From 16e09d778d4ac1e0842a4e6c7549f5e294ccc853 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 6 Jun 2026 23:34:33 -0400 Subject: [PATCH] Fix auto ceiling height sync --- packages/core/src/index.ts | 2 + packages/core/src/lib/space-detection.test.ts | 92 +++++++ packages/core/src/lib/space-detection.ts | 242 +++++++++++++++--- packages/nodes/src/wall/move-tool.tsx | 12 +- 4 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/lib/space-detection.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15ba572b..b9205d7e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,7 @@ export { } from './lib/door-operation' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { + type AutoCeilingPlanningContext, type AutoCeilingSyncPlan, type AutoSlabSyncPlan, detectSpacesForLevel, @@ -69,6 +70,7 @@ export { pauseSpaceDetection, planAutoCeilingsForLevel, planAutoSlabsForLevel, + projectAutoSlabsForPlan, resumeSpaceDetection, type Space, wallTouchesOthers, diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts new file mode 100644 index 00000000..22a5fa2c --- /dev/null +++ b/packages/core/src/lib/space-detection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'bun:test' +import { CeilingNode, SlabNode, WallNode } from '../schema' +import { planAutoCeilingsForLevel } from './space-detection' + +const square: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +function roomPolygon() { + return square.map(([x, y]) => ({ x, y })) +} + +function squareWalls(height = 2.5) { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0], height }), + WallNode.parse({ start: [4, 0], end: [4, 3], height }), + WallNode.parse({ start: [4, 3], end: [0, 3], height }), + WallNode.parse({ start: [0, 3], end: [0, 0], height }), + ] +} + +function slab(elevation: number) { + return SlabNode.parse({ + polygon: square, + elevation, + autoFromWalls: true, + }) +} + +describe('planAutoCeilingsForLevel', () => { + test('creates auto ceilings at the top of the room walls', () => { + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + walls: squareWalls(), + slabs: [slab(0.05)], + }).create[0] + + expect(created?.height).toBeCloseTo(2.55) + }) + + test('updates existing auto ceiling height when the slab elevation changes', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + walls: squareWalls(), + slabs: [slab(0.4)], + }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(ceiling.id) + expect(plan.update[0]?.data.polygon).toBeUndefined() + expect(plan.update[0]?.data.height).toBeCloseTo(2.9) + }) + + test('updates existing auto ceiling height when wall height changes', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.55, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + walls: squareWalls(3), + slabs: [slab(0.05)], + }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.data.height).toBeCloseTo(3.05) + }) + + test('does not replace a manual ceiling with an auto ceiling', () => { + const manualCeiling = CeilingNode.parse({ + polygon: square, + height: 2.5, + autoFromWalls: false, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { + walls: squareWalls(), + slabs: [slab(0.4)], + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + }) +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 5001d9ac..79e69c86 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -41,6 +41,10 @@ type DetectedRoom = { bbox: ReturnType } +type DetectedCeilingRoom = DetectedRoom & { + ceilingHeight: number +} + export type AutoSlabSyncPlan = { create: SlabNodeType[] update: Array<{ id: SlabNodeType['id']; data: Partial }> @@ -55,9 +59,16 @@ export type AutoCeilingSyncPlan = { const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 +const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 +const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 + +export type AutoCeilingPlanningContext = { + walls?: WallNode[] + slabs?: SlabNodeType[] +} function pointFromTuple(point: [number, number]): Point2D { return { x: point[0], y: point[1] } @@ -186,6 +197,100 @@ function bboxOverlapArea(a: ReturnType, b: ReturnType pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, + ) + + return matchingPoints.length >= 2 +} + +function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { + if (slab.polygon.length < 3) return false + const slabPolygon = slab.polygon.map(pointFromTuple) + if (!pointInPolygon(point, slabPolygon)) return false + + for (const hole of slab.holes ?? []) { + if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { + return false + } + } + + return true +} + +function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) { + if (slab.polygon.length < 3) return false + if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) { + return true + } + return pointIsOnSlab(polygonCentroid(roomPolygon), slab) +} + +function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) { + let maxElevation = 0 + + for (const slab of slabs) { + if (!slabSupportsRoom(roomPolygon, slab)) continue + maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION) + } + + return maxElevation +} + +function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) { + let maxHeight = 0 + + for (const wall of walls) { + if (!wallBoundsRoom(wall, roomPolygon)) continue + const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT + if (Number.isFinite(height)) { + maxHeight = Math.max(maxHeight, height) + } + } + + return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT +} + +function resolveAutoCeilingHeight( + roomPolygon: Point2D[], + context: AutoCeilingPlanningContext = {}, +) { + return ( + resolveRoomSlabElevation(roomPolygon, context.slabs) + + resolveRoomWallHeight(roomPolygon, context.walls) + ) +} + function getWallDirection(wall: Pick) { const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] @@ -481,6 +586,7 @@ function wallGeometrySignature(wall: WallNode) { wall.end[0].toFixed(4), wall.end[1].toFixed(4), (wall.thickness ?? 0.2).toFixed(4), + (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } @@ -489,6 +595,48 @@ function levelWallSnapshot(walls: WallNode[]) { return walls.map(wallGeometrySignature).sort().join('||') } +function slabGeometrySignature(slab: SlabNodeType) { + const polygon = slab.polygon + .map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`) + .join(';') + const holes = (slab.holes ?? []) + .map((hole) => hole.map((point) => `${point[0].toFixed(4)},${point[1].toFixed(4)}`).join(';')) + .join('/') + + return [slab.id, (slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4), polygon, holes].join( + '|', + ) +} + +function levelSlabSnapshot(slabs: SlabNodeType[]) { + return slabs.map(slabGeometrySignature).sort().join('||') +} + +function levelStructureSnapshots(nodes: Record) { + const byLevel = new Map() + const getEntry = (levelId: string) => { + const entry = byLevel.get(levelId) ?? { walls: [], slabs: [] } + byLevel.set(levelId, entry) + return entry + } + + for (const node of Object.values(nodes)) { + if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue + if ((node as any).type === 'wall') { + getEntry((node as any).parentId).walls.push(node as WallNode) + } else if ((node as any).type === 'slab') { + getEntry((node as any).parentId).slabs.push(SlabNode.parse(node)) + } + } + + const snapshots = new Map() + for (const [levelId, entry] of byLevel.entries()) { + snapshots.set(levelId, `${levelWallSnapshot(entry.walls)}##${levelSlabSnapshot(entry.slabs)}`) + } + + return snapshots +} + function buildSpace(levelId: string, polygon: Point2D[]): Space { const signature = polygonSignature(polygon) return { @@ -654,18 +802,44 @@ function syncAutoSlabsForLevel( if (plan.create.length > 0) { sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) } + + return plan +} + +export function projectAutoSlabsForPlan( + existingSlabs: SlabNodeType[], + plan: AutoSlabSyncPlan, +): SlabNodeType[] { + const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab])) + + for (const id of plan.delete) { + slabsById.delete(id) + } + + for (const update of plan.update) { + const slab = slabsById.get(update.id) + if (!slab) continue + slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data })) + } + + for (const slab of plan.create) { + slabsById.set(slab.id, slab) + } + + return [...slabsById.values()] } export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], + context: AutoCeilingPlanningContext = {}, ): AutoCeilingSyncPlan { const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualSignatures = new Set( manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), ) - const detected: DetectedRoom[] = roomPolygons + const detected: DetectedCeilingRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -681,6 +855,7 @@ export function planAutoCeilingsForLevel( centroid: polygonCentroid(room.poly), area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), + ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) .filter(({ sig }) => !manualSignatures.has(sig)) @@ -698,7 +873,7 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const updatesById = new Map() const autoBySignature = new Map() for (const entry of existingAutoMeta) { @@ -711,7 +886,10 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(existing.ceiling.id) - updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple)) + updatesById.set(existing.ceiling.id, { + polygon: room.poly.map(pointToTuple), + height: room.ceilingHeight, + }) }) const remainingDetected = detected @@ -746,7 +924,10 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(bestMatch.entry.ceiling.id) - updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple)) + updatesById.set(bestMatch.entry.ceiling.id, { + polygon: room.poly.map(pointToTuple), + height: room.ceilingHeight, + }) } const ceilingsToDelete = existingAuto @@ -756,12 +937,21 @@ export function planAutoCeilingsForLevel( const ceilingsToUpdate = existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) .flatMap((ceiling) => { - const polygon = updatesById.get(ceiling.id) - if (!polygon) return [] + const update = updatesById.get(ceiling.id) + if (!update) return [] - return sameTuplePolygon(ceiling.polygon, polygon) - ? [] - : [{ id: ceiling.id, data: { polygon } }] + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { + data.polygon = update.polygon + } + if ( + Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > + CEILING_HEIGHT_EPSILON + ) { + data.height = update.height + } + + return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] }) const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -780,7 +970,7 @@ export function planAutoCeilingsForLevel( name, polygon: room.poly.map(pointToTuple), holes: [], - height: DEFAULT_AUTO_CEILING_HEIGHT, + height: room.ceilingHeight, autoFromWalls: true, }), ) @@ -798,8 +988,9 @@ function syncAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], sceneStore: any, + context: AutoCeilingPlanningContext = {}, ) { - const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings) + const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context) if (plan.delete.length > 0) { sceneStore.getState().deleteNodes(plan.delete) @@ -882,17 +1073,15 @@ function runSpaceDetection( ) } - syncAutoSlabsForLevel( - levelId, - roomPolygons, - slabs.map((slab: any) => SlabNode.parse(slab)), - sceneStore, - ) + const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) + const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) + const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) syncAutoCeilingsForLevel( levelId, roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, + { walls, slabs: projectedSlabs }, ) for (const space of spaces) { @@ -935,21 +1124,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => if (getSceneHistoryPauseDepth() > 0) return const nodes = state.nodes - const wallsByLevel = new Map() - - for (const node of Object.values(nodes)) { - if (node && (node as any).type === 'wall' && (node as any).parentId) { - const levelId = (node as any).parentId as string - const levelWalls = wallsByLevel.get(levelId) ?? [] - levelWalls.push(node as WallNode) - wallsByLevel.set(levelId, levelWalls) - } - } - - const currentSnapshots = new Map() - for (const [levelId, walls] of wallsByLevel.entries()) { - currentSnapshots.set(levelId, levelWallSnapshot(walls)) - } + const currentSnapshots = levelStructureSnapshots(nodes) // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) // every paused change once detection resumes. Whatever the AI built while @@ -984,7 +1159,8 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } finally { resumeSceneHistory(sceneStore) previousSnapshots.clear() - for (const [levelId, snapshot] of currentSnapshots.entries()) { + const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) + for (const [levelId, snapshot] of postRunSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) } isProcessing = false diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index d9503aa7..72110c61 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -14,6 +14,7 @@ import { planAutoCeilingsForLevel, planAutoSlabsForLevel, planWallMoveJunctions, + projectAutoSlabsForPlan, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -266,10 +267,15 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // existing room → existing slab" logic sees stable IDs across // ticks. Without this anchor, IDs would drift as overrides // re-flowed through the planner. - const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) + const existingSlabs = getLevelSlabs(levelId, sceneState.nodes) + const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs) const ceilingPlan = planAutoCeilingsForLevel( roomPolygons, getLevelCeilings(levelId, sceneState.nodes), + { + walls: levelWalls, + slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan), + }, ) latestSurfacePlans = { slabs: slabPlan, ceilings: ceilingPlan } @@ -281,8 +287,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { touchedSlabIds.add(update.id as AnyNodeId) } for (const update of ceilingPlan.update) { - if (update.data.polygon === undefined) continue - overrideEntries.push([update.id, { polygon: update.data.polygon }]) + if (update.data.polygon === undefined && update.data.height === undefined) continue + overrideEntries.push([update.id, update.data as Record]) touchedCeilingIds.add(update.id as AnyNodeId) }