import { GRID_STEP_N } from './space-geometry'; type DataRecord = Record; 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): 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(); 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, }; }