Files
houseplan-card/src/draft-live-preflight.ts
T
2026-09-05 14:04:26 +03:00

407 lines
16 KiB
TypeScript

import { GRID_STEP_N } from './space-geometry';
type DataRecord = Record<string, unknown>;
type Point = [number, number];
export interface DraftLiveSeed {
draftId: string;
segmentId: string;
index: number;
a: Point;
b: Point;
cm: number;
}
export interface DraftLiveProjection {
space: DataRecord & {
rooms: DataRecord[];
walls: DataRecord[];
wall_segments: DataRecord[];
open_spans: DataRecord[];
openings: DataRecord[];
partitions: DataRecord[];
room_drafts: DataRecord[];
wall_columns: DataRecord[];
};
roomIds: string[];
segmentCount: number;
}
interface LocalSegment {
key: string;
kind: 'partition' | 'draft';
source: DataRecord;
draft?: DataRecord;
index?: number;
a: Point;
b: Point;
cm: number;
}
const asRecord = (value: unknown): DataRecord | null =>
typeof value === 'object' && value !== null ? value as DataRecord : null;
const recordsOf = (value: unknown): DataRecord[] =>
Array.isArray(value) ? value.flatMap((item) => {
const record = asRecord(item);
return record ? [record] : [];
}) : [];
const pointOf = (value: unknown): Point | null => {
if (!Array.isArray(value)) return null;
const x = Number(value[0]), y = Number(value[1]);
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
};
const pointsOf = (value: unknown): Point[] =>
Array.isArray(value) ? value.flatMap((item) => {
const point = pointOf(item);
return point ? [point] : [];
}) : [];
const stable = (value: unknown): string => JSON.stringify(value ?? null);
const pointKey = (point: Point, factor = 1e8): string =>
`${Math.round(point[0] * factor)},${Math.round(point[1] * factor)}`;
const samePoint = (left: Point, right: Point, epsilon = 1e-8): boolean =>
Math.hypot(left[0] - right[0], left[1] - right[1]) <= epsilon;
const distanceToSegment = (point: Point, a: Point, b: Point): number => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const denominator = dx * dx + dy * dy;
const t = denominator > 0 ? Math.max(0, Math.min(1,
((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / denominator)) : 0;
return Math.hypot(point[0] - a[0] - t * dx, point[1] - a[1] - t * dy);
};
const cross = (a: Point, b: Point, c: Point): number =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const segmentDistance = (a: Point, b: Point, c: Point, d: Point): number => {
const epsilon = GRID_STEP_N * 0.0002;
const abC = cross(a, b, c), abD = cross(a, b, d);
const cdA = cross(c, d, a), cdB = cross(c, d, b);
if (abC * abD <= epsilon * epsilon && cdA * cdB <= epsilon * epsilon
&& Math.min(a[0], b[0]) <= Math.max(c[0], d[0]) + epsilon
&& Math.min(c[0], d[0]) <= Math.max(a[0], b[0]) + epsilon
&& Math.min(a[1], b[1]) <= Math.max(c[1], d[1]) + epsilon
&& Math.min(c[1], d[1]) <= Math.max(a[1], b[1]) + epsilon) return 0;
return Math.min(
distanceToSegment(a, c, d), distanceToSegment(b, c, d),
distanceToSegment(c, a, b), distanceToSegment(d, a, b),
);
};
const roomPoints = (room: DataRecord): Point[] => {
const poly = pointsOf(room.poly);
if (poly.length >= 3) return poly;
const x = Number(room.x), y = Number(room.y);
const w = Number(room.w), h = Number(room.h);
return [x, y, w, h].every(Number.isFinite)
? [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
: [];
};
const roomEdges = (room: DataRecord): Array<[Point, Point]> => {
const points = roomPoints(room);
return points.map((point, index) => [point, points[(index + 1) % points.length]]);
};
const cmUnits = (cm: number, cellCm: number): number =>
(Math.max(0, Number(cm) || 0) / cellCm) * GRID_STEP_N;
const segmentCm = (segment: DataRecord, fallback = 15): number => {
const value = Number(segment.cm);
return Number.isFinite(value) ? Math.max(0, value) : fallback;
};
const segmentAngle = (segment: Pick<LocalSegment, 'a' | 'b'>): number =>
Math.atan2(segment.b[1] - segment.a[1], segment.b[0] - segment.a[0]);
const collinear = (left: LocalSegment, right: LocalSegment): boolean => {
let delta = Math.abs(segmentAngle(left) - segmentAngle(right));
delta %= Math.PI;
delta = Math.min(delta, Math.PI - delta);
return delta <= Math.PI / 180 && Math.abs(left.cm - right.cm) <= 1e-6;
};
function localSegments(space: DataRecord): {
segments: LocalSegment[];
malformedPartitions: DataRecord[];
malformedDrafts: DataRecord[];
} {
const segments: LocalSegment[] = [];
const malformedPartitions: DataRecord[] = [];
const malformedDrafts: DataRecord[] = [];
for (const partition of recordsOf(space.partitions)) {
const a = pointOf(partition.a), b = pointOf(partition.b);
if (!a || !b) {
malformedPartitions.push(partition);
continue;
}
segments.push({
key: `partition:${String(partition.id || stable(partition))}`,
kind: 'partition', source: partition, a, b, cm: segmentCm(partition, 0),
});
}
for (const draft of recordsOf(space.room_drafts)) {
const points = pointsOf(draft.points);
const rawPoints = Array.isArray(draft.points) ? draft.points : [];
const rawSegments = recordsOf(draft.segments);
if (points.length !== rawPoints.length || points.length < 2
|| rawSegments.length < points.length - 1) {
malformedDrafts.push(draft);
continue;
}
for (let index = 0; index + 1 < points.length; index++) {
const source = rawSegments[index];
segments.push({
key: `draft:${String(draft.id || '')}:${index}:${String(source.id || '')}`,
kind: 'draft', source, draft, index,
a: points[index], b: points[index + 1], cm: segmentCm(source),
});
}
}
return { segments, malformedPartitions, malformedDrafts };
}
function dedupe(records: readonly DataRecord[]): DataRecord[] {
const seen = new Set<string>();
return records.filter((record) => {
const identity = `${String(record.id || '')}|${stable(record)}`;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
function draftSlices(selected: readonly LocalSegment[], malformed: readonly DataRecord[]): DataRecord[] {
const slices = selected.filter((segment) => segment.kind === 'draft').map((segment) => {
const draft = segment.draft as DataRecord;
const index = segment.index as number;
return {
...draft,
id: `${String(draft.id || 'draft')}@local:${index}`,
points: [[...segment.a], [...segment.b]],
segments: [{ ...segment.source }],
};
});
return dedupe([...slices, ...malformed]);
}
/** The one segment appended by the current active draft click. */
export function draftLiveSeed(spaceInput: unknown, draftId: string): DraftLiveSeed | null {
const space = asRecord(spaceInput);
const draft = recordsOf(space?.room_drafts).find((item) => item.id === draftId);
if (!draft) return null;
const points = pointsOf(draft.points);
const segments = recordsOf(draft.segments);
const index = points.length - 2;
if (index < 0 || segments.length !== points.length - 1) return null;
const segment = segments[index];
const cm = Number(segment.cm);
if (!Number.isFinite(cm) || cm < 0 || samePoint(points[index], points[index + 1])) return null;
return {
draftId,
segmentId: String(segment.id || `${draftId}-${index}`),
index,
a: points[index],
b: points[index + 1],
cm,
};
}
/**
* Prove that the editor changed exactly one active draft by appending one
* point/segment. Unknown or mixed geometry falls back to the generic barrier.
*/
export function isSingleDraftAppend(
beforeInput: unknown, afterSpaceInput: unknown, draftId: string,
): boolean {
const before = asRecord(beforeInput), after = asRecord(afterSpaceInput);
if (!before || !after || String(before.spaceId || '') !== String(after.id || '')) return false;
const arrays = [
'rooms', 'openings', 'walls', 'wall_segments', 'open_spans',
'partitions', 'wall_columns', 'decor',
];
if (arrays.some((key) => stable(before[key] ?? []) !== stable(after[key] ?? []))) return false;
const transform = asRecord(before.plan_transform) || {};
const transformAfter: DataRecord = {};
for (const key of [
'plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle',
]) if (after[key] !== undefined) transformAfter[key] = after[key];
if (stable(transform) !== stable(transformAfter)) return false;
const beforeDrafts = recordsOf(before.room_drafts);
const afterDrafts = recordsOf(after.room_drafts);
const previous = beforeDrafts.find((draft) => draft.id === draftId);
const next = afterDrafts.find((draft) => draft.id === draftId);
if (!next) return false;
const otherBefore = beforeDrafts.filter((draft) => draft.id !== draftId);
const otherAfter = afterDrafts.filter((draft) => draft.id !== draftId);
if (stable(otherBefore) !== stable(otherAfter)) return false;
const previousPoints = pointsOf(previous?.points);
const nextPoints = pointsOf(next.points);
const previousSegments = recordsOf(previous?.segments);
const nextSegments = recordsOf(next.segments);
// The first point is transient by contract and has no config record. Thus
// the first accepted segment creates a two-point draft from an absent
// baseline; later clicks append one point and one segment to that record.
if (previous) {
if (nextPoints.length !== previousPoints.length + 1
|| nextSegments.length !== previousSegments.length + 1
|| stable(nextPoints.slice(0, -1)) !== stable(previousPoints)
|| stable(nextSegments.slice(0, -1)) !== stable(previousSegments)) return false;
} else if (nextPoints.length !== 2 || nextSegments.length !== 1) return false;
if (nextSegments.length !== nextPoints.length - 1) return false;
const last = nextSegments[nextSegments.length - 1];
return Number.isFinite(Number(last.cm))
&& !samePoint(nextPoints[nextPoints.length - 2], nextPoints[nextPoints.length - 1]);
}
/**
* Build a bounded runtime-only proof around the newly appended draft segment.
* Stored geometry is never mutated; generic/full validation remains the
* terminal authority.
*/
export function draftLiveCandidateSpace(
spaceInput: unknown, seed: DraftLiveSeed,
): DraftLiveProjection | null {
const space = asRecord(spaceInput);
if (!space) return null;
const cellCmRaw = Number(space.cell_cm);
const cellCm = Number.isFinite(cellCmRaw) && cellCmRaw > 0 ? cellCmRaw : 5;
const clearance = cmUnits(5, cellCm);
const seedHalf = cmUnits(seed.cm, cellCm) / 2;
const allRooms = recordsOf(space.rooms);
const catalogue = recordsOf(space.wall_segments);
const catalogueById = new Map(catalogue.map((record) => [String(record.id || ''), record]));
const legacyWalls = recordsOf(space.walls);
const roomEdgesWithHalf = (room: DataRecord) => {
const ids = Array.isArray(room.wall_ids) ? room.wall_ids.map(String) : [];
return roomEdges(room).map(([a, b], index) => {
const candidates = [catalogueById.get(ids[index]), ...legacyWalls.filter((wall) => {
const wa = pointOf(wall.a), wb = pointOf(wall.b);
if (wa && wb) return segmentDistance(a, b, wa, wb) <= GRID_STEP_N * 0.02;
const [midpoint] = String(wall.key || '').split('@');
const center = pointOf(midpoint?.split(',').map(Number));
return !!center && distanceToSegment(center, a, b) <= GRID_STEP_N * 0.02;
})].filter((value): value is DataRecord => !!value);
const cm = Math.max(15, ...candidates.map((value) => segmentCm(value, 15)));
return { a, b, half: cmUnits(cm, cellCm) / 2 };
});
};
const directRoomIds = allRooms.flatMap((room) => {
const id = String(room.id || '');
const near = roomEdgesWithHalf(room).some(({ a, b, half }) =>
segmentDistance(seed.a, seed.b, a, b) <= seedHalf + half + clearance);
return id && near ? [id] : [];
});
const selectedRoomIds = new Set(directRoomIds);
if (directRoomIds.length) {
const directBounds = allRooms.filter((room) => selectedRoomIds.has(String(room.id || '')))
.flatMap(roomEdgesWithHalf);
for (const room of allRooms) {
const id = String(room.id || '');
if (!id || selectedRoomIds.has(id)) continue;
if (roomEdgesWithHalf(room).some(({ a, b, half }) => directBounds.some((other) =>
segmentDistance(a, b, other.a, other.b)
<= half + other.half + GRID_STEP_N * 0.02))) selectedRoomIds.add(id);
}
}
const roomIds = [...selectedRoomIds];
const selectedRooms = allRooms.filter((room) => selectedRoomIds.has(String(room.id || '')));
const selectedWallIds = new Set(selectedRooms.flatMap((room) => (
Array.isArray(room.wall_ids) ? room.wall_ids.map(String) : []
)));
const wallSegments = recordsOf(space.wall_segments).filter((segment) =>
selectedWallIds.has(String(segment.id || '')));
const selectedEdges = selectedRooms.flatMap(roomEdges);
const walls = recordsOf(space.walls).filter((wall) => {
const a = pointOf(wall.a), b = pointOf(wall.b);
if (a && b) return selectedEdges.some(([c, d]) =>
segmentDistance(a, b, c, d) <= GRID_STEP_N * 0.02);
const [midpoint] = String(wall.key || '').split('@');
const center = pointOf(midpoint?.split(',').map(Number));
if (center) return selectedEdges.some(([c, d]) =>
distanceToSegment(center, c, d) <= GRID_STEP_N * 0.02);
// Unlocatable legacy data stays in the proof: malformed records must not
// become safe merely because the bounded projector cannot position them.
return true;
});
const { segments, malformedPartitions, malformedDrafts } = localSegments(space);
const selectedKeys = new Set(segments.flatMap((segment) => {
const near = segmentDistance(seed.a, seed.b, segment.a, segment.b)
<= seedHalf + cmUnits(segment.cm, cellCm) / 2 + clearance;
return near ? [segment.key] : [];
}));
// Complete collinear runs so the 20 cm rule measures a wall, not one atom.
let grew = true;
while (grew) {
grew = false;
const selected = segments.filter((segment) => selectedKeys.has(segment.key));
for (const candidate of segments) {
if (selectedKeys.has(candidate.key)) continue;
if (selected.some((current) => collinear(current, candidate)
&& (samePoint(current.a, candidate.a) || samePoint(current.a, candidate.b)
|| samePoint(current.b, candidate.a) || samePoint(current.b, candidate.b)))) {
selectedKeys.add(candidate.key);
grew = true;
}
}
}
// Retain every ray at the selected junctions, but do not recurse through
// the newly added rays' far endpoints into the whole connected floor.
const junctions = new Set(segments.filter((segment) => selectedKeys.has(segment.key))
.flatMap((segment) => [pointKey(segment.a), pointKey(segment.b)]));
for (const candidate of segments) {
if (junctions.has(pointKey(candidate.a)) || junctions.has(pointKey(candidate.b)))
selectedKeys.add(candidate.key);
}
const selectedSegments = segments.filter((segment) => selectedKeys.has(segment.key));
const partitions = selectedSegments.filter((segment) => segment.kind === 'partition')
.map((segment) => segment.source).concat(malformedPartitions);
const roomDrafts = draftSlices(selectedSegments, malformedDrafts);
const wallColumns = recordsOf(space.wall_columns).filter((column) => {
const center = pointOf(column.center);
if (!center) return true;
const half = cmUnits(Number(column.cm), cellCm) / 2;
const radius = column.shape === 'circle' ? half : half * Math.SQRT2;
return distanceToSegment(center, seed.a, seed.b) <= seedHalf + radius + clearance;
});
const partitionIds = new Set(partitions.map((partition) => String(partition.id || '')));
const wallIds = new Set(wallSegments.map((segment) => String(segment.id || '')));
const openings = recordsOf(space.openings).filter((opening) => {
const host = asRecord(opening.host);
if (host?.kind === 'partition') return partitionIds.has(String(host.id || ''));
if (host?.kind === 'wall' && wallIds.has(String(host.id || ''))) return true;
const center = pointOf([opening.x, opening.y]);
return !center || distanceToSegment(center, seed.a, seed.b) <= seedHalf + clearance;
});
const openSpans = recordsOf(space.open_spans).filter((span) => {
const a = pointOf(span.a), b = pointOf(span.b);
return !a || !b || segmentDistance(seed.a, seed.b, a, b) <= seedHalf + clearance;
});
return {
space: {
...space,
rooms: selectedRooms,
walls,
wall_segments: wallSegments,
open_spans: openSpans,
openings,
partitions,
room_drafts: roomDrafts,
wall_columns: wallColumns,
},
roomIds,
segmentCount: wallSegments.length + selectedSegments.length,
};
}