From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/14] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed20..eefaa2a79 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304..112273a41 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e8724081..5563268b8 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 538286580..69a3d5ee3 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635..bac2b78fc 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/14] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f85723..d7c86be96 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e9..df67ca169 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca75..a3eccc116 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1dd..b86e426c4 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From b408c802b38061153faae117c9a0981657d65d1c Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Jul 2026 13:18:45 +0530 Subject: [PATCH 03/14] fix(editor): split crossing walls and room surfaces --- packages/core/src/lib/space-detection.ts | 23 ++- packages/core/src/store/history-control.ts | 4 + .../tools/wall/wall-drafting.test.ts | 133 +++++++++++++++++- .../components/tools/wall/wall-drafting.ts | 85 +++++++---- .../tools/wall/wall-snap-geometry.ts | 85 ++++++++++- 5 files changed, 296 insertions(+), 34 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 41302a742..e34f82e31 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -19,8 +19,10 @@ import { } from '../services/storey' import { getSceneHistoryPauseDepth, + isSceneCommitTransactionActive, pauseSceneHistory, resumeSceneHistory, + subscribeSceneCommits, } from '../store/history-control' import { getClampedWallCurveOffset, @@ -1456,11 +1458,10 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) let isProcessing = false - const unsubscribe = sceneStore.subscribe((state: any) => { + const processNodes = (nodes: any) => { if (isProcessing) return if (getSceneHistoryPauseDepth() > 0) return - const nodes = state.nodes const currentSnapshots = levelStructureSnapshots(nodes) // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) @@ -1508,9 +1509,25 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } isProcessing = false } + } + + const unsubscribeStore = sceneStore.subscribe((state: any) => { + // A single semantic edit can contain several store writes (for example: + // create both wall halves, delete the original, then create the divider). + // Those intermediate graphs are intentionally invalid. Reconcile once from + // the coalesced scene commit below instead of demoting surfaces against a + // transient half-written topology. + if (isSceneCommitTransactionActive()) return + processNodes(state.nodes) + }) + const unsubscribeCommits = subscribeSceneCommits((commit) => { + processNodes(commit.current.nodes) }) - return unsubscribe + return () => { + unsubscribeStore() + unsubscribeCommits() + } } export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 4ee927b0a..f5bd00cad 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -119,6 +119,10 @@ function beginSceneCommitTransaction(): void { sceneCommitTransactionDepth += 1 } +export function isSceneCommitTransactionActive(): boolean { + return sceneCommitTransactionDepth > 0 +} + function pendingSceneCommitIsNoOp(): boolean { return Boolean( pendingSceneCommit && diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 40703ca04..26f92356e 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -2,8 +2,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, + CeilingNode, DoorNode as DoorSchema, + detectSpacesForLevel, + initSpaceDetectionSync, runAsSingleSceneHistoryStep, + SlabNode, useScene, type WallNode, WallNode as WallSchema, @@ -49,7 +53,12 @@ function seedLevel(walls: WallNode[], extraNodes: AnyNode[] = []) { parentId: null, visible: true, metadata: {}, - children: walls.map((wall) => wall.id), + children: [ + ...walls.map((wall) => wall.id), + ...extraNodes + .filter((node) => node.parentId === LEVEL_ID) + .map((node) => node.id as AnyNodeId), + ], level: 0, } as AnyNode, ], @@ -177,6 +186,128 @@ describe('createWallOnCurrentLevel', () => { expect(created).not.toBeNull() expect(useScene.temporal.getState().pastStates.length - before).toBe(1) }) + + test('a divider commit splits the room slab and ceiling into two scene nodes', () => { + const walls = [ + makeWall([0, 0], [4, 0], 'wall_bottom'), + makeWall([4, 0], [4, 3], 'wall_right'), + makeWall([4, 3], [0, 3], 'wall_top'), + makeWall([0, 3], [0, 0], 'wall_left'), + ] + const slab = SlabNode.parse({ + id: 'slab_main', + parentId: LEVEL_ID, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: LEVEL_ID, + polygon: slab.polygon, + autoFromWalls: true, + }) + seedLevel(walls, [slab, ceiling]) + const editorState = { + spaces: {}, + setSpaces(spaces: Record) { + editorState.spaces = spaces + }, + } + const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + + try { + const created = createWallOnCurrentLevel([2, 0], [2, 3]) + + expect(created).not.toBeNull() + const nodes = Object.values(useScene.getState().nodes) + const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall') + const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls) + const slabs = nodes.filter((node) => node.type === 'slab').map((node) => SlabNode.parse(node)) + const ceilings = nodes + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + + expect(roomPolygons).toHaveLength(2) + expect(slabs).toHaveLength(2) + expect(ceilings).toHaveLength(2) + expect(slabs.every((surface) => surface.autoFromWalls)).toBe(true) + expect(ceilings.every((surface) => surface.autoFromWalls)).toBe(true) + const committedLevel = useScene.getState().nodes[LEVEL_ID] + expect(committedLevel?.type).toBe('level') + if (committedLevel?.type !== 'level') return + const treeChildren = committedLevel.children.map((id) => useScene.getState().nodes[id]) + expect(treeChildren.filter((node) => node?.type === 'slab')).toHaveLength(2) + expect(treeChildren.filter((node) => node?.type === 'ceiling')).toHaveLength(2) + } finally { + unsubscribe() + } + }) + + test('crossing divider walls split into four joined segments and four room surfaces', () => { + const walls = [ + makeWall([0, 0], [4, 0], 'wall_bottom'), + makeWall([4, 0], [4, 3], 'wall_right'), + makeWall([4, 3], [0, 3], 'wall_top'), + makeWall([0, 3], [0, 0], 'wall_left'), + ] + const slab = SlabNode.parse({ + id: 'slab_main', + parentId: LEVEL_ID, + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: LEVEL_ID, + polygon: slab.polygon, + autoFromWalls: true, + }) + seedLevel(walls, [slab, ceiling]) + const editorState = { + spaces: {}, + setSpaces(spaces: Record) { + editorState.spaces = spaces + }, + } + const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + + try { + expect(createWallOnCurrentLevel([0, 0], [4, 3])).not.toBeNull() + const beforeCrossing = useScene.temporal.getState().pastStates.length + expect(createWallOnCurrentLevel([0, 3], [4, 0])).not.toBeNull() + expect(useScene.temporal.getState().pastStates.length - beforeCrossing).toBe(1) + + const nodes = Object.values(useScene.getState().nodes) + const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall') + const centerSegments = postCommitWalls.filter( + (wall) => + (wall.start[0] === 2 && wall.start[1] === 1.5) || + (wall.end[0] === 2 && wall.end[1] === 1.5), + ) + const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls) + + expect(postCommitWalls).toHaveLength(8) + expect(centerSegments).toHaveLength(4) + expect(roomPolygons).toHaveLength(4) + expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(4) + expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(4) + + expect(createWallOnCurrentLevel([0, 3], [4, 0])).toBeNull() + expect(levelWalls()).toHaveLength(8) + } finally { + unsubscribe() + } + }) }) describe('resolveEndpointWallSplit', () => { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index e588ce5a8..b87694d13 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -19,6 +19,7 @@ import { resolveSnapFlags } from '../../../lib/snapping-mode' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import { distanceSquared, + findWallSegmentIntersections, findWallSnapTarget, findWallSpecialPointSnap, projectPointOntoWall, @@ -27,6 +28,7 @@ import { type WallDraftSnapResult, type WallPlanPoint, type WallSnapRadii, + wallSegmentsCoverSegment, } from './wall-snap-geometry' // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so @@ -470,7 +472,7 @@ export function createWallOnCurrentLevel( }, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId - const { createNode, createNodes, deleteNode, nodes } = useScene.getState() + const { createNodes, deleteNode, nodes } = useScene.getState() const { updateNodes } = useScene.getState() if (!(currentLevelId && isSegmentLongEnough(start, end))) { @@ -531,40 +533,75 @@ export function createWallOnCurrentLevel( return null } - const duplicateWall = workingWalls.some( - (wall) => - (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) || - (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)), - ) - if (duplicateWall) { + if (wallSegmentsCoverSegment(resolvedStart, resolvedEnd, workingWalls)) { return null } + const interiorEpsilon = 1e-6 + const splitPoints: WallPlanPoint[] = [] + const crossings = findWallSegmentIntersections(resolvedStart, resolvedEnd, workingWalls) + .filter( + (crossing) => crossing.draftT > interiorEpsilon && crossing.draftT < 1 - interiorEpsilon, + ) + .sort((left, right) => left.draftT - right.draftT) + + for (const crossing of crossings) { + if (crossing.wallT > interiorEpsilon && crossing.wallT < 1 - interiorEpsilon) { + const splitHost = splitWallIfNeeded( + { wallId: crossing.wallId, point: crossing.point }, + workingWalls, + nodes, + createNodes, + updateNodes, + deleteNode, + ) + if (!splitHost || splitHost.walls.some((wall) => wall.id === crossing.wallId)) { + continue + } + workingWalls = splitHost.walls + } + + if (!splitPoints.some((point) => pointsEqual(point, crossing.point))) { + splitPoints.push(crossing.point) + } + } + const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length // A placed wall preset seeds `toolDefaults.wall` (thickness, height, // materials, sides) before the tool activates; merge those first so the // drawn wall reproduces the preset. Identity + endpoints always win. const defaults = useEditor.getState().toolDefaults.wall ?? {} - const wall = WallSchema.parse({ - ...defaults, - name: `Wall ${wallCount + 1}`, - start: resolvedStart, - end: resolvedEnd, - }) + const vertices = [resolvedStart, ...splitPoints, resolvedEnd] + const walls = vertices.slice(0, -1).map((segmentStart, index) => + WallSchema.parse({ + ...defaults, + name: `Wall ${wallCount + index + 1}`, + start: segmentStart, + end: vertices[index + 1]!, + }), + ) - createNode(wall, currentLevelId) - const createdWall = useScene.getState().nodes[wall.id] - if (createdWall?.type === 'wall') { - useScene.getState().updateNode( - createdWall.id, - resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { - maxElevation: options?.supportCap ?? null, - }), - ) + createNodes(walls.map((wall) => ({ node: wall, parentId: currentLevelId }))) + const committedNodes = useScene.getState().nodes + const supportUpdates = walls.flatMap((wall) => { + const createdWall = committedNodes[wall.id] + if (createdWall?.type !== 'wall') return [] + return [ + { + id: createdWall.id, + data: resolveWallSupportSlabPatch(createdWall, committedNodes, { + maxElevation: options?.supportCap ?? null, + }), + }, + ] + }) + if (supportUpdates.length > 0) { + useScene.getState().updateNodes(supportUpdates) } sfxEmitter.emit('sfx:structure-build') - const committedWall = useScene.getState().nodes[wall.id] - return committedWall?.type === 'wall' ? committedWall : wall + const terminalWall = walls.at(-1)! + const committedWall = useScene.getState().nodes[terminalWall.id] + return committedWall?.type === 'wall' ? committedWall : terminalWall }) } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 5a6ae7cb6..ac67de4b9 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -188,7 +188,7 @@ function segmentIntersection( a2: WallPlanPoint, b1: WallPlanPoint, b2: WallPlanPoint, -): WallPlanPoint | null { +): { point: WallPlanPoint; firstT: number; secondT: number } | null { const rx = a2[0] - a1[0] const rz = a2[1] - a1[1] const sx = b2[0] - b1[0] @@ -202,7 +202,80 @@ function segmentIntersection( const u = (qpx * rz - qpz * rx) / denom if (t < 0 || t > 1 || u < 0 || u > 1) return null - return [a1[0] + t * rx, a1[1] + t * rz] + return { + point: [a1[0] + t * rx, a1[1] + t * rz], + firstT: t, + secondT: u, + } +} + +export function findWallSegmentIntersections( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): Array<{ wallId: WallNode['id']; point: WallPlanPoint; draftT: number; wallT: number }> { + const intersections: Array<{ + wallId: WallNode['id'] + point: WallPlanPoint + draftT: number + wallT: number + }> = [] + + for (const wall of walls) { + if (isCurvedWall(wall)) continue + const intersection = segmentIntersection(start, end, wall.start, wall.end) + if (!intersection) continue + intersections.push({ + wallId: wall.id, + point: intersection.point, + draftT: intersection.firstT, + wallT: intersection.secondT, + }) + } + + return intersections +} + +export function wallSegmentsCoverSegment( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], + tolerance = 1e-6, +): boolean { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= tolerance * tolerance) return false + + const length = Math.sqrt(lengthSquared) + const intervals: Array<[number, number]> = [] + for (const wall of walls) { + if (isCurvedWall(wall)) continue + const startDistance = + Math.abs((wall.start[0] - start[0]) * dz - (wall.start[1] - start[1]) * dx) / length + const endDistance = + Math.abs((wall.end[0] - start[0]) * dz - (wall.end[1] - start[1]) * dx) / length + if (startDistance > tolerance || endDistance > tolerance) continue + + const wallStartT = + ((wall.start[0] - start[0]) * dx + (wall.start[1] - start[1]) * dz) / lengthSquared + const wallEndT = ((wall.end[0] - start[0]) * dx + (wall.end[1] - start[1]) * dz) / lengthSquared + const intervalStart = Math.max(0, Math.min(wallStartT, wallEndT)) + const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT)) + if (intervalEnd >= intervalStart) { + intervals.push([intervalStart, intervalEnd]) + } + } + + intervals.sort((left, right) => left[0] - right[0]) + const parameterTolerance = tolerance / length + let coveredUntil = 0 + for (const [intervalStart, intervalEnd] of intervals) { + if (intervalStart > coveredUntil + parameterTolerance) return false + coveredUntil = Math.max(coveredUntil, intervalEnd) + if (coveredUntil >= 1 - parameterTolerance) return true + } + return false } /** @@ -224,16 +297,16 @@ export function findWallIntersectionFromRaw( for (let i = 0; i < straight.length; i += 1) { for (let j = i + 1; j < straight.length; j += 1) { - const crossing = segmentIntersection( + const intersection = segmentIntersection( straight[i]!.start, straight[i]!.end, straight[j]!.start, straight[j]!.end, ) - if (!crossing) continue - const d = distanceSquared(point, crossing) + if (!intersection) continue + const d = distanceSquared(point, intersection.point) if (d <= radiusSquared && d < bestDistSq) { - best = crossing + best = intersection.point bestDistSq = d } } From a93cab1a6e9ffd7b96eef95b0b17efbd7719e30a Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Jul 2026 13:34:11 +0530 Subject: [PATCH 04/14] fix(core): preserve room surfaces when splitting --- packages/core/src/lib/space-detection.test.ts | 110 ++++++++ packages/core/src/lib/space-detection.ts | 265 +++++++++++++++--- 2 files changed, 333 insertions(+), 42 deletions(-) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index c7eb3949a..748b2c4d9 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -176,6 +176,60 @@ describe('planAutoCeilingsForLevel', () => { expect(plan.update).toHaveLength(0) expect(plan.delete).toHaveLength(0) }) + + test('a split ceiling inherits customization and keeps each opening with its room', () => { + const leftHole: Array<[number, number]> = [ + [0.5, 0.5], + [1, 0.5], + [1, 1], + [0.5, 1], + ] + const rightHole: Array<[number, number]> = [ + [3, 0.5], + [3.5, 0.5], + [3.5, 1], + [3, 1], + ] + const ceiling = CeilingNode.parse({ + polygon: square, + height: 2.2, + materialPreset: 'custom-ceiling', + slots: { surface: 'library:blue' }, + holes: [leftHole, rightHole], + holeMetadata: [{ source: 'manual' }, { source: 'stair', stairId: 'stair_right' }], + autoFromWalls: true, + }) + const rooms = [ + [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + [ + { x: 2, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 3 }, + ], + ] + + const plan = planAutoCeilingsForLevel(rooms, [ceiling], { storeyHeight: 2.5 }) + const updated = CeilingNode.parse({ ...ceiling, ...plan.update[0]?.data }) + const surfaces = [updated, ...plan.create] + const left = surfaces.find((surface) => surface.polygon.some(([x]) => x === 0)) + const right = surfaces.find((surface) => surface.polygon.some(([x]) => x === 4)) + + expect(plan.create).toHaveLength(1) + expect(plan.update).toHaveLength(1) + expect(surfaces.every((surface) => surface.height === 2.2)).toBe(true) + expect(surfaces.every((surface) => surface.materialPreset === 'custom-ceiling')).toBe(true) + expect(surfaces.every((surface) => surface.slots?.surface === 'library:blue')).toBe(true) + expect(left?.holes).toEqual([leftHole]) + expect(left?.holeMetadata).toEqual([{ source: 'manual' }]) + expect(right?.holes).toEqual([rightHole]) + expect(right?.holeMetadata).toEqual([{ source: 'stair', stairId: 'stair_right' }]) + }) }) // Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper @@ -675,4 +729,60 @@ describe('planAutoSlabsForLevel', () => { expect(plan.update).toHaveLength(0) expect(plan.delete).toHaveLength(0) }) + + test('a split slab inherits customization and keeps each opening with its room', () => { + const leftHole: Array<[number, number]> = [ + [0.5, 0.5], + [1, 0.5], + [1, 1], + [0.5, 1], + ] + const rightHole: Array<[number, number]> = [ + [3, 0.5], + [3.5, 0.5], + [3.5, 1], + [3, 1], + ] + const customized = SlabNode.parse({ + polygon: square, + elevation: 0.2, + thickness: 0.1, + materialPreset: 'custom-floor', + slots: { surface: 'library:oak' }, + holes: [leftHole, rightHole], + holeMetadata: [{ source: 'manual' }, { source: 'stair', stairId: 'stair_right' }], + autoFromWalls: true, + }) + const rooms = [ + [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + [ + { x: 2, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 3 }, + ], + ] + + const plan = planAutoSlabsForLevel(rooms, [customized]) + const updated = SlabNode.parse({ ...customized, ...plan.update[0]?.data }) + const surfaces = [updated, ...plan.create] + const left = surfaces.find((surface) => surface.polygon.some(([x]) => x === 0)) + const right = surfaces.find((surface) => surface.polygon.some(([x]) => x === 4)) + + expect(plan.create).toHaveLength(1) + expect(plan.update).toHaveLength(1) + expect(surfaces.every((surface) => surface.elevation === 0.2)).toBe(true) + expect(surfaces.every((surface) => surface.thickness === 0.1)).toBe(true) + expect(surfaces.every((surface) => surface.materialPreset === 'custom-floor')).toBe(true) + expect(surfaces.every((surface) => surface.slots?.surface === 'library:oak')).toBe(true) + expect(left?.holes).toEqual([leftHole]) + expect(left?.holeMetadata).toEqual([{ source: 'manual' }]) + expect(right?.holes).toEqual([rightHole]) + expect(right?.holeMetadata).toEqual([{ source: 'stair', stairId: 'stair_right' }]) + }) }) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index e34f82e31..6960ee403 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -775,6 +775,93 @@ function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, ) } +function sameTuplePolygons( + current: Array>, + next: Array>, +) { + return ( + current.length === next.length && + current.every((polygon, index) => { + const nextPolygon = next[index] + return nextPolygon ? sameTuplePolygon(polygon, nextPolygon) : false + }) + ) +} + +type SurfaceWithOpenings = { + id: string + holes: Array> + holeMetadata: SlabNodeType['holeMetadata'] +} + +function partitionSurfaceOpenings( + surface: SurfaceWithOpenings, + roomIndices: number[], + detected: DetectedRoom[], +) { + const assignments = new Map< + number, + { + holes: Array> + holeMetadata: SlabNodeType['holeMetadata'] + } + >() + for (const roomIndex of roomIndices) { + assignments.set(roomIndex, { holes: [], holeMetadata: [] }) + } + + surface.holes.forEach((hole, holeIndex) => { + const holePolygon = hole.map(pointFromTuple) + const holeCentroid = polygonCentroid(holePolygon) + let bestRoomIndex: number | null = null + let bestCoverage = Number.NEGATIVE_INFINITY + let bestDistance = Number.POSITIVE_INFINITY + + for (const roomIndex of roomIndices) { + const room = detected[roomIndex] + if (!room) continue + const coverage = polygonCoverageRatio(holePolygon, [room.poly]) + const distance = Math.hypot( + holeCentroid.x - room.centroid.x, + holeCentroid.y - room.centroid.y, + ) + if ( + coverage > bestCoverage + 1e-6 || + (Math.abs(coverage - bestCoverage) <= 1e-6 && distance < bestDistance) + ) { + bestRoomIndex = roomIndex + bestCoverage = coverage + bestDistance = distance + } + } + + if (bestRoomIndex == null) return + const assignment = assignments.get(bestRoomIndex) + if (!assignment) return + assignment.holes.push(hole) + assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) + }) + + return assignments +} + +function sameHoleMetadata( + current: SlabNodeType['holeMetadata'], + next: SlabNodeType['holeMetadata'], +) { + return ( + current.length === next.length && + current.every((metadata, index) => { + const candidate = next[index] + return ( + candidate?.source === metadata.source && + candidate.stairId === metadata.stairId && + candidate.elevatorId === metadata.elevatorId + ) + }) + ) +} + function wallGeometrySignature(wall: WallNode) { return [ wall.id, @@ -988,7 +1075,8 @@ export function planAutoSlabsForLevel( const matchedSlabIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const roomIndexBySlabId = new Map() + const sourceSlabIdByRoomIndex = new Map() const autoBySignature = new Map>() for (const entry of existingAutoMeta) { @@ -1003,7 +1091,8 @@ export function planAutoSlabsForLevel( matchedDetectedIdx.add(index) matchedSlabIds.add(existing.slab.id) - updatesById.set(existing.slab.id, room.poly.map(pointToTuple)) + roomIndexBySlabId.set(existing.slab.id, index) + sourceSlabIdByRoomIndex.set(index, existing.slab.id) }) const remainingDetected = detected @@ -1038,14 +1127,26 @@ export function planAutoSlabsForLevel( matchedDetectedIdx.add(index) matchedSlabIds.add(bestMatch.entry.slab.id) - updatesById.set(bestMatch.entry.slab.id, room.poly.map(pointToTuple)) + roomIndexBySlabId.set(bestMatch.entry.slab.id, index) + sourceSlabIdByRoomIndex.set(index, bestMatch.entry.slab.id) } + detected.forEach((room, index) => { + if (sourceSlabIdByRoomIndex.has(index)) return + let bestSource: { id: string; coverage: number } | null = null + for (const entry of existingAutoMeta) { + const coverage = polygonCoverageRatio(room.poly, [entry.slab.polygon.map(pointFromTuple)]) + if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue + bestSource = { id: entry.slab.id, coverage } + } + if (bestSource) sourceSlabIdByRoomIndex.set(index, bestSource.id) + }) + const detectedRoomPolygons = detectedAll.map((room) => room.poly) const slabsToDelete: Array = [] const slabDemotions: AutoSlabSyncPlan['update'] = [] for (const slab of existingAuto) { - if (updatesById.has(slab.id)) continue + if (roomIndexBySlabId.has(slab.id)) continue const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons) if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { @@ -1057,17 +1158,34 @@ export function planAutoSlabsForLevel( } } - const slabsToUpdate = [ - ...existingAuto - .filter((slab) => updatesById.has(slab.id)) - .flatMap((slab) => { - const polygon = updatesById.get(slab.id) - if (!polygon) return [] + const openingAssignmentsBySlabId = new Map>() + for (const slab of existingAuto) { + const roomIndices = [...sourceSlabIdByRoomIndex.entries()] + .filter(([, slabId]) => slabId === slab.id) + .map(([roomIndex]) => roomIndex) + if (roomIndices.length === 0) continue + openingAssignmentsBySlabId.set(slab.id, partitionSurfaceOpenings(slab, roomIndices, detected)) + } - return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }] - }), - ...slabDemotions, - ] + const slabsToUpdate = existingAuto.flatMap((slab) => { + const roomIndex = roomIndexBySlabId.get(slab.id) + if (roomIndex == null) return [] + const room = detected[roomIndex] + if (!room) return [] + const polygon = room.poly.map(pointToTuple) + const openings = openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + } + const data: Partial = {} + if (!sameTuplePolygon(slab.polygon, polygon)) data.polygon = polygon + if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [] + }) + slabsToUpdate.push(...slabDemotions) const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] const slabsToCreate: SlabNodeType[] = [] @@ -1079,13 +1197,23 @@ export function planAutoSlabsForLevel( const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') plannedSlabsForNaming.push({ name }) + const sourceId = sourceSlabIdByRoomIndex.get(index) + const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined + const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined slabsToCreate.push( SlabNode.parse({ name, polygon: room.poly.map(pointToTuple), - holes: [], - elevation: DEFAULT_AUTO_SLAB_ELEVATION, + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + elevation: source?.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION, + thickness: source?.thickness, + recessed: source?.recessed, + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, autoFromWalls: true, }), ) @@ -1168,7 +1296,8 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const roomIndexByCeilingId = new Map() + const sourceCeilingIdByRoomIndex = new Map() const autoBySignature = new Map>() for (const entry of existingAutoMeta) { @@ -1183,9 +1312,8 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(existing.ceiling.id) - updatesById.set(existing.ceiling.id, { - polygon: room.poly.map(pointToTuple), - }) + roomIndexByCeilingId.set(existing.ceiling.id, index) + sourceCeilingIdByRoomIndex.set(index, existing.ceiling.id) }) const remainingDetected = detected @@ -1220,16 +1348,26 @@ export function planAutoCeilingsForLevel( matchedDetectedIdx.add(index) matchedCeilingIds.add(bestMatch.entry.ceiling.id) - updatesById.set(bestMatch.entry.ceiling.id, { - polygon: room.poly.map(pointToTuple), - }) + roomIndexByCeilingId.set(bestMatch.entry.ceiling.id, index) + sourceCeilingIdByRoomIndex.set(index, bestMatch.entry.ceiling.id) } + detected.forEach((room, index) => { + if (sourceCeilingIdByRoomIndex.has(index)) return + let bestSource: { id: string; coverage: number } | null = null + for (const entry of existingAutoMeta) { + const coverage = polygonCoverageRatio(room.poly, [entry.ceiling.polygon.map(pointFromTuple)]) + if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue + bestSource = { id: entry.ceiling.id, coverage } + } + if (bestSource) sourceCeilingIdByRoomIndex.set(index, bestSource.id) + }) + const detectedRoomPolygons = detectedAll.map((room) => room.poly) const ceilingsToDelete: Array = [] const ceilingDemotions: AutoCeilingSyncPlan['update'] = [] for (const ceiling of existingAuto) { - if (updatesById.has(ceiling.id)) continue + if (roomIndexByCeilingId.has(ceiling.id)) continue const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons) if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { @@ -1256,20 +1394,46 @@ export function planAutoCeilingsForLevel( : [] }) - const ceilingsToUpdate = [ - // Auto ceilings only track their room's POLYGON here — their height is - // follows-mode (absent) and derives from the level top at read time. - ...existingAuto - .filter((ceiling) => updatesById.has(ceiling.id)) - .flatMap((ceiling) => { - const update = updatesById.get(ceiling.id) - if (!update) return [] - if (sameTuplePolygon(ceiling.polygon, update.polygon)) return [] - return [{ id: ceiling.id, data: { polygon: update.polygon } }] - }), - ...ceilingDemotions, - ...manualClamps, - ] + const openingAssignmentsByCeilingId = new Map< + string, + ReturnType + >() + for (const ceiling of existingAuto) { + const roomIndices = [...sourceCeilingIdByRoomIndex.entries()] + .filter(([, ceilingId]) => ceilingId === ceiling.id) + .map(([roomIndex]) => roomIndex) + if (roomIndices.length === 0) continue + openingAssignmentsByCeilingId.set( + ceiling.id, + partitionSurfaceOpenings(ceiling, roomIndices, detected), + ) + } + + const ceilingsToUpdate = existingAuto.flatMap((ceiling) => { + const roomIndex = roomIndexByCeilingId.get(ceiling.id) + if (roomIndex == null) return [] + const room = detected[roomIndex] + if (!room) return [] + const polygon = room.poly.map(pointToTuple) + const openings = openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + } + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, polygon)) data.polygon = polygon + if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + if (ceiling.height != null) { + const bound = resolveCeilingClampBound(polygon, context) + if (Number.isFinite(bound) && ceiling.height > bound + CEILING_HEIGHT_EPSILON) { + data.height = bound + } + } + return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [] + }) + ceilingsToUpdate.push(...ceilingDemotions, ...manualClamps) const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const ceilingsToCreate: CeilingNodeType[] = [] @@ -1281,15 +1445,32 @@ export function planAutoCeilingsForLevel( const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') plannedCeilingsForNaming.push({ name }) + const sourceId = sourceCeilingIdByRoomIndex.get(index) + const source = sourceId ? existingAuto.find((ceiling) => ceiling.id === sourceId) : undefined + const openings = sourceId ? openingAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined + const inheritedHeight = + source?.height == null + ? {} + : { + height: Math.min( + source.height, + resolveCeilingClampBound(room.poly.map(pointToTuple), context), + ), + } - // Height-less on purpose: auto ceilings follow the level top (the - // clamp bound) through `resolveCeilingHeight` instead of baking a - // derived height that would go stale on level-height edits. + // Uncustomized auto ceilings stay height-less so they continue to follow + // the level top; an inherited explicit height is clamped to the new room. ceilingsToCreate.push( CeilingNode.parse({ name, polygon: room.poly.map(pointToTuple), - holes: [], + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, + ...inheritedHeight, autoFromWalls: true, }), ) From fb37342d0bdd1a39812ed882a76af673dee708e5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Jul 2026 14:14:44 +0530 Subject: [PATCH 05/14] fix(core): preserve room surfaces when merging --- packages/core/src/lib/space-detection.test.ts | 196 ++++++++++++++++++ packages/core/src/lib/space-detection.ts | 119 ++++++++++- .../tools/wall/wall-drafting.test.ts | 34 +++ 3 files changed, 341 insertions(+), 8 deletions(-) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 748b2c4d9..bf94269b4 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -155,6 +155,102 @@ describe('planAutoCeilingsForLevel', () => { expect(plan.delete[0]).not.toBe(survivorId) }) + test('preserves conflicting merged ceilings as separate manual surfaces', () => { + const leftCeiling = CeilingNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + height: 2.4, + slots: { surface: 'library:red' }, + autoFromWalls: true, + }) + const rightCeiling = CeilingNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + slots: { surface: 'library:blue' }, + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoCeilingsForLevel([mergedRoom], [leftCeiling, rightCeiling]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + expect(plan.update).toEqual( + expect.arrayContaining([ + { id: leftCeiling.id, data: { autoFromWalls: false } }, + { id: rightCeiling.id, data: { autoFromWalls: false } }, + ]), + ) + }) + + test('unions openings when compatible ceilings merge', () => { + const leftHole: Array<[number, number]> = [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ] + const rightHole: Array<[number, number]> = [ + [6, 1], + [7, 1], + [7, 2], + [6, 2], + ] + const leftCeiling = CeilingNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [leftHole], + holeMetadata: [{ source: 'manual' }], + autoFromWalls: true, + }) + const rightCeiling = CeilingNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + holes: [rightHole], + holeMetadata: [{ source: 'stair', stairId: 'stair_right' }], + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoCeilingsForLevel([mergedRoom], [leftCeiling, rightCeiling]) + const survivor = [leftCeiling, rightCeiling].find( + (ceiling) => ceiling.id === plan.update[0]?.id, + ) + const merged = CeilingNode.parse({ ...survivor, ...plan.update[0]?.data }) + + expect(plan.delete).toHaveLength(1) + expect(merged.holes).toEqual(expect.arrayContaining([leftHole, rightHole])) + expect(merged.holeMetadata).toEqual( + expect.arrayContaining([{ source: 'manual' }, { source: 'stair', stairId: 'stair_right' }]), + ) + }) + test('a demoted ceiling suppresses re-creating an auto ceiling when the room re-forms', () => { const ceiling = CeilingNode.parse({ polygon: square, @@ -689,6 +785,106 @@ describe('planAutoSlabsForLevel', () => { expect(plan.update[0]?.data.autoFromWalls).toBeUndefined() }) + test('preserves conflicting merged slabs as separate manual surfaces', () => { + const leftSlab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + elevation: 0.15, + thickness: 0.15, + slots: { surface: 'library:red' }, + autoFromWalls: true, + }) + const rightSlab = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + elevation: -0.15, + thickness: 0.1, + slots: { surface: 'library:blue' }, + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoSlabsForLevel([mergedRoom], [leftSlab, rightSlab]) + + expect(plan.create).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + expect(plan.update).toEqual( + expect.arrayContaining([ + { id: leftSlab.id, data: { autoFromWalls: false } }, + { id: rightSlab.id, data: { autoFromWalls: false } }, + ]), + ) + }) + + test('unions openings when compatible slabs merge', () => { + const leftHole: Array<[number, number]> = [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ] + const rightHole: Array<[number, number]> = [ + [6, 1], + [7, 1], + [7, 2], + [6, 2], + ] + const leftSlab = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + holes: [leftHole], + holeMetadata: [{ source: 'manual' }], + autoFromWalls: true, + }) + const rightSlab = SlabNode.parse({ + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + holes: [rightHole], + holeMetadata: [{ source: 'elevator', elevatorId: 'elevator_right' }], + autoFromWalls: true, + }) + const mergedRoom = [ + { x: 0, y: 0 }, + { x: 8, y: 0 }, + { x: 8, y: 3 }, + { x: 0, y: 3 }, + ] + + const plan = planAutoSlabsForLevel([mergedRoom], [leftSlab, rightSlab]) + const survivor = [leftSlab, rightSlab].find((slab) => slab.id === plan.update[0]?.id) + const merged = SlabNode.parse({ ...survivor, ...plan.update[0]?.data }) + + expect(plan.delete).toHaveLength(1) + expect(merged.holes).toEqual(expect.arrayContaining([leftHole, rightHole])) + expect(merged.holeMetadata).toEqual( + expect.arrayContaining([ + { source: 'manual' }, + { source: 'elevator', elevatorId: 'elevator_right' }, + ]), + ) + }) + test('a demoted slab suppresses re-creating an auto slab when the room re-forms', () => { const auto = slab(0.05) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 6960ee403..25ae973ae 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -862,6 +862,47 @@ function sameHoleMetadata( ) } +function mergedSurfaceOpenings(surfaces: SurfaceWithOpenings[]) { + const holes: Array> = [] + const holeMetadata: SlabNodeType['holeMetadata'] = [] + const seen = new Set() + + for (const surface of surfaces) { + surface.holes.forEach((hole, index) => { + const metadata = surface.holeMetadata[index] ?? { source: 'manual' } + const key = JSON.stringify([hole, metadata]) + if (seen.has(key)) return + seen.add(key) + holes.push(hole) + holeMetadata.push(metadata) + }) + } + + return { holes, holeMetadata } +} + +function slabMergeSettingsSignature(slab: SlabNodeType) { + return JSON.stringify([ + slab.elevation, + slab.thickness, + slab.recessed, + slab.material ?? null, + slab.materialPreset ?? null, + slab.slots ?? null, + slab.visible, + ]) +} + +function ceilingMergeSettingsSignature(ceiling: CeilingNodeType) { + return JSON.stringify([ + ceiling.height ?? null, + ceiling.material ?? null, + ceiling.materialPreset ?? null, + ceiling.slots ?? null, + ceiling.visible, + ]) +} + function wallGeometrySignature(wall: WallNode) { return [ wall.id, @@ -1073,6 +1114,26 @@ export function planAutoSlabsForLevel( } }) + const conflictingMergeSlabIds = new Set() + const conflictingMergeSlabRoomIndices = new Set() + const compatibleMergeSlabsByRoomIndex = new Map() + detected.forEach((room, roomIndex) => { + const contributors = existingAuto.filter( + (slab) => + polygonCoverageRatio(slab.polygon.map(pointFromTuple), [room.poly]) >= + ORPHAN_MERGE_COVERAGE_THRESHOLD, + ) + if (contributors.length < 2) return + + const signatures = new Set(contributors.map(slabMergeSettingsSignature)) + if (signatures.size > 1) { + conflictingMergeSlabRoomIndices.add(roomIndex) + for (const slab of contributors) conflictingMergeSlabIds.add(slab.id) + return + } + compatibleMergeSlabsByRoomIndex.set(roomIndex, contributors) + }) + const matchedSlabIds = new Set() const matchedDetectedIdx = new Set() const roomIndexBySlabId = new Map() @@ -1086,6 +1147,10 @@ export function planAutoSlabsForLevel( } detected.forEach((room, index) => { + if (conflictingMergeSlabRoomIndices.has(index)) { + matchedDetectedIdx.add(index) + return + } const existing = autoBySignature.get(room.sig)?.shift() if (!existing) return @@ -1148,6 +1213,11 @@ export function planAutoSlabsForLevel( for (const slab of existingAuto) { if (roomIndexBySlabId.has(slab.id)) continue + if (conflictingMergeSlabIds.has(slab.id)) { + slabDemotions.push({ id: slab.id, data: { autoFromWalls: false } }) + continue + } + const coverage = polygonCoverageRatio(slab.polygon.map(pointFromTuple), detectedRoomPolygons) if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { slabsToDelete.push(slab.id) @@ -1173,10 +1243,12 @@ export function planAutoSlabsForLevel( const room = detected[roomIndex] if (!room) return [] const polygon = room.poly.map(pointToTuple) - const openings = openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - } + const openings = compatibleMergeSlabsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) const data: Partial = {} if (!sameTuplePolygon(slab.polygon, polygon)) data.polygon = polygon if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes @@ -1294,6 +1366,26 @@ export function planAutoCeilingsForLevel( } }) + const conflictingMergeCeilingIds = new Set() + const conflictingMergeCeilingRoomIndices = new Set() + const compatibleMergeCeilingsByRoomIndex = new Map() + detected.forEach((room, roomIndex) => { + const contributors = existingAuto.filter( + (ceiling) => + polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), [room.poly]) >= + ORPHAN_MERGE_COVERAGE_THRESHOLD, + ) + if (contributors.length < 2) return + + const signatures = new Set(contributors.map(ceilingMergeSettingsSignature)) + if (signatures.size > 1) { + conflictingMergeCeilingRoomIndices.add(roomIndex) + for (const ceiling of contributors) conflictingMergeCeilingIds.add(ceiling.id) + return + } + compatibleMergeCeilingsByRoomIndex.set(roomIndex, contributors) + }) + const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() const roomIndexByCeilingId = new Map() @@ -1307,6 +1399,10 @@ export function planAutoCeilingsForLevel( } detected.forEach((room, index) => { + if (conflictingMergeCeilingRoomIndices.has(index)) { + matchedDetectedIdx.add(index) + return + } const existing = autoBySignature.get(room.sig)?.shift() if (!existing) return @@ -1369,6 +1465,11 @@ export function planAutoCeilingsForLevel( for (const ceiling of existingAuto) { if (roomIndexByCeilingId.has(ceiling.id)) continue + if (conflictingMergeCeilingIds.has(ceiling.id)) { + ceilingDemotions.push({ id: ceiling.id, data: { autoFromWalls: false } }) + continue + } + const coverage = polygonCoverageRatio(ceiling.polygon.map(pointFromTuple), detectedRoomPolygons) if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) { ceilingsToDelete.push(ceiling.id) @@ -1415,10 +1516,12 @@ export function planAutoCeilingsForLevel( const room = detected[roomIndex] if (!room) return [] const polygon = room.poly.map(pointToTuple) - const openings = openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - } + const openings = compatibleMergeCeilingsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) const data: Partial = {} if (!sameTuplePolygon(ceiling.polygon, polygon)) data.polygon = polygon if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 26f92356e..7ef2f0ecf 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -248,6 +248,40 @@ describe('createWallOnCurrentLevel', () => { } }) + test('repeated divider deletion rejoins its split boundary walls', () => { + const walls = [ + makeWall([0, 0], [4, 0], 'wall_bottom'), + makeWall([4, 0], [4, 3], 'wall_right'), + makeWall([4, 3], [0, 3], 'wall_top'), + makeWall([0, 3], [0, 0], 'wall_left'), + ] + seedLevel(walls) + + const divider = createWallOnCurrentLevel([2, 0], [2, 3]) + + expect(divider).not.toBeNull() + expect(levelWalls()).toHaveLength(7) + + useScene.getState().deleteNode(divider!.id as AnyNodeId) + + expect(levelWalls()).toHaveLength(4) + expect( + levelWalls().some( + (wall) => + (wall.start[0] === 0 && wall.end[0] === 4) || (wall.start[0] === 4 && wall.end[0] === 0), + ), + ).toBe(true) + + const secondDivider = createWallOnCurrentLevel([0, 1.5], [4, 1.5]) + + expect(secondDivider).not.toBeNull() + expect(levelWalls()).toHaveLength(7) + + useScene.getState().deleteNode(secondDivider!.id as AnyNodeId) + + expect(levelWalls()).toHaveLength(4) + }) + test('crossing divider walls split into four joined segments and four room surfaces', () => { const walls = [ makeWall([0, 0], [4, 0], 'wall_bottom'), From 6943a6c92dd1e8ce21319ee687a2420feb6a98c5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Jul 2026 15:29:00 +0530 Subject: [PATCH 06/14] fix(editor): handle curved and extreme room boundaries --- packages/core/src/lib/space-detection.test.ts | 32 +++++++ packages/core/src/lib/space-detection.ts | 1 - .../tools/wall/wall-drafting.test.ts | 92 +++++++++++++++++++ .../components/tools/wall/wall-drafting.ts | 46 +++++++--- .../tools/wall/wall-snap-geometry.test.ts | 24 ++++- .../tools/wall/wall-snap-geometry.ts | 66 ++++++++----- 6 files changed, 223 insertions(+), 38 deletions(-) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index bf94269b4..748af4929 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -563,6 +563,38 @@ describe('detectSpacesForLevel', () => { ).toEqual(walls.map((wall) => `${wall.id}:front`).sort()) }) + test('detects a valid room below 0.5 square metres and plans its surfaces', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [0.6, 0] }), + WallNode.parse({ start: [0.6, 0], end: [0.6, 0.6] }), + WallNode.parse({ start: [0.6, 0.6], end: [0, 0.6] }), + WallNode.parse({ start: [0, 0.6], end: [0, 0] }), + ] + + const { roomPolygons } = detectSpacesForLevel('level-small', walls) + + expect(roomPolygons).toHaveLength(1) + expect(areaOf(roomPolygons[0]!)).toBeCloseTo(0.36) + expect(planAutoSlabsForLevel(roomPolygons, []).create).toHaveLength(1) + expect(planAutoCeilingsForLevel(roomPolygons, []).create).toHaveLength(1) + }) + + test('detects a valid room above 10,000 square metres and plans its surfaces', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [101, 0] }), + WallNode.parse({ start: [101, 0], end: [101, 101] }), + WallNode.parse({ start: [101, 101], end: [0, 101] }), + WallNode.parse({ start: [0, 101], end: [0, 0] }), + ] + + const { roomPolygons } = detectSpacesForLevel('level-large', walls) + + expect(roomPolygons).toHaveLength(1) + expect(areaOf(roomPolygons[0]!)).toBeCloseTo(10_201) + expect(planAutoSlabsForLevel(roomPolygons, []).create).toHaveLength(1) + expect(planAutoCeilingsForLevel(roomPolygons, []).create).toHaveLength(1) + }) + test('excludes dangling wall branches from a room boundary', () => { const roomWalls = squareWalls() const branch = WallNode.parse({ start: [0, 0], end: [1, 1] }) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 25ae973ae..5e38c1c3a 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -655,7 +655,6 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] { const signedArea = polygonArea(polygon) if (signedArea <= 0) continue - if (signedArea < 0.5 || signedArea > 10_000) continue const signature = polygonSignature(polygon) if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 7ef2f0ecf..7010f8a86 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -5,7 +5,10 @@ import { CeilingNode, DoorNode as DoorSchema, detectSpacesForLevel, + getWallCurveFrameAt, + getWallCurveLength, initSpaceDetectionSync, + isCurvedWall, runAsSingleSceneHistoryStep, SlabNode, useScene, @@ -282,6 +285,95 @@ describe('createWallOnCurrentLevel', () => { expect(levelWalls()).toHaveLength(4) }) + test('a divider ending on a curved wall splits the curve and the room surfaces', () => { + const curvedWall = { + ...makeWall([0, 0], [4, 0], 'wall_curve'), + curveOffset: 1, + } + const curveMidpoint = getWallCurveFrameAt(curvedWall, 0.5).point + const walls = [ + curvedWall, + makeWall([4, 0], [4, 3], 'wall_right'), + makeWall([4, 3], [0, 3], 'wall_top'), + makeWall([0, 3], [0, 0], 'wall_left'), + ] + seedLevel(walls) + const editorState = { + spaces: {}, + setSpaces(spaces: Record) { + editorState.spaces = spaces + }, + } + const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + + try { + const divider = createWallOnCurrentLevel([curveMidpoint.x, curveMidpoint.y], [2, 3]) + const nodes = Object.values(useScene.getState().nodes) + const postCommitWalls = nodes.filter((node): node is WallNode => node.type === 'wall') + const curvedSegments = postCommitWalls.filter(isCurvedWall) + const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), postCommitWalls) + + expect(divider).not.toBeNull() + expect(useScene.getState().nodes[curvedWall.id]).toBeUndefined() + expect(curvedSegments).toHaveLength(2) + expect( + curvedSegments.every( + (wall) => + (wall.start[0] === curveMidpoint.x && wall.start[1] === curveMidpoint.y) || + (wall.end[0] === curveMidpoint.x && wall.end[1] === curveMidpoint.y), + ), + ).toBe(true) + const firstCurve = curvedSegments.find( + (wall) => wall.start[0] === curvedWall.start[0] && wall.start[1] === curvedWall.start[1], + ) + const secondCurve = curvedSegments.find( + (wall) => wall.end[0] === curvedWall.end[0] && wall.end[1] === curvedWall.end[1], + ) + const originalQuarter = getWallCurveFrameAt(curvedWall, 0.25).point + const originalThreeQuarter = getWallCurveFrameAt(curvedWall, 0.75).point + const firstMidpoint = getWallCurveFrameAt(firstCurve!, 0.5).point + const secondMidpoint = getWallCurveFrameAt(secondCurve!, 0.5).point + expect(firstMidpoint.x).toBeCloseTo(originalQuarter.x, 6) + expect(firstMidpoint.y).toBeCloseTo(originalQuarter.y, 6) + expect(secondMidpoint.x).toBeCloseTo(originalThreeQuarter.x, 6) + expect(secondMidpoint.y).toBeCloseTo(originalThreeQuarter.y, 6) + expect(roomPolygons).toHaveLength(2) + expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(2) + expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(2) + } finally { + unsubscribe() + } + }) + + test('splitting a curved wall migrates attachments by arc length', () => { + const curvedWall = { + ...makeWall([0, 0], [4, 0], 'wall_curve'), + curveOffset: 1, + } + const curveLength = getWallCurveLength(curvedWall) + const door = DoorSchema.parse({ + position: [curveLength * 0.75, 1.05, 0], + parentId: curvedWall.id, + wallId: curvedWall.id, + }) + seedLevel([{ ...curvedWall, children: [door.id] }], [door as AnyNode]) + const curveMidpoint = getWallCurveFrameAt(curvedWall, 0.5).point + + const divider = createWallOnCurrentLevel([curveMidpoint.x, curveMidpoint.y], [2, 3]) + + expect(divider).not.toBeNull() + const secondCurve = levelWalls().find( + (wall) => isCurvedWall(wall) && wall.end[0] === 4 && wall.end[1] === 0, + ) + const migratedDoor = useScene.getState().nodes[door.id as AnyNodeId] + expect(secondCurve).toBeDefined() + expect(migratedDoor?.type).toBe('door') + expect(migratedDoor?.parentId).toBe(secondCurve?.id) + if (migratedDoor?.type === 'door') { + expect(migratedDoor.position[0]).toBeCloseTo(curveLength * 0.25, 2) + } + }) + test('crossing divider walls split into four joined segments and four room surfaces', () => { const walls = [ makeWall([0, 0], [4, 0], 'wall_bottom'), diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index b87694d13..adf8ddc42 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -4,7 +4,10 @@ import { DEFAULT_ANGLE_STEP, type DoorNode, getScaledDimensions, + getWallArcData, + getWallCurveLength, type ItemNode, + isCurvedWall, resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, @@ -22,7 +25,7 @@ import { findWallSegmentIntersections, findWallSnapTarget, findWallSpecialPointSnap, - projectPointOntoWall, + projectPointOntoWallDetailed, WALL_CONNECT_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS, type WallDraftSnapResult, @@ -56,6 +59,7 @@ type WallSplitIntersection = { /** `null` = snap-only outcome: resolve to `point` but split no wall. */ wallId: WallNode['id'] | null point: WallPlanPoint + wallT: number } export function getSegmentGridStep(): number { @@ -75,19 +79,32 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] } -function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] { +function splitWallAtPoint( + wall: WallNode, + splitPoint: WallPlanPoint, + wallT: number, +): [WallNode, WallNode] { const { id: _id, parentId: _parentId, children, ...rest } = wall + const arc = getWallArcData(wall) + const curveOffsets = arc + ? ([wallT, 1 - wallT].map((fraction) => { + const subArcAngle = Math.abs(arc.delta) * fraction + return arc.direction * arc.radius * (1 - Math.cos(subArcAngle / 2)) + }) as [number, number]) + : ([wall.curveOffset, wall.curveOffset] as const) const first = WallSchema.parse({ ...rest, start: wall.start, end: splitPoint, + curveOffset: curveOffsets[0], children: [], }) const second = WallSchema.parse({ ...rest, start: splitPoint, end: wall.end, + curveOffset: curveOffsets[1], children: [], }) @@ -111,8 +128,9 @@ function findWallIntersection( for (const wall of walls) { if (ignore.has(wall.id)) continue - const projected = projectPointOntoWall(point, wall) - if (!projected) continue + const projection = projectPointOntoWallDetailed(point, wall) + if (!projection) continue + const { point: projected, wallT } = projection const candidateDistanceSquared = distanceSquared(point, projected) if ( @@ -128,8 +146,8 @@ function findWallIntersection( WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, ) best = nearCorner - ? { wallId: null, point: [nearCorner[0], nearCorner[1]] } - : { wallId: wall.id, point: projected } + ? { wallId: null, point: [nearCorner[0], nearCorner[1]], wallT } + : { wallId: wall.id, point: projected, wallT } bestDistanceSquared = candidateDistanceSquared } @@ -149,8 +167,10 @@ function wallHasAttachments(wall: WallNode, nodes: ReturnType) { - return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +function wallLength(wall: WallNode) { + return isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null { @@ -226,12 +246,12 @@ function remapAttachmentToWall( function buildAttachmentMigrationPlan( wall: WallNode, - splitPoint: WallPlanPoint, + wallT: number, firstWall: WallNode, secondWall: WallNode, nodes: ReturnType['nodes'], ): { id: AnyNodeId; data: Partial }[] | null { - const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1]) + const splitDistance = wallLength(wall) * wallT const firstLength = wallLength(firstWall) const secondLength = wallLength(secondWall) const tolerance = 1e-4 @@ -290,10 +310,10 @@ function splitWallIfNeeded( return { walls, point: intersection.point } } - const [first, second] = splitWallAtPoint(wallToSplit, intersection.point) + const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, intersection.wallT) const attachmentUpdates = buildAttachmentMigrationPlan( wallToSplit, - intersection.point, + intersection.wallT, first, second, nodes, @@ -548,7 +568,7 @@ export function createWallOnCurrentLevel( for (const crossing of crossings) { if (crossing.wallT > interiorEpsilon && crossing.wallT < 1 - interiorEpsilon) { const splitHost = splitWallIfNeeded( - { wallId: crossing.wallId, point: crossing.point }, + { wallId: crossing.wallId, point: crossing.point, wallT: crossing.wallT }, workingWalls, nodes, createNodes, diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts index bf2526fdc..fa5a7bc4c 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { WallNode } from '@pascal-app/core' +import { getWallArcData, getWallCurveFrameAt, type WallNode } from '@pascal-app/core' import { chainEndJoinsExistingWall, findWallSnapTarget, @@ -100,6 +100,13 @@ describe('chainEndJoinsExistingWall (chain termination)', () => { expect(chainEndJoinsExistingWall([2, 0.01], walls, [])).toBe(false) expect(chainEndJoinsExistingWall([2, 0.0005], walls, [])).toBe(true) }) + + test('true when the end lies on a curved wall interior', () => { + const curvedWall = { ...makeWall([0, 0], [4, 0], 'host'), curveOffset: 1 } + const point = getWallCurveFrameAt(curvedWall, 0.37).point + + expect(chainEndJoinsExistingWall([point.x, point.y], [curvedWall], [])).toBe(true) + }) }) describe('findWallSnapTarget (edge / along-wall)', () => { @@ -120,4 +127,19 @@ describe('findWallSnapTarget (edge / along-wall)', () => { expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull() }) + + test('projects exactly onto a curved wall body between sample points', () => { + const curvedWall = { ...makeWall([0, 0], [4, 0]), curveOffset: 1 } + const expected = getWallCurveFrameAt(curvedWall, 0.37).point + const arc = getWallArcData(curvedWall)! + const radialX = (expected.x - arc.center.x) / arc.radius + const radialY = (expected.y - arc.center.y) / arc.radius + const result = findWallSnapTarget( + [expected.x + radialX * 0.02, expected.y + radialY * 0.02], + [curvedWall], + ) + + expect(result?.[0]).toBeCloseTo(expected.x, 3) + expect(result?.[1]).toBeCloseTo(expected.y, 3) + }) }) diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index ac67de4b9..280da806a 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -3,15 +3,15 @@ // (building-local meters). `wall-drafting.ts` layers grid/angle snapping and // scene access on top of these primitives. -import { - getWallCurveFrameAt, - getWallCurveLength, - isCurvedWall, - type WallNode, -} from '@pascal-app/core' +import { getWallArcData, getWallCurveFrameAt, isCurvedWall, type WallNode } from '@pascal-app/core' export type WallPlanPoint = [number, number] +export type WallProjection = { + point: WallPlanPoint + wallT: number +} + /** Which kind of existing-geometry snap produced a drafted point. */ export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' @@ -53,7 +53,32 @@ export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { return dx * dx + dz * dz } -export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { +export function projectPointOntoWallDetailed( + point: WallPlanPoint, + wall: WallNode, +): WallProjection | null { + if (isCurvedWall(wall)) { + const arc = getWallArcData(wall) + if (!arc) return null + + const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + const direction = Math.sign(arc.delta) + let directedAngle = (pointAngle - arc.startAngle) * direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + + const totalAngle = Math.abs(arc.delta) + const wallT = directedAngle / totalAngle + if (wallT <= 0 || wallT >= 1) { + return null + } + + const frame = getWallCurveFrameAt(wall, wallT) + return { + point: [frame.point.x, frame.point.y], + wallT, + } + } + const [x1, z1] = wall.start const [x2, z2] = wall.end const dx = x2 - x1 @@ -68,7 +93,14 @@ export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): Wall return null } - return [x1 + dx * t, z1 + dz * t] + return { + point: [x1 + dx * t, z1 + dz * t], + wallT: t, + } +} + +export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { + return projectPointOntoWallDetailed(point, wall)?.point ?? null } export function findWallSnapTarget( @@ -88,15 +120,7 @@ export function findWallSnapTarget( const candidates: Array = [wall.start, wall.end] - if (isCurvedWall(wall)) { - const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) - for (let index = 0; index <= sampleCount; index += 1) { - const frame = getWallCurveFrameAt(wall, index / sampleCount) - candidates.push([frame.point.x, frame.point.y]) - } - } else { - candidates.push(projectPointOntoWall(point, wall)) - } + candidates.push(projectPointOntoWall(point, wall)) for (const candidate of candidates) { if (!candidate) { continue @@ -340,14 +364,12 @@ export const WALL_CHAIN_JOIN_TOLERANCE = 1e-3 /** * True when a committed chain segment's resolved `end` lies on wall geometry - * (an endpoint, or a straight wall's interior) of a wall outside the current + * (an endpoint, or a wall's interior) of a wall outside the current * draft chain. The wall tools stop chaining there: a segment that tees into * the existing network is a termination — continuing would draft the next * segment on top of existing walls. `chainWallIds` excludes the chain's own * segments (including the just-committed one) so edge/midpoint snaps onto a - * previous own segment don't read as a join. Curved wall interiors are - * skipped (their endpoints still count) — resolving an end onto a curve body - * is rare and continuing there matches the previous behaviour. + * previous own segment don't read as a join. */ export function chainEndJoinsExistingWall( end: WallPlanPoint, @@ -368,8 +390,6 @@ export function chainEndJoinsExistingWall( return true } - if (isCurvedWall(wall)) continue - const projected = projectPointOntoWall(end, wall) if (projected && distanceSquared(end, projected) <= toleranceSquared) { return true From b010bb10e14bdb40be08708305c5b84b828a17d5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Jul 2026 16:31:45 +0530 Subject: [PATCH 07/14] fix room surface deletion and wall draft completion --- packages/core/src/lib/space-detection.test.ts | 102 +++++++++++ packages/core/src/lib/space-detection.ts | 158 ++++++++++++++++-- .../src/components/editor/floorplan-panel.tsx | 17 +- .../tools/wall/wall-drafting.test.ts | 30 ++++ .../components/tools/wall/wall-drafting.ts | 10 ++ 5 files changed, 300 insertions(+), 17 deletions(-) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 748af4929..676dc4ae6 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -479,6 +479,108 @@ function createEditorStoreStub() { return { getState: () => state } } +function roomWithGeneratedSurfaces() { + const walls = [ + WallNode.parse({ id: 'wall_1', start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_2', start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_3', start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_4', start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const generatedSlab = SlabNode.parse({ + id: 'slab_main', + parentId: 'level_0', + polygon: square, + autoFromWalls: true, + }) + const generatedCeiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: 'level_0', + polygon: square, + autoFromWalls: true, + }) + const nodes = [ + BuildingNode.parse({ id: 'building_a', children: ['level_0'] }), + LevelNode.parse({ + id: 'level_0', + parentId: 'building_a', + children: [...walls.map((wall) => wall.id), generatedSlab.id, generatedCeiling.id], + }), + ...walls, + generatedSlab, + generatedCeiling, + ] + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('generated surface deletion through the detection sync', () => { + test('a deleted generated slab stays deleted', () => { + const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const { slab_main: _deleted, ...withoutSlab } = sceneStore.getState().nodes + sceneStore.setNodes(withoutSlab) + + expect( + Object.values(sceneStore.getState().nodes).filter((node) => node.type === 'slab'), + ).toHaveLength(0) + } finally { + unsubscribe() + } + }) + + test('a deleted generated ceiling stays deleted after the next wall edit', () => { + const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const { ceiling_main: _deleted, ...withoutCeiling } = sceneStore.getState().nodes + sceneStore.setNodes(withoutCeiling) + const wall = sceneStore.getState().nodes.wall_1 as WallNode + sceneStore.setNodes({ + ...sceneStore.getState().nodes, + wall_1: { ...wall, height: 2.6 }, + }) + + expect( + Object.values(sceneStore.getState().nodes).filter((node) => node.type === 'ceiling'), + ).toHaveLength(0) + } finally { + unsubscribe() + } + }) + + test('deleted generated surfaces stay suppressed after the sync is reinitialized', () => { + const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) + let unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + const { + slab_main: _slab, + ceiling_main: _ceiling, + ...withoutSurfaces + } = sceneStore.getState().nodes + sceneStore.setNodes(withoutSurfaces) + unsubscribe() + + unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + try { + const wall = sceneStore.getState().nodes.wall_1 as WallNode + sceneStore.setNodes({ + ...sceneStore.getState().nodes, + wall_1: { ...wall, height: 2.6 }, + }) + + const surfaces = Object.values(sceneStore.getState().nodes).filter( + (node) => node.type === 'slab' || node.type === 'ceiling', + ) + expect(surfaces).toHaveLength(0) + } finally { + unsubscribe() + } + }) +}) + describe('reactive ceiling re-clamp through the detection sync', () => { test('a flush deck created on the level above clamps the existing manual ceiling below', () => { const walls = [ diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 5e38c1c3a..bb24508da 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -83,6 +83,21 @@ export type AutoZoneSyncPlan = { update: Array<{ id: ZoneNodeType['id']; data: Partial }> } +type AutoSurfaceKind = 'slab' | 'ceiling' + +type AutoSurfaceSuppressionMetadata = Partial> + +type AutoSurfaceSnapshot = { + id: string + levelId: string + kind: AutoSurfaceKind + roomSignature: string +} + +export type AutoSlabPlanningContext = { + suppressedRoomSignatures?: readonly string[] +} + const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 @@ -105,6 +120,7 @@ const COVERAGE_SAMPLE_STEPS = 12 // no wall/slab inputs anymore — only the bound for the explicit-height // reactive re-clamp below. export type AutoCeilingPlanningContext = { + suppressedRoomSignatures?: readonly string[] /** Stored storey height of the level being planned (floor-to-floor). */ storeyHeight?: number /** @@ -158,6 +174,92 @@ function polygonSignature(points: Point2D[]) { return forward < reversed ? forward : reversed } +function readAutoSurfaceSuppressions(level: unknown): AutoSurfaceSuppressionMetadata { + if (!(level && typeof level === 'object' && 'metadata' in level)) return {} + const metadata = level.metadata + if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return {} + const value = (metadata as Record).autoSurfaceSuppressions + if (!(value && typeof value === 'object' && !Array.isArray(value))) return {} + + const record = value as Record + return { + slab: Array.isArray(record.slab) + ? record.slab.filter((entry): entry is string => typeof entry === 'string') + : [], + ceiling: Array.isArray(record.ceiling) + ? record.ceiling.filter((entry): entry is string => typeof entry === 'string') + : [], + } +} + +function autoSurfaceSnapshots(nodes: Record) { + const snapshots = new Map() + + for (const node of Object.values(nodes)) { + if ( + !( + node && + (node.type === 'slab' || node.type === 'ceiling') && + node.autoFromWalls === true && + typeof node.parentId === 'string' && + Array.isArray(node.polygon) + ) + ) { + continue + } + + snapshots.set(node.id, { + id: node.id, + levelId: node.parentId, + kind: node.type, + roomSignature: polygonSignature(node.polygon.map(pointFromTuple)), + }) + } + + return snapshots +} + +function recordDeletedAutoSurfaceSuppressions( + previousSurfaces: Map, + nodes: Record, + sceneStore: any, +) { + const additionsByLevel = new Map() + + for (const surface of previousSurfaces.values()) { + if (nodes[surface.id] || nodes[surface.levelId]?.type !== 'level') continue + const additions = additionsByLevel.get(surface.levelId) ?? {} + const signatures = additions[surface.kind] ?? [] + if (!signatures.includes(surface.roomSignature)) signatures.push(surface.roomSignature) + additions[surface.kind] = signatures + additionsByLevel.set(surface.levelId, additions) + } + + const updates = [...additionsByLevel.entries()].flatMap(([levelId, additions]) => { + const level = nodes[levelId] + if (level?.type !== 'level') return [] + const existing = readAutoSurfaceSuppressions(level) + const next: AutoSurfaceSuppressionMetadata = { + slab: [...new Set([...(existing.slab ?? []), ...(additions.slab ?? [])])], + ceiling: [...new Set([...(existing.ceiling ?? []), ...(additions.ceiling ?? [])])], + } + return [ + { + id: levelId, + data: { + metadata: { + ...(level.metadata && typeof level.metadata === 'object' ? level.metadata : {}), + autoSurfaceSuppressions: next, + }, + }, + }, + ] + }) + + if (updates.length > 0) sceneStore.getState().updateNodes(updates) + return updates.length > 0 +} + function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance } @@ -1072,7 +1174,9 @@ export function resolveAutoZonePolygon( export function planAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], + context: AutoSlabPlanningContext = {}, ): AutoSlabSyncPlan { + const suppressedRoomSignatures = new Set(context.suppressedRoomSignatures ?? []) const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), @@ -1098,7 +1202,10 @@ export function planAutoSlabsForLevel( })) const detected = detectedAll.filter( - ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ({ sig, poly }) => + !suppressedRoomSignatures.has(sig) && + !manualSignatures.has(sig) && + !matchesManualFootprint(poly, manualPolygons), ) const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls) @@ -1302,8 +1409,9 @@ function syncAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], sceneStore: any, + context: AutoSlabPlanningContext = {}, ) { - const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context) if (plan.delete.length > 0) { sceneStore.getState().deleteNodes(plan.delete) @@ -1325,6 +1433,7 @@ export function planAutoCeilingsForLevel( existingCeilings: CeilingNodeType[], context: AutoCeilingPlanningContext = {}, ): AutoCeilingSyncPlan { + const suppressedRoomSignatures = new Set(context.suppressedRoomSignatures ?? []) const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualSignatures = new Set( manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), @@ -1350,7 +1459,10 @@ export function planAutoCeilingsForLevel( })) const detected = detectedAll.filter( - ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ({ sig, poly }) => + !suppressedRoomSignatures.has(sig) && + !manualSignatures.has(sig) && + !matchesManualFootprint(poly, manualPolygons), ) const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls) @@ -1679,9 +1791,12 @@ function runSpaceDetection( ) } - const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) - syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) const levelNode = nodes[levelId] + const suppressions = readAutoSurfaceSuppressions(levelNode) + const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) + syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, { + suppressedRoomSignatures: suppressions.slab, + }) const storeyHeight = levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) @@ -1692,6 +1807,7 @@ function runSpaceDetection( ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, { + suppressedRoomSignatures: suppressions.ceiling, storeyHeight, ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), }, @@ -1739,25 +1855,41 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => // scene that merely loaded — rerunning on hydration resurrected auto slabs // the user had deleted in an earlier session. const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) + let previousAutoSurfaces = autoSurfaceSnapshots(sceneStore.getState().nodes) let isProcessing = false - const processNodes = (nodes: any) => { + const processNodes = (incomingNodes: any) => { if (isProcessing) return if (getSceneHistoryPauseDepth() > 0) return - 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 - // paused becomes the new baseline; only future changes will reconcile. if (spaceDetectionPauseDepth > 0) { + const pausedSnapshots = levelStructureSnapshots(incomingNodes) previousSnapshots.clear() - for (const [levelId, snapshot] of currentSnapshots.entries()) { + for (const [levelId, snapshot] of pausedSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) } + previousAutoSurfaces = autoSurfaceSnapshots(incomingNodes) return } + let nodes = incomingNodes + const deletedSurfaceSuppressionNeeded = [...previousAutoSurfaces.values()].some( + (surface) => !nodes[surface.id] && nodes[surface.levelId]?.type === 'level', + ) + if (deletedSurfaceSuppressionNeeded) { + isProcessing = true + pauseSceneHistory(sceneStore) + try { + recordDeletedAutoSurfaceSuppressions(previousAutoSurfaces, nodes, sceneStore) + nodes = sceneStore.getState().nodes + } finally { + resumeSceneHistory(sceneStore) + isProcessing = false + } + } + + const currentSnapshots = levelStructureSnapshots(nodes) + const levelsToUpdate = new Set() for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { // First sight of a level is a hydration baseline, not a wall edit — @@ -1776,6 +1908,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => for (const [levelId, snapshot] of currentSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) } + previousAutoSurfaces = autoSurfaceSnapshots(nodes) return } @@ -1790,6 +1923,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => for (const [levelId, snapshot] of postRunSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) } + previousAutoSurfaces = autoSurfaceSnapshots(sceneStore.getState().nodes) isProcessing = false } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 14f51a193..69128b51b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -179,6 +179,7 @@ import { chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, + shouldStopWallDraftAfterCommit, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, @@ -9764,11 +9765,17 @@ export function FloorplanPanel({ setCursorPoint(null) return } - } else if (!(viewIs2DOnly || publishedNextStart)) { - // Split view: the 3D tool owns both the commit and the continuation - // decision, and it clears the published chain start whenever it stops - // drafting (room close, T-junction, single). Mirror that here instead - // of chaining the 2D draft from a dead point. + } else if ( + shouldStopWallDraftAfterCommit({ + locallyCreatedWall: createdWall, + publishedNextStart, + }) + ) { + // The mounted wall tool owns both the commit and the continuation + // decision in split and 2D-only views. It clears the published chain + // start whenever it stops drafting (loop close, room close, + // T-junction, single). Mirror that here instead of chaining the 2D + // draft from a dead point. clearWallPlacementDraft() setCursorPoint(null) return diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 7010f8a86..b749d4097 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -21,6 +21,7 @@ import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel, resolveEndpointWallSplit, + shouldStopWallDraftAfterCommit, snapWallDraftPointDetailed, } from './wall-drafting' import type { WallPlanPoint } from './wall-snap-geometry' @@ -36,6 +37,35 @@ if (typeof globalThis.requestAnimationFrame === 'undefined') { const LEVEL_ID = 'level_test' as AnyNodeId +describe('shouldStopWallDraftAfterCommit', () => { + test('stops the 2D draft when the mounted wall tool closes the chain', () => { + expect( + shouldStopWallDraftAfterCommit({ + locallyCreatedWall: null, + publishedNextStart: null, + }), + ).toBe(true) + }) + + test('continues from a next start published by the mounted wall tool', () => { + expect( + shouldStopWallDraftAfterCommit({ + locallyCreatedWall: null, + publishedNextStart: [4, 0], + }), + ).toBe(false) + }) + + test('lets the 2D fallback committer decide from its created wall', () => { + expect( + shouldStopWallDraftAfterCommit({ + locallyCreatedWall: makeWall([0, 0], [4, 0], 'wall_created'), + publishedNextStart: null, + }), + ).toBe(false) + }) +}) + function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode { return { ...WallSchema.parse({ start, end, name: id }), diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index adf8ddc42..ecacc8e0f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -476,6 +476,16 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } +export function shouldStopWallDraftAfterCommit({ + locallyCreatedWall, + publishedNextStart, +}: { + locallyCreatedWall: WallNode | null + publishedNextStart: WallPlanPoint | null +}): boolean { + return locallyCreatedWall === null && publishedNextStart === null +} + export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, From 7ed39c27244ad0d0c564b5b83b60d0fb5daa4b83 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 00:18:40 +0530 Subject: [PATCH 08/14] fix: reconcile surfaces across wall topology changes --- packages/core/src/lib/space-detection.test.ts | 869 +++++++++++++++++- packages/core/src/lib/space-detection.ts | 861 +++++++++-------- .../tools/wall/wall-drafting.test.ts | 71 +- .../components/tools/wall/wall-drafting.ts | 5 +- .../tools/wall/wall-snap-geometry.ts | 44 +- packages/editor/src/lib/scene.test.ts | 74 ++ packages/editor/src/lib/scene.ts | 34 +- 7 files changed, 1565 insertions(+), 393 deletions(-) create mode 100644 packages/editor/src/lib/scene.test.ts diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 676dc4ae6..1ab01d66d 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -3,6 +3,11 @@ import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } fr import type { AnyNode, AnyNodeId } from '../schema/types' import { resolveCeilingHeight } from '../services/level-height' import { getCeilingClampBound } from '../services/storey' +import { + notifySceneCommit, + type SceneCommitOrigin, + type SceneSnapshot, +} from '../store/history-control' import { detectSpacesForLevel, initSpaceDetectionSync, @@ -33,6 +38,10 @@ function squareWalls(height = 2.5) { ] } +function wallsForTest(nodes: AnyNode[]) { + return nodes.filter((node): node is WallNode => node.type === 'wall') +} + function slab(elevation: number) { return SlabNode.parse({ polygon: square, @@ -326,6 +335,65 @@ describe('planAutoCeilingsForLevel', () => { expect(right?.holes).toEqual([rightHole]) expect(right?.holeMetadata).toEqual([{ source: 'stair', stairId: 'stair_right' }]) }) + + test('a stair opening crossing a divider is clipped into both split ceilings', () => { + const crossingHole: Array<[number, number]> = [ + [1.5, 1], + [2.5, 1], + [2.5, 2], + [1.5, 2], + ] + const ceiling = CeilingNode.parse({ + polygon: square, + holes: [crossingHole], + holeMetadata: [{ source: 'stair', stairId: 'stair_crossing' }], + autoFromWalls: true, + }) + const rooms = [ + [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + [ + { x: 2, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 3 }, + ], + ] + + const plan = planAutoCeilingsForLevel(rooms, [ceiling]) + const surfaces = [CeilingNode.parse({ ...ceiling, ...plan.update[0]?.data }), ...plan.create] + const holes = surfaces.flatMap((surface) => surface.holes) + + expect(holes).toHaveLength(2) + expect(holes).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + [1.5, 1], + [2, 1], + [2, 2], + [1.5, 2], + ]), + expect.arrayContaining([ + [2, 1], + [2.5, 1], + [2.5, 2], + [2, 2], + ]), + ]), + ) + expect( + surfaces.every( + (surface) => + surface.holeMetadata.length === 1 && + surface.holeMetadata[0]?.source === 'stair' && + surface.holeMetadata[0]?.stairId === 'stair_crossing', + ), + ).toBe(true) + }) }) // Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper @@ -462,9 +530,22 @@ function createSceneStoreStub(initialNodes: Record) { return () => listeners.delete(listener) }, temporal: { getState: () => ({ pause() {}, resume() {} }) }, - setNodes(next: Record) { + setNodes(next: Record, origin: SceneCommitOrigin = 'local') { + const before = state.nodes state.nodes = next notify() + const snapshot = (nodes: Record): SceneSnapshot => ({ + nodes: nodes as Record, + rootNodeIds: [], + collections: {}, + materials: {}, + installedPlugins: [], + }) + notifySceneCommit({ + origin, + before: snapshot(before), + current: snapshot(next), + }) }, } } @@ -513,6 +594,128 @@ function roomWithGeneratedSurfaces() { return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record } +function twoSeparatedRoomsWithGeneratedSurfaces() { + const leftWalls = [ + WallNode.parse({ id: 'wall_left_bottom', start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_left_right', start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_left_top', start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_left_left', start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const rightWalls = [ + WallNode.parse({ id: 'wall_right_bottom', start: [10, 0], end: [14, 0], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_right_right', start: [14, 0], end: [14, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_right_top', start: [14, 3], end: [10, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_right_left', start: [10, 3], end: [10, 0], parentId: 'level_0' }), + ] + const leftSlab = SlabNode.parse({ + id: 'slab_left', + parentId: 'level_0', + polygon: square, + autoFromWalls: true, + }) + const rightSlab = SlabNode.parse({ + id: 'slab_right', + parentId: 'level_0', + polygon: [ + [10, 0], + [14, 0], + [14, 3], + [10, 3], + ], + autoFromWalls: true, + }) + const leftCeiling = CeilingNode.parse({ + id: 'ceiling_left', + parentId: 'level_0', + polygon: leftSlab.polygon, + autoFromWalls: true, + }) + const rightCeiling = CeilingNode.parse({ + id: 'ceiling_right', + parentId: 'level_0', + polygon: rightSlab.polygon, + autoFromWalls: true, + }) + const children = [...leftWalls, ...rightWalls, leftSlab, rightSlab, leftCeiling, rightCeiling] + const nodes = [ + BuildingNode.parse({ id: 'building_a', children: ['level_0'] }), + LevelNode.parse({ + id: 'level_0', + parentId: 'building_a', + children: children.map((node) => node.id), + }), + ...children, + ] + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +function splitRoomWithGeneratedSurfaces() { + const nodes = roomWithGeneratedSurfaces() + const divider = WallNode.parse({ + id: 'wall_divider', + start: [2, 0], + end: [2, 3], + parentId: 'level_0', + }) + const leftSlab = SlabNode.parse({ + id: 'slab_left', + parentId: 'level_0', + polygon: [ + [0, 0], + [2, 0], + [2, 3], + [0, 3], + ], + autoFromWalls: true, + }) + const rightSlab = SlabNode.parse({ + id: 'slab_right', + parentId: 'level_0', + polygon: [ + [2, 0], + [4, 0], + [4, 3], + [2, 3], + ], + autoFromWalls: true, + }) + const leftCeiling = CeilingNode.parse({ + id: 'ceiling_left', + parentId: 'level_0', + polygon: leftSlab.polygon, + autoFromWalls: true, + }) + const rightCeiling = CeilingNode.parse({ + id: 'ceiling_right', + parentId: 'level_0', + polygon: rightSlab.polygon, + autoFromWalls: true, + }) + delete nodes.slab_main + delete nodes.ceiling_main + nodes[divider.id] = divider + nodes[leftSlab.id] = leftSlab + nodes[rightSlab.id] = rightSlab + nodes[leftCeiling.id] = leftCeiling + nodes[rightCeiling.id] = rightCeiling + const level = nodes.level_0 + if (level?.type === 'level') { + nodes.level_0 = { + ...level, + children: [ + ...level.children.filter((id) => id !== 'slab_main' && id !== 'ceiling_main'), + divider.id, + leftSlab.id, + rightSlab.id, + leftCeiling.id, + rightCeiling.id, + ], + } + } + return nodes +} + describe('generated surface deletion through the detection sync', () => { test('a deleted generated slab stays deleted', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) @@ -525,6 +728,9 @@ describe('generated surface deletion through the detection sync', () => { expect( Object.values(sceneStore.getState().nodes).filter((node) => node.type === 'slab'), ).toHaveLength(0) + expect(sceneStore.getState().nodes.level_0?.metadata).not.toHaveProperty( + 'autoSurfaceSuppressions', + ) } finally { unsubscribe() } @@ -537,6 +743,9 @@ describe('generated surface deletion through the detection sync', () => { try { const { ceiling_main: _deleted, ...withoutCeiling } = sceneStore.getState().nodes sceneStore.setNodes(withoutCeiling) + const levelAfterDelete = sceneStore.getState().nodes.level_0 + expect(levelAfterDelete?.metadata).not.toHaveProperty('autoSurfaceSuppressions') + const wall = sceneStore.getState().nodes.wall_1 as WallNode sceneStore.setNodes({ ...sceneStore.getState().nodes, @@ -551,7 +760,7 @@ describe('generated surface deletion through the detection sync', () => { } }) - test('deleted generated surfaces stay suppressed after the sync is reinitialized', () => { + test('a reshaped room keeps its missing surfaces after the sync is reinitialized', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) let unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) @@ -565,16 +774,490 @@ describe('generated surface deletion through the detection sync', () => { unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) try { - const wall = sceneStore.getState().nodes.wall_1 as WallNode + const current = sceneStore.getState().nodes sceneStore.setNodes({ - ...sceneStore.getState().nodes, - wall_1: { ...wall, height: 2.6 }, + ...current, + wall_1: { ...(current.wall_1 as WallNode), end: [5, 0] }, + wall_2: { ...(current.wall_2 as WallNode), start: [5, 0], end: [5, 3] }, + wall_3: { ...(current.wall_3 as WallNode), start: [5, 3] }, + }) + + const surfaces = Object.values(sceneStore.getState().nodes).filter( + (node) => node.type === 'slab' || node.type === 'ceiling', + ) + expect(surfaces).toHaveLength(0) + expect(sceneStore.getState().nodes.level_0?.metadata).not.toHaveProperty( + 'autoSurfaceSuppressions', + ) + } finally { + unsubscribe() + } + }) + + test('slab and ceiling deletion are preserved independently during a reshape', () => { + const initial = roomWithGeneratedSurfaces() + delete initial.slab_main + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + wall_1: { ...(current.wall_1 as WallNode), end: [5, 0] }, + wall_2: { ...(current.wall_2 as WallNode), start: [5, 0], end: [5, 3] }, + wall_3: { ...(current.wall_3 as WallNode), start: [5, 3] }, }) + const slabs = Object.values(sceneStore.getState().nodes).filter( + (node) => node.type === 'slab', + ) + const ceilings = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + expect(slabs).toHaveLength(0) + expect(ceilings).toHaveLength(1) + expect(ceilings[0]?.polygon).toContainEqual([5, 0]) + expect(ceilings[0]?.polygon).toContainEqual([5, 3]) + } finally { + unsubscribe() + } + }) + + test('a distant wall edit does not recreate missing surfaces in an unchanged room', () => { + const initialNodes = twoSeparatedRoomsWithGeneratedSurfaces() + const { + slab_left: _deletedSlab, + ceiling_left: _deletedCeiling, + ...withoutLeftSurfaces + } = initialNodes + const sceneStore = createSceneStoreStub(withoutLeftSurfaces) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + wall_right_bottom: { + ...(current.wall_right_bottom as WallNode), + end: [15, 0], + }, + wall_right_right: { + ...(current.wall_right_right as WallNode), + start: [15, 0], + end: [15, 3], + }, + wall_right_top: { + ...(current.wall_right_top as WallNode), + start: [15, 3], + }, + }) + + const slabs = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'slab') + .map((node) => SlabNode.parse(node)) + const ceilings = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + expect(slabs).toHaveLength(1) + expect(ceilings).toHaveLength(1) + expect(slabs[0]?.id).toBe('slab_right') + expect(ceilings[0]?.id).toBe('ceiling_right') + expect(slabs[0]?.polygon).toContainEqual([15, 0]) + expect(slabs[0]?.polygon).toContainEqual([15, 3]) + expect(ceilings[0]?.polygon).toContainEqual([15, 0]) + expect(ceilings[0]?.polygon).toContainEqual([15, 3]) + } finally { + unsubscribe() + } + }) + + test('loading an older project does not generate its missing surfaces', () => { + const sceneStore = createSceneStoreStub({}) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const loaded = roomWithGeneratedSurfaces() + delete loaded.slab_main + delete loaded.ceiling_main + + try { + sceneStore.setNodes(loaded, 'load') + const surfaces = Object.values(sceneStore.getState().nodes).filter( (node) => node.type === 'slab' || node.type === 'ceiling', ) expect(surfaces).toHaveLength(0) + expect(sceneStore.getState().nodes.level_0?.metadata).not.toHaveProperty( + 'autoSurfaceSuppressions', + ) + } finally { + unsubscribe() + } + }) +}) + +describe('topology-delta room surface reconciliation', () => { + test('closing a genuinely new room creates its initial slab and ceiling', () => { + const initial = roomWithGeneratedSurfaces() + delete initial.wall_4 + delete initial.slab_main + delete initial.ceiling_main + const level = initial.level_0 + if (level?.type === 'level') { + initial.level_0 = { + ...level, + children: level.children.filter( + (id) => id !== 'wall_4' && id !== 'slab_main' && id !== 'ceiling_main', + ), + } + } + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + const closingWall = WallNode.parse({ + id: 'wall_4', + start: [0, 3], + end: [0, 0], + parentId: 'level_0', + }) + const currentLevel = current.level_0 + sceneStore.setNodes({ + ...current, + wall_4: closingWall, + ...(currentLevel?.type === 'level' + ? { + level_0: { + ...currentLevel, + children: [...currentLevel.children, closingWall.id], + }, + } + : {}), + }) + + const nodes = Object.values(sceneStore.getState().nodes) + expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(1) + expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(1) + } finally { + unsubscribe() + } + }) + + test('a new bay against an existing room gets surfaces even when the older room has none', () => { + const initial = roomWithGeneratedSurfaces() + delete initial.slab_main + delete initial.ceiling_main + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + const bayWalls = [ + WallNode.parse({ + id: 'wall_bay_left', + start: [1, 0], + end: [1, -2], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_bay_bottom', + start: [1, -2], + end: [3, -2], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_bay_right', + start: [3, -2], + end: [3, 0], + parentId: 'level_0', + }), + ] + sceneStore.setNodes({ + ...current, + ...Object.fromEntries(bayWalls.map((wall) => [wall.id, wall])), + }) + + const nodes = Object.values(sceneStore.getState().nodes) + const slabs = nodes.filter((node) => node.type === 'slab').map((node) => SlabNode.parse(node)) + const ceilings = nodes + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + expect(detectSpacesForLevel('level_0', wallsForTest(nodes)).roomPolygons).toHaveLength(2) + expect(slabs).toHaveLength(1) + expect(ceilings).toHaveLength(1) + expect(slabs[0]?.polygon.some(([, z]) => z < 0)).toBe(true) + expect(ceilings[0]?.polygon.some(([, z]) => z < 0)).toBe(true) + } finally { + unsubscribe() + } + }) + + test('splitting a room without generated surfaces preserves their absence', () => { + const initial = roomWithGeneratedSurfaces() + delete initial.slab_main + delete initial.ceiling_main + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + const divider = WallNode.parse({ + id: 'wall_divider', + start: [2, 0], + end: [2, 3], + parentId: 'level_0', + }) + const currentLevel = current.level_0 + sceneStore.setNodes({ + ...current, + wall_divider: divider, + ...(currentLevel?.type === 'level' + ? { + level_0: { + ...currentLevel, + children: [...currentLevel.children, divider.id], + }, + } + : {}), + }) + + const nodes = Object.values(sceneStore.getState().nodes) + expect(detectSpacesForLevel('level_0', wallsForTest(nodes)).roomPolygons).toHaveLength(2) + expect(nodes.filter((node) => node.type === 'slab')).toHaveLength(0) + expect(nodes.filter((node) => node.type === 'ceiling')).toHaveLength(0) + } finally { + unsubscribe() + } + }) + + test('deleting a divider merges compatible generated surfaces', () => { + const initial = splitRoomWithGeneratedSurfaces() + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const { wall_divider: _deleted, ...withoutDivider } = sceneStore.getState().nodes + sceneStore.setNodes(withoutDivider) + + const nodes = Object.values(sceneStore.getState().nodes) + const slabs = nodes.filter((node) => node.type === 'slab').map((node) => SlabNode.parse(node)) + const ceilings = nodes + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + expect(slabs).toHaveLength(1) + expect(ceilings).toHaveLength(1) + expect(slabs[0]?.polygon).toEqual(expect.arrayContaining(square)) + expect(ceilings[0]?.polygon).toEqual(expect.arrayContaining(square)) + } finally { + unsubscribe() + } + }) + + test('merging rooms does not fill an area whose slab was already missing', () => { + const initial = splitRoomWithGeneratedSurfaces() + delete initial.slab_left + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const { wall_divider: _deleted, ...withoutDivider } = sceneStore.getState().nodes + sceneStore.setNodes(withoutDivider) + + const slabs = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'slab') + .map((node) => SlabNode.parse(node)) + expect(slabs).toHaveLength(1) + expect(slabs[0]?.autoFromWalls).toBe(false) + expect(slabs[0]?.polygon).toEqual([ + [2, 0], + [4, 0], + [4, 3], + [2, 3], + ]) + } finally { + unsubscribe() + } + }) + + test('editing one room does not shrink a legacy surface shared with an unaffected room', () => { + const initial = twoSeparatedRoomsWithGeneratedSurfaces() + delete initial.slab_left + delete initial.slab_right + delete initial.ceiling_left + delete initial.ceiling_right + const sharedPolygon: Array<[number, number]> = [ + [0, 0], + [14, 0], + [14, 3], + [0, 3], + ] + const sharedSlab = SlabNode.parse({ + id: 'slab_shared', + parentId: 'level_0', + polygon: sharedPolygon, + autoFromWalls: true, + }) + const sharedCeiling = CeilingNode.parse({ + id: 'ceiling_shared', + parentId: 'level_0', + polygon: sharedPolygon, + autoFromWalls: true, + }) + initial[sharedSlab.id] = sharedSlab + initial[sharedCeiling.id] = sharedCeiling + const level = initial.level_0 + if (level?.type === 'level') { + initial.level_0 = { + ...level, + children: [ + ...level.children.filter( + (id) => + id !== 'slab_left' && + id !== 'slab_right' && + id !== 'ceiling_left' && + id !== 'ceiling_right', + ), + sharedSlab.id, + sharedCeiling.id, + ], + } + } + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + wall_right_bottom: { + ...(current.wall_right_bottom as WallNode), + end: [15, 0], + }, + wall_right_right: { + ...(current.wall_right_right as WallNode), + start: [15, 0], + end: [15, 3], + }, + wall_right_top: { + ...(current.wall_right_top as WallNode), + start: [15, 3], + }, + }) + + expect((sceneStore.getState().nodes.slab_shared as SlabNode).polygon).toEqual(sharedPolygon) + expect((sceneStore.getState().nodes.ceiling_shared as CeilingNode).polygon).toEqual( + sharedPolygon, + ) + } finally { + unsubscribe() + } + }) + + test('moving a divider keeps the existing side automatic without filling the missing side', () => { + const initial = splitRoomWithGeneratedSurfaces() + delete initial.slab_left + delete initial.ceiling_left + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + wall_divider: { + ...(current.wall_divider as WallNode), + start: [2.5, 0], + end: [2.5, 3], + }, + }) + + const slabs = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'slab') + .map((node) => SlabNode.parse(node)) + const ceilings = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'ceiling') + .map((node) => CeilingNode.parse(node)) + expect(slabs).toHaveLength(1) + expect(ceilings).toHaveLength(1) + expect(slabs[0]?.autoFromWalls).toBe(true) + expect(ceilings[0]?.autoFromWalls).toBe(true) + expect(slabs[0]?.polygon).toContainEqual([2.5, 0]) + expect(slabs[0]?.polygon).toContainEqual([2.5, 3]) + expect(ceilings[0]?.polygon).toContainEqual([2.5, 0]) + expect(ceilings[0]?.polygon).toContainEqual([2.5, 3]) + } finally { + unsubscribe() + } + }) + + test('separately closed rooms receive unique generated surface names', () => { + const initial = twoSeparatedRoomsWithGeneratedSurfaces() + const closingWall = initial.wall_right_left as WallNode + delete initial.wall_right_left + delete initial.slab_right + delete initial.ceiling_right + initial.slab_left = SlabNode.parse({ ...initial.slab_left, name: 'Room 1 Slab' }) + initial.ceiling_left = CeilingNode.parse({ + ...initial.ceiling_left, + name: 'Room 1 Ceiling', + }) + const level = initial.level_0 + if (level?.type === 'level') { + initial.level_0 = { + ...level, + children: level.children.filter( + (id) => id !== closingWall.id && id !== 'slab_right' && id !== 'ceiling_right', + ), + } + } + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + const currentLevel = current.level_0 + sceneStore.setNodes({ + ...current, + [closingWall.id]: closingWall, + ...(currentLevel?.type === 'level' + ? { + level_0: { + ...currentLevel, + children: [...currentLevel.children, closingWall.id], + }, + } + : {}), + }) + + const slabNames = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'slab') + .map((node) => node.name) + const ceilingNames = Object.values(sceneStore.getState().nodes) + .filter((node) => node.type === 'ceiling') + .map((node) => node.name) + expect(new Set(slabNames).size).toBe(2) + expect(new Set(ceilingNames).size).toBe(2) + expect(slabNames).toContain('Room 2 Slab') + expect(ceilingNames).toContain('Room 2 Ceiling') + } finally { + unsubscribe() + } + }) + + test('deleting an exterior wall leaves generated slabs and ceilings unchanged', () => { + const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const slabBefore = sceneStore.getState().nodes.slab_main + const ceilingBefore = sceneStore.getState().nodes.ceiling_main + + try { + const { wall_1: _deleted, ...withoutBoundaryWall } = sceneStore.getState().nodes + sceneStore.setNodes(withoutBoundaryWall) + + const nodes = Object.values(sceneStore.getState().nodes) + expect(detectSpacesForLevel('level_0', wallsForTest(nodes)).roomPolygons).toHaveLength(0) + expect(sceneStore.getState().nodes.slab_main).toEqual(slabBefore) + expect(sceneStore.getState().nodes.ceiling_main).toEqual(ceilingBefore) } finally { unsubscribe() } @@ -641,6 +1324,123 @@ describe('reactive ceiling re-clamp through the detection sync', () => { unsubscribe() } }) + + test('an auto slab expanded by an upper-room reshape clamps a newly covered ceiling', () => { + const upperWalls = [ + WallNode.parse({ id: 'wall_upper_1', start: [0, 0], end: [1, 0], parentId: 'level_1' }), + WallNode.parse({ id: 'wall_upper_2', start: [1, 0], end: [1, 3], parentId: 'level_1' }), + WallNode.parse({ id: 'wall_upper_3', start: [1, 3], end: [0, 3], parentId: 'level_1' }), + WallNode.parse({ id: 'wall_upper_4', start: [0, 3], end: [0, 0], parentId: 'level_1' }), + ] + const upperSlab = SlabNode.parse({ + id: 'slab_upper', + parentId: 'level_1', + polygon: [ + [0, 0], + [1, 0], + [1, 3], + [0, 3], + ], + elevation: 0, + thickness: 0.3, + autoFromWalls: true, + }) + const lowerCeilingPolygon: Array<[number, number]> = [ + [2, 0], + [4, 0], + [4, 3], + [2, 3], + ] + const manualCeiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: 'level_0', + polygon: lowerCeilingPolygon, + height: 2.49, + autoFromWalls: false, + }) + const initialNodes = Object.fromEntries( + [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: [manualCeiling.id], + }), + LevelNode.parse({ + id: 'level_1', + level: 1, + height: 2.5, + parentId: 'building_a', + children: [...upperWalls.map((wall) => wall.id), upperSlab.id], + }), + ...upperWalls, + upperSlab, + manualCeiling, + ].map((node) => [node.id, node]), + ) as Record + const sceneStore = createSceneStoreStub(initialNodes) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + sceneStore.setNodes({ + ...current, + wall_upper_1: { ...(current.wall_upper_1 as WallNode), end: [4, 0] }, + wall_upper_2: { + ...(current.wall_upper_2 as WallNode), + start: [4, 0], + end: [4, 3], + }, + wall_upper_3: { ...(current.wall_upper_3 as WallNode), start: [4, 3] }, + }) + + const expandedUpperSlab = sceneStore.getState().nodes.slab_upper as SlabNode + const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode + expect(expandedUpperSlab.polygon).toContainEqual([4, 0]) + expect(expandedUpperSlab.polygon).toContainEqual([4, 3]) + expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01) + } finally { + unsubscribe() + } + }) +}) + +describe('procedural zone sync isolation', () => { + test('creating a matching zone adopts room walls without reconciling surfaces', () => { + const initial = roomWithGeneratedSurfaces() + delete initial.slab_main + delete initial.ceiling_main + const sceneStore = createSceneStoreStub(initial) + const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + + try { + const current = sceneStore.getState().nodes + const zone = ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_0', + name: 'Kitchen', + polygon: square, + }) + sceneStore.setNodes({ ...current, [zone.id]: zone }) + + const updated = sceneStore.getState().nodes[zone.id] + expect(updated?.type).toBe('zone') + if (updated?.type !== 'zone') return + expect(updated.autoFromWalls).toBe(true) + expect(new Set(updated.boundaryWallIds)).toEqual( + new Set(['wall_1', 'wall_2', 'wall_3', 'wall_4']), + ) + expect( + Object.values(sceneStore.getState().nodes).filter( + (node) => node.type === 'slab' || node.type === 'ceiling', + ), + ).toHaveLength(0) + } finally { + unsubscribe() + } + }) }) describe('detectSpacesForLevel', () => { @@ -1115,4 +1915,63 @@ describe('planAutoSlabsForLevel', () => { expect(right?.holes).toEqual([rightHole]) expect(right?.holeMetadata).toEqual([{ source: 'stair', stairId: 'stair_right' }]) }) + + test('an elevator opening crossing a divider is clipped into both split slabs', () => { + const crossingHole: Array<[number, number]> = [ + [1.5, 1], + [2.5, 1], + [2.5, 2], + [1.5, 2], + ] + const auto = SlabNode.parse({ + polygon: square, + holes: [crossingHole], + holeMetadata: [{ source: 'elevator', elevatorId: 'elevator_crossing' }], + autoFromWalls: true, + }) + const rooms = [ + [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + [ + { x: 2, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 3 }, + ], + ] + + const plan = planAutoSlabsForLevel(rooms, [auto]) + const surfaces = [SlabNode.parse({ ...auto, ...plan.update[0]?.data }), ...plan.create] + const holes = surfaces.flatMap((surface) => surface.holes) + + expect(holes).toHaveLength(2) + expect(holes).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + [1.5, 1], + [2, 1], + [2, 2], + [1.5, 2], + ]), + expect.arrayContaining([ + [2, 1], + [2.5, 1], + [2.5, 2], + [2, 2], + ]), + ]), + ) + expect( + surfaces.every( + (surface) => + surface.holeMetadata.length === 1 && + surface.holeMetadata[0]?.source === 'elevator' && + surface.holeMetadata[0]?.elevatorId === 'elevator_crossing', + ), + ).toBe(true) + }) }) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index bb24508da..789dbbfcf 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -12,16 +12,13 @@ import { import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' import { CEILING_CLAMP_MARGIN, - findLevelAboveId, getCeilingClampBound, - getLevelElevations, getStoredLevelHeight, } from '../services/storey' import { - getSceneHistoryPauseDepth, - isSceneCommitTransactionActive, pauseSceneHistory, resumeSceneHistory, + type SceneCommit, subscribeSceneCommits, } from '../store/history-control' import { @@ -83,21 +80,6 @@ export type AutoZoneSyncPlan = { update: Array<{ id: ZoneNodeType['id']; data: Partial }> } -type AutoSurfaceKind = 'slab' | 'ceiling' - -type AutoSurfaceSuppressionMetadata = Partial> - -type AutoSurfaceSnapshot = { - id: string - levelId: string - kind: AutoSurfaceKind - roomSignature: string -} - -export type AutoSlabPlanningContext = { - suppressedRoomSignatures?: readonly string[] -} - const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 @@ -120,7 +102,6 @@ const COVERAGE_SAMPLE_STEPS = 12 // no wall/slab inputs anymore — only the bound for the explicit-height // reactive re-clamp below. export type AutoCeilingPlanningContext = { - suppressedRoomSignatures?: readonly string[] /** Stored storey height of the level being planned (floor-to-floor). */ storeyHeight?: number /** @@ -174,92 +155,6 @@ function polygonSignature(points: Point2D[]) { return forward < reversed ? forward : reversed } -function readAutoSurfaceSuppressions(level: unknown): AutoSurfaceSuppressionMetadata { - if (!(level && typeof level === 'object' && 'metadata' in level)) return {} - const metadata = level.metadata - if (!(metadata && typeof metadata === 'object' && !Array.isArray(metadata))) return {} - const value = (metadata as Record).autoSurfaceSuppressions - if (!(value && typeof value === 'object' && !Array.isArray(value))) return {} - - const record = value as Record - return { - slab: Array.isArray(record.slab) - ? record.slab.filter((entry): entry is string => typeof entry === 'string') - : [], - ceiling: Array.isArray(record.ceiling) - ? record.ceiling.filter((entry): entry is string => typeof entry === 'string') - : [], - } -} - -function autoSurfaceSnapshots(nodes: Record) { - const snapshots = new Map() - - for (const node of Object.values(nodes)) { - if ( - !( - node && - (node.type === 'slab' || node.type === 'ceiling') && - node.autoFromWalls === true && - typeof node.parentId === 'string' && - Array.isArray(node.polygon) - ) - ) { - continue - } - - snapshots.set(node.id, { - id: node.id, - levelId: node.parentId, - kind: node.type, - roomSignature: polygonSignature(node.polygon.map(pointFromTuple)), - }) - } - - return snapshots -} - -function recordDeletedAutoSurfaceSuppressions( - previousSurfaces: Map, - nodes: Record, - sceneStore: any, -) { - const additionsByLevel = new Map() - - for (const surface of previousSurfaces.values()) { - if (nodes[surface.id] || nodes[surface.levelId]?.type !== 'level') continue - const additions = additionsByLevel.get(surface.levelId) ?? {} - const signatures = additions[surface.kind] ?? [] - if (!signatures.includes(surface.roomSignature)) signatures.push(surface.roomSignature) - additions[surface.kind] = signatures - additionsByLevel.set(surface.levelId, additions) - } - - const updates = [...additionsByLevel.entries()].flatMap(([levelId, additions]) => { - const level = nodes[levelId] - if (level?.type !== 'level') return [] - const existing = readAutoSurfaceSuppressions(level) - const next: AutoSurfaceSuppressionMetadata = { - slab: [...new Set([...(existing.slab ?? []), ...(additions.slab ?? [])])], - ceiling: [...new Set([...(existing.ceiling ?? []), ...(additions.ceiling ?? [])])], - } - return [ - { - id: levelId, - data: { - metadata: { - ...(level.metadata && typeof level.metadata === 'object' ? level.metadata : {}), - autoSurfaceSuppressions: next, - }, - }, - }, - ] - }) - - if (updates.length > 0) sceneStore.getState().updateNodes(updates) - return updates.length > 0 -} - function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance } @@ -895,6 +790,127 @@ type SurfaceWithOpenings = { holeMetadata: SlabNodeType['holeMetadata'] } +function crossProduct(a: Point2D, b: Point2D, c: Point2D) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) +} + +function lineIntersection(start: Point2D, end: Point2D, clipStart: Point2D, clipEnd: Point2D) { + const segment = { x: end.x - start.x, y: end.y - start.y } + const clip = { x: clipEnd.x - clipStart.x, y: clipEnd.y - clipStart.y } + const denominator = segment.x * clip.y - segment.y * clip.x + if (Math.abs(denominator) < 1e-9) return end + const offset = { x: clipStart.x - start.x, y: clipStart.y - start.y } + const t = (offset.x * clip.y - offset.y * clip.x) / denominator + return { x: start.x + segment.x * t, y: start.y + segment.y * t } +} + +function clipPolygonToConvex(subject: Point2D[], clipPolygon: Point2D[]) { + if (subject.length < 3 || clipPolygon.length < 3) return [] + const orientation = polygonArea(clipPolygon) >= 0 ? 1 : -1 + let output = [...subject] + + for (let index = 0; index < clipPolygon.length; index += 1) { + const clipStart = clipPolygon[index]! + const clipEnd = clipPolygon[(index + 1) % clipPolygon.length]! + const input = output + output = [] + if (input.length === 0) break + + let previous = input[input.length - 1]! + let previousInside = orientation * crossProduct(clipStart, clipEnd, previous) >= -1e-8 + for (const current of input) { + const currentInside = orientation * crossProduct(clipStart, clipEnd, current) >= -1e-8 + if (currentInside !== previousInside) { + output.push(lineIntersection(previous, current, clipStart, clipEnd)) + } + if (currentInside) output.push(current) + previous = current + previousInside = currentInside + } + output = dedupeSequentialPoints(output, 1e-7) + } + + return output.length >= 3 && Math.abs(polygonArea(output)) > 1e-8 ? output : [] +} + +function isConvexPolygon(polygon: Point2D[]) { + let direction = 0 + for (let index = 0; index < polygon.length; index += 1) { + const cross = crossProduct( + polygon[index]!, + polygon[(index + 1) % polygon.length]!, + polygon[(index + 2) % polygon.length]!, + ) + if (Math.abs(cross) < 1e-8) continue + const nextDirection = Math.sign(cross) + if (direction !== 0 && nextDirection !== direction) return false + direction = nextDirection + } + return true +} + +function pointInTriangle(point: Point2D, a: Point2D, b: Point2D, c: Point2D) { + return ( + crossProduct(a, b, point) >= -1e-8 && + crossProduct(b, c, point) >= -1e-8 && + crossProduct(c, a, point) >= -1e-8 + ) +} + +function triangulatePolygon(polygon: Point2D[]) { + const points = polygonArea(polygon) >= 0 ? [...polygon] : [...polygon].reverse() + const indices = points.map((_, index) => index) + const triangles: Point2D[][] = [] + let attempts = 0 + + while (indices.length > 3 && attempts < points.length * points.length) { + let clippedEar = false + for (let index = 0; index < indices.length; index += 1) { + const previousIndex = indices[(index - 1 + indices.length) % indices.length]! + const currentIndex = indices[index]! + const nextIndex = indices[(index + 1) % indices.length]! + const previous = points[previousIndex]! + const current = points[currentIndex]! + const next = points[nextIndex]! + if (crossProduct(previous, current, next) <= 1e-8) continue + if ( + indices.some( + (candidateIndex) => + candidateIndex !== previousIndex && + candidateIndex !== currentIndex && + candidateIndex !== nextIndex && + pointInTriangle(points[candidateIndex]!, previous, current, next), + ) + ) { + continue + } + triangles.push([previous, current, next]) + indices.splice(index, 1) + clippedEar = true + break + } + if (!clippedEar) break + attempts += 1 + } + + if (indices.length === 3) { + triangles.push(indices.map((index) => points[index]!)) + } + return triangles +} + +function clipOpeningToRoom(opening: Point2D[], room: Point2D[]) { + const openingIsInside = opening.every( + (point) => pointInPolygon(point, room) || pointDistanceToPolygonBoundary(point, room) <= 1e-7, + ) + if (openingIsInside) return [opening] + + const clipRegions = isConvexPolygon(room) ? [room] : triangulatePolygon(room) + return clipRegions + .map((region) => clipPolygonToConvex(opening, region)) + .filter((polygon) => polygon.length >= 3) +} + function partitionSurfaceOpenings( surface: SurfaceWithOpenings, roomIndices: number[], @@ -913,34 +929,17 @@ function partitionSurfaceOpenings( surface.holes.forEach((hole, holeIndex) => { const holePolygon = hole.map(pointFromTuple) - const holeCentroid = polygonCentroid(holePolygon) - let bestRoomIndex: number | null = null - let bestCoverage = Number.NEGATIVE_INFINITY - let bestDistance = Number.POSITIVE_INFINITY - for (const roomIndex of roomIndices) { const room = detected[roomIndex] if (!room) continue - const coverage = polygonCoverageRatio(holePolygon, [room.poly]) - const distance = Math.hypot( - holeCentroid.x - room.centroid.x, - holeCentroid.y - room.centroid.y, - ) - if ( - coverage > bestCoverage + 1e-6 || - (Math.abs(coverage - bestCoverage) <= 1e-6 && distance < bestDistance) - ) { - bestRoomIndex = roomIndex - bestCoverage = coverage - bestDistance = distance + const assignment = assignments.get(roomIndex) + if (!assignment) continue + const clippedOpenings = clipOpeningToRoom(holePolygon, room.poly) + for (const clipped of clippedOpenings) { + assignment.holes.push(clipped.map(pointToTuple)) + assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) } } - - if (bestRoomIndex == null) return - const assignment = assignments.get(bestRoomIndex) - if (!assignment) return - assignment.holes.push(hole) - assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) }) return assignments @@ -1011,97 +1010,10 @@ function wallGeometrySignature(wall: WallNode) { wall.start[1].toFixed(4), wall.end[0].toFixed(4), wall.end[1].toFixed(4), - (wall.thickness ?? 0.2).toFixed(4), - // Plane-bound (no stored height) is a distinct state, not a default - // value: it resolves to the storey plane, so it must not alias an - // explicit height of the same magnitude in the trigger signature. - wall.height == null ? 'plane' : wall.height.toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } -function levelWallSnapshot(walls: WallNode[]) { - return walls.map(wallGeometrySignature).sort().join('||') -} - -function zoneGeometrySignature(zone: ZoneNodeType) { - return [ - zone.id, - zone.autoFromWalls ? 'auto' : 'manual', - zone.boundaryWallIds.slice().sort().join(','), - zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'), - ].join('|') -} - -// Slab/ceiling POLYGONS stay out of the trigger signature: including -// generated footprints caused delete/recreate feedback. Zones are included -// only so a newly traced room footprint can adopt its enclosing walls -// without waiting for the next remodel. Slab ELEVATIONS and the level's -// stored storey height ARE included — both feed the explicit-ceiling -// re-clamp bound (the storey plane), and neither is rewritten by -// the sync, so regeneration triggers when they change without feedback. -// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − -// thickness, recessed pools excluded): a deck created, lowered, or -// thickened above must re-run the sync below so ceilings re-clamp under -// it. Same polygon exclusion applies — the level-above's own auto sync -// rewrites its slab footprints, and hashing them here would re-trigger -// this level on every remodel above. -function levelStructureSnapshots(nodes: Record) { - const wallsByLevel = new Map() - const zonesByLevel = new Map() - const slabElevationsByLevel = new Map() - const coveringUndersidesByLevel = new Map() - - for (const node of Object.values(nodes)) { - if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue - const levelId = (node as any).parentId as string - if ((node as any).type === 'wall') { - const walls = wallsByLevel.get(levelId) ?? [] - walls.push(node as WallNode) - wallsByLevel.set(levelId, walls) - } else if ((node as any).type === 'zone') { - const zones = zonesByLevel.get(levelId) ?? [] - zones.push(ZoneNode.parse(node)) - zonesByLevel.set(levelId, zones) - } else if ((node as any).type === 'slab') { - const elevations = slabElevationsByLevel.get(levelId) ?? [] - elevations.push( - `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, - ) - slabElevationsByLevel.set(levelId, elevations) - if ((node as any).recessed !== true) { - const undersides = coveringUndersidesByLevel.get(levelId) ?? [] - const elevation = ((node as any).elevation as number | undefined) ?? 0.05 - const thickness = ((node as any).thickness as number | undefined) ?? 0.05 - undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) - coveringUndersidesByLevel.set(levelId, undersides) - } - } - } - - const levelElevations = getLevelElevations(nodes as Record) - const snapshots = new Map() - const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) - for (const levelId of levelIds) { - const walls = wallsByLevel.get(levelId) ?? [] - const zones = zonesByLevel.get(levelId) ?? [] - const level = nodes[levelId] - const storeyKey = - level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' - const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') - const aboveId = findLevelAboveId(levelId, levelElevations) - const aboveSlabKey = aboveId - ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') - : '' - snapshots.set( - levelId, - `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, - ) - } - - return snapshots -} - function buildSpace(levelId: string, room: ExtractedRoom): Space { const signature = polygonSignature(room.polygon) return { @@ -1174,9 +1086,8 @@ export function resolveAutoZonePolygon( export function planAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], - context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, ): AutoSlabSyncPlan { - const suppressedRoomSignatures = new Set(context.suppressedRoomSignatures ?? []) const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), @@ -1202,10 +1113,7 @@ export function planAutoSlabsForLevel( })) const detected = detectedAll.filter( - ({ sig, poly }) => - !suppressedRoomSignatures.has(sig) && - !manualSignatures.has(sig) && - !matchesManualFootprint(poly, manualPolygons), + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), ) const existingAuto = existingSlabs.filter((slab) => slab.autoFromWalls) @@ -1365,7 +1273,7 @@ export function planAutoSlabsForLevel( }) slabsToUpdate.push(...slabDemotions) - const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] + const plannedSlabsForNaming: Array<{ name?: string }> = [...namingSlabs] const slabsToCreate: SlabNodeType[] = [] for (let index = 0; index < detected.length; index += 1) { if (matchedDetectedIdx.has(index)) continue @@ -1409,9 +1317,9 @@ function syncAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], sceneStore: any, - context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, ) { - const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context) + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, namingSlabs) if (plan.delete.length > 0) { sceneStore.getState().deleteNodes(plan.delete) @@ -1432,8 +1340,8 @@ export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, ): AutoCeilingSyncPlan { - const suppressedRoomSignatures = new Set(context.suppressedRoomSignatures ?? []) const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) const manualSignatures = new Set( manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))), @@ -1459,10 +1367,7 @@ export function planAutoCeilingsForLevel( })) const detected = detectedAll.filter( - ({ sig, poly }) => - !suppressedRoomSignatures.has(sig) && - !manualSignatures.has(sig) && - !matchesManualFootprint(poly, manualPolygons), + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), ) const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls) @@ -1649,7 +1554,7 @@ export function planAutoCeilingsForLevel( }) ceilingsToUpdate.push(...ceilingDemotions, ...manualClamps) - const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] + const plannedCeilingsForNaming: Array<{ name?: string }> = [...namingCeilings] const ceilingsToCreate: CeilingNodeType[] = [] for (let index = 0; index < detected.length; index += 1) { if (matchedDetectedIdx.has(index)) continue @@ -1703,8 +1608,9 @@ function syncAutoCeilingsForLevel( existingCeilings: CeilingNodeType[], sceneStore: any, context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, ) { - const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context) + const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings) if (plan.delete.length > 0) { sceneStore.getState().deleteNodes(plan.delete) @@ -1741,89 +1647,359 @@ export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { return detectSpacesFromWalls(levelId, walls) } -function runSpaceDetection( - levelIds: string[], - sceneStore: any, - editorStore: any, - nodes: any, -): void { - const { updateNodes } = sceneStore.getState() - const existingSpaces = editorStore.getState().spaces as Record - const nextSpaces: Record = {} +type SceneNodes = SceneCommit['current']['nodes'] - for (const [spaceId, space] of Object.entries(existingSpaces)) { - if (!levelIds.includes(space.levelId)) { - nextSpaces[spaceId] = space +function wallsForLevel(nodes: SceneNodes, levelId: string) { + return Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) +} + +function changedWallIdsByLevel(before: SceneNodes, current: SceneNodes) { + const changes = new Map>() + const wallIds = new Set() + for (const node of Object.values(before)) { + if (node?.type === 'wall') wallIds.add(node.id) + } + for (const node of Object.values(current)) { + if (node?.type === 'wall') wallIds.add(node.id) + } + + const markChanged = (levelId: string | null | undefined, wallId: string) => { + if (!levelId) return + const ids = changes.get(levelId) ?? new Set() + ids.add(wallId) + changes.set(levelId, ids) + } + + for (const wallId of wallIds) { + const beforeWall = before[wallId as AnyNodeId] + const currentWall = current[wallId as AnyNodeId] + const previous = beforeWall?.type === 'wall' ? beforeWall : null + const next = currentWall?.type === 'wall' ? currentWall : null + const unchanged = + previous && + next && + previous.parentId === next.parentId && + wallGeometrySignature(previous) === wallGeometrySignature(next) + if (unchanged) continue + markChanged(previous?.parentId, wallId) + markChanged(next?.parentId, wallId) + } + + return changes +} + +function zoneGeometrySignature(zone: ZoneNodeType) { + return [ + zone.autoFromWalls ? 'auto' : 'manual', + zone.boundaryWallIds.slice().sort().join(','), + zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'), + ].join('|') +} + +function changedZoneIdsByLevel(before: SceneNodes, current: SceneNodes) { + const changes = new Map>() + const zoneIds = new Set() + for (const node of Object.values(before)) { + if (node?.type === 'zone') zoneIds.add(node.id) + } + for (const node of Object.values(current)) { + if (node?.type === 'zone') zoneIds.add(node.id) + } + + const markChanged = (levelId: string | null | undefined, zoneId: string) => { + if (!levelId) return + const ids = changes.get(levelId) ?? new Set() + ids.add(zoneId) + changes.set(levelId, ids) + } + + for (const zoneId of zoneIds) { + const beforeNode = before[zoneId as AnyNodeId] + const currentNode = current[zoneId as AnyNodeId] + const previous = beforeNode?.type === 'zone' ? beforeNode : null + const next = currentNode?.type === 'zone' ? currentNode : null + const unchanged = + previous && + next && + previous.parentId === next.parentId && + zoneGeometrySignature(previous) === zoneGeometrySignature(next) + if (unchanged) continue + markChanged(previous?.parentId, zoneId) + markChanged(next?.parentId, zoneId) + } + + return changes +} + +function roomWallIds(room: ExtractedRoom) { + return new Set(room.boundaryFaces.map((boundary) => boundary.wallId)) +} + +function roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { + const beforeIds = roomWallIds(beforeRoom) + const currentIds = roomWallIds(currentRoom) + const sharedWallCount = [...currentIds].filter((wallId) => beforeIds.has(wallId)).length + const smallerBoundarySize = Math.min(beforeIds.size, currentIds.size) + if (sharedWallCount >= 2 && sharedWallCount >= Math.ceil(smallerBoundarySize / 2)) { + return true + } + if (bboxOverlapArea(bboxOf(beforeRoom.polygon), bboxOf(currentRoom.polygon)) <= 1e-6) { + return false + } + return ( + polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 || + polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0 + ) +} + +function affectedRoomsForWallDelta( + beforeRooms: ExtractedRoom[], + currentRooms: ExtractedRoom[], + changedWallIds: ReadonlySet, +) { + const beforeIndices = new Set() + const currentIndices = new Set() + + beforeRooms.forEach((room, index) => { + if (room.boundaryFaces.some((boundary) => changedWallIds.has(boundary.wallId))) { + beforeIndices.add(index) + } + }) + currentRooms.forEach((room, index) => { + if (room.boundaryFaces.some((boundary) => changedWallIds.has(boundary.wallId))) { + currentIndices.add(index) } + }) + + let changed = true + while (changed) { + changed = false + beforeRooms.forEach((beforeRoom, beforeIndex) => { + currentRooms.forEach((currentRoom, currentIndex) => { + if (!roomsAreRelated(beforeRoom, currentRoom)) return + if (beforeIndices.has(beforeIndex) && !currentIndices.has(currentIndex)) { + currentIndices.add(currentIndex) + changed = true + } + if (currentIndices.has(currentIndex) && !beforeIndices.has(beforeIndex)) { + beforeIndices.add(beforeIndex) + changed = true + } + }) + }) } - for (const levelId of levelIds) { - const walls = Object.values(nodes).filter( - (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, - ) + return { + before: [...beforeIndices].map((index) => beforeRooms[index]!).filter(Boolean), + current: [...currentIndices].map((index) => currentRooms[index]!).filter(Boolean), + } +} - const slabs = Object.values(nodes).filter( - (node: any) => node?.type === 'slab' && node.parentId === levelId, - ) - const ceilings = Object.values(nodes).filter( - (node: any) => node?.type === 'ceiling' && node.parentId === levelId, - ) - const zones = Object.values(nodes).filter( - (node: any) => node?.type === 'zone' && node.parentId === levelId, - ) +type RoomSurface = SlabNodeType | CeilingNodeType - const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls) +function surfaceTouchesRooms(surface: RoomSurface, rooms: ExtractedRoom[]) { + const polygon = surface.polygon.map(pointFromTuple) + return rooms.some( + (room) => + polygonCoverageRatio(polygon, [room.polygon]) > 0 || + polygonCoverageRatio(room.polygon, [polygon]) > 0, + ) +} + +function roomHasAutoSurface(room: ExtractedRoom, surfaces: RoomSurface[]) { + return matchesManualFootprint( + room.polygon, + surfaces + .filter((surface) => surface.autoFromWalls) + .map((surface) => surface.polygon.map(pointFromTuple)), + ) +} - const changedWallUpdates = wallUpdates.filter((update) => { - const wall = nodes[update.wallId] - return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) +function roomsEligibleForAutoSurface( + beforeRooms: ExtractedRoom[], + currentRooms: ExtractedRoom[], + currentSurfaces: RoomSurface[], +) { + return currentRooms.filter((currentRoom) => { + const related = beforeRooms.flatMap((beforeRoom) => { + if (!roomsAreRelated(beforeRoom, currentRoom)) return [] + return [ + { + room: beforeRoom, + coverage: polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]), + }, + ] }) + const maxCoverage = Math.max(0, ...related.map(({ coverage }) => coverage)) + const predecessors = + currentRooms.length >= beforeRooms.length && maxCoverage > 0 + ? related.filter(({ coverage }) => coverage >= maxCoverage - 1e-6).map(({ room }) => room) + : related.map(({ room }) => room) + if (predecessors.length === 0) return true + return predecessors.every((beforeRoom) => roomHasAutoSurface(beforeRoom, currentSurfaces)) + }) +} - if (changedWallUpdates.length > 0) { - updateNodes( - changedWallUpdates.map((update) => ({ - id: update.wallId, - data: { - frontSide: update.frontSide, - backSide: update.backSide, - }, - })), +function updateSpacesForLevel(levelId: string, spaces: Space[], editorStore: any) { + const existingSpaces = editorStore.getState().spaces as Record + const nextSpaces: Record = {} + for (const [spaceId, space] of Object.entries(existingSpaces)) { + if (space.levelId !== levelId) nextSpaces[spaceId] = space + } + for (const space of spaces) nextSpaces[space.id] = space + editorStore.getState().setSpaces(nextSpaces) +} + +function reconcileChangedZones( + levelId: string, + changedZoneIds: ReadonlySet, + currentNodes: SceneNodes, + sceneStore: any, +) { + const spaces = detectSpacesFromWalls(levelId, wallsForLevel(currentNodes, levelId)).spaces + const zones = [...changedZoneIds].flatMap((id) => { + const node = currentNodes[id as AnyNodeId] + return node?.type === 'zone' && node.parentId === levelId ? [ZoneNode.parse(node)] : [] + }) + const plan = planAutoZonesForLevel(spaces, zones) + if (plan.update.length > 0) sceneStore.getState().updateNodes(plan.update) +} + +function reconcileWallTopologyDelta( + levelId: string, + changedWallIds: ReadonlySet, + beforeNodes: SceneNodes, + currentNodes: SceneNodes, + sceneStore: any, + editorStore: any, +): void { + const { updateNodes } = sceneStore.getState() + const beforeRooms = extractRooms(wallsForLevel(beforeNodes, levelId)) + const currentWalls = wallsForLevel(currentNodes, levelId) + const detection = detectSpacesFromWalls(levelId, currentWalls) + const currentRooms = extractRooms(currentWalls) + const affected = affectedRoomsForWallDelta(beforeRooms, currentRooms, changedWallIds) + const scopedRooms = [...affected.before, ...affected.current] + + const changedWallUpdates = detection.wallUpdates.filter((update) => { + const wall = currentNodes[update.wallId as AnyNodeId] + return ( + wall?.type === 'wall' && + (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) + ) + }) + if (changedWallUpdates.length > 0) { + updateNodes( + changedWallUpdates.map((update) => ({ + id: update.wallId, + data: { + frontSide: update.frontSide, + backSide: update.backSide, + }, + })), + ) + } + + if (scopedRooms.length > 0 && affected.current.length > 0) { + const unaffectedRooms = [ + ...beforeRooms.filter((room) => !affected.before.includes(room)), + ...currentRooms.filter((room) => !affected.current.includes(room)), + ] + const allSlabs = Object.values(currentNodes) + .filter((node): node is SlabNodeType => node?.type === 'slab' && node.parentId === levelId) + .map((slab) => SlabNode.parse(slab)) + const slabs = allSlabs.filter( + (slab) => + surfaceTouchesRooms(slab, scopedRooms) && !surfaceTouchesRooms(slab, unaffectedRooms), + ) + const allCeilings = Object.values(currentNodes) + .filter( + (node): node is CeilingNodeType => node?.type === 'ceiling' && node.parentId === levelId, ) - } + .map((ceiling) => CeilingNode.parse(ceiling)) + const ceilings = allCeilings.filter( + (ceiling) => + surfaceTouchesRooms(ceiling, scopedRooms) && !surfaceTouchesRooms(ceiling, unaffectedRooms), + ) + const slabRooms = roomsEligibleForAutoSurface(affected.before, affected.current, slabs) + const ceilingRooms = roomsEligibleForAutoSurface(affected.before, affected.current, ceilings) - const levelNode = nodes[levelId] - const suppressions = readAutoSurfaceSuppressions(levelNode) - const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) - syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore, { - suppressedRoomSignatures: suppressions.slab, - }) + syncAutoSlabsForLevel( + levelId, + slabRooms.map((room) => room.polygon), + slabs, + sceneStore, + allSlabs, + ) + const levelNode = currentNodes[levelId as AnyNodeId] const storeyHeight = levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : DEFAULT_LEVEL_HEIGHT syncAutoCeilingsForLevel( levelId, - roomPolygons, - ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), + ceilingRooms.map((room) => room.polygon), + ceilings, sceneStore, { - suppressedRoomSignatures: suppressions.ceiling, storeyHeight, - ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, currentNodes, polygon), }, + allCeilings, ) - const zonePlan = planAutoZonesForLevel( - spaces, - zones.map((zone: any) => ZoneNode.parse(zone)), - ) - if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + } + + const zones = Object.values(currentNodes) + .filter((node): node is ZoneNodeType => node?.type === 'zone' && node.parentId === levelId) + .map((zone) => ZoneNode.parse(zone)) + const zonePlan = planAutoZonesForLevel(detection.spaces, zones) + if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + updateSpacesForLevel(levelId, detection.spaces, editorStore) +} + +function ceilingClampInputsChanged(before: SceneNodes, current: SceneNodes) { + const ids = new Set([ + ...(Object.keys(before) as AnyNodeId[]), + ...(Object.keys(current) as AnyNodeId[]), + ]) + for (const id of ids) { + const previous = before[id] + const next = current[id] + const relevant = + previous?.type === 'slab' || + next?.type === 'slab' || + previous?.type === 'level' || + next?.type === 'level' || + previous?.type === 'building' || + next?.type === 'building' + if (relevant && previous !== next) return true + } + return false +} + +function reconcileLoweredCeilingBounds( + beforeNodes: SceneNodes, + currentNodes: SceneNodes, + sceneStore: any, +) { + const liveNodes = sceneStore.getState().nodes as SceneNodes + const updates: Array<{ id: CeilingNodeType['id']; data: Partial }> = [] - for (const space of spaces) { - nextSpaces[space.id] = space + for (const node of Object.values(liveNodes)) { + if (node?.type !== 'ceiling' || node.height == null || !node.parentId) continue + const currentBound = getCeilingClampBound(node.parentId, currentNodes, node.polygon) + const previousBound = getCeilingClampBound(node.parentId, beforeNodes, node.polygon) + if ( + currentBound < previousBound - CEILING_HEIGHT_EPSILON && + node.height > currentBound + CEILING_HEIGHT_EPSILON + ) { + updates.push({ id: node.id, data: { height: currentBound } }) } } - editorStore.getState().setSpaces(nextSpaces) + if (updates.length > 0) sceneStore.getState().updateNodes(updates) } // Refcount of outstanding pause requests, matching the pauseSceneHistory @@ -1850,101 +2026,46 @@ export function isSpaceDetectionPaused(): boolean { } export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { - // Baseline from whatever is already in the store. Detection reacts to wall - // edits made IN-SESSION (create / move / delete); it must not re-litigate a - // scene that merely loaded — rerunning on hydration resurrected auto slabs - // the user had deleted in an earlier session. - const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) - let previousAutoSurfaces = autoSurfaceSnapshots(sceneStore.getState().nodes) let isProcessing = false - const processNodes = (incomingNodes: any) => { + const processCommit = (commit: SceneCommit) => { if (isProcessing) return - if (getSceneHistoryPauseDepth() > 0) return - - if (spaceDetectionPauseDepth > 0) { - const pausedSnapshots = levelStructureSnapshots(incomingNodes) - previousSnapshots.clear() - for (const [levelId, snapshot] of pausedSnapshots.entries()) { - previousSnapshots.set(levelId, snapshot) - } - previousAutoSurfaces = autoSurfaceSnapshots(incomingNodes) - return - } - - let nodes = incomingNodes - const deletedSurfaceSuppressionNeeded = [...previousAutoSurfaces.values()].some( - (surface) => !nodes[surface.id] && nodes[surface.levelId]?.type === 'level', + if (spaceDetectionPauseDepth > 0 || commit.origin === 'load') return + const changedWalls = changedWallIdsByLevel(commit.before.nodes, commit.current.nodes) + const changedZones = changedZoneIdsByLevel(commit.before.nodes, commit.current.nodes) + const shouldReconcileCeilingBounds = ceilingClampInputsChanged( + commit.before.nodes, + commit.current.nodes, ) - if (deletedSurfaceSuppressionNeeded) { - isProcessing = true - pauseSceneHistory(sceneStore) - try { - recordDeletedAutoSurfaceSuppressions(previousAutoSurfaces, nodes, sceneStore) - nodes = sceneStore.getState().nodes - } finally { - resumeSceneHistory(sceneStore) - isProcessing = false - } - } - - const currentSnapshots = levelStructureSnapshots(nodes) - - const levelsToUpdate = new Set() - for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { - // First sight of a level is a hydration baseline, not a wall edit — - // `setScene` delivers a loaded scene as one atomic update, and a level's - // first wall can't close a room anyway. Record it (below) and only - // react to subsequent changes. - const previous = previousSnapshots.get(levelId) - if (previous === undefined) continue - if (previous !== (currentSnapshots.get(levelId) ?? '')) { - levelsToUpdate.add(levelId) - } - } - - if (levelsToUpdate.size === 0) { - previousSnapshots.clear() - for (const [levelId, snapshot] of currentSnapshots.entries()) { - previousSnapshots.set(levelId, snapshot) - } - previousAutoSurfaces = autoSurfaceSnapshots(nodes) - return - } + if (changedWalls.size === 0 && changedZones.size === 0 && !shouldReconcileCeilingBounds) return isProcessing = true pauseSceneHistory(sceneStore) try { - runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) + for (const [levelId, wallIds] of changedWalls) { + reconcileWallTopologyDelta( + levelId, + wallIds, + commit.before.nodes, + commit.current.nodes, + sceneStore, + editorStore, + ) + } + for (const [levelId, zoneIds] of changedZones) { + if (changedWalls.has(levelId)) continue + reconcileChangedZones(levelId, zoneIds, commit.current.nodes, sceneStore) + } + if (shouldReconcileCeilingBounds || changedWalls.size > 0) { + reconcileLoweredCeilingBounds(commit.before.nodes, sceneStore.getState().nodes, sceneStore) + } } finally { resumeSceneHistory(sceneStore) - previousSnapshots.clear() - const postRunSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) - for (const [levelId, snapshot] of postRunSnapshots.entries()) { - previousSnapshots.set(levelId, snapshot) - } - previousAutoSurfaces = autoSurfaceSnapshots(sceneStore.getState().nodes) isProcessing = false } } - const unsubscribeStore = sceneStore.subscribe((state: any) => { - // A single semantic edit can contain several store writes (for example: - // create both wall halves, delete the original, then create the divider). - // Those intermediate graphs are intentionally invalid. Reconcile once from - // the coalesced scene commit below instead of demoting surfaces against a - // transient half-written topology. - if (isSceneCommitTransactionActive()) return - processNodes(state.nodes) - }) - const unsubscribeCommits = subscribeSceneCommits((commit) => { - processNodes(commit.current.nodes) - }) - - return () => { - unsubscribeStore() - unsubscribeCommits() - } + return subscribeSceneCommits(processCommit) } export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index b749d4097..1e31cf734 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -327,7 +327,21 @@ describe('createWallOnCurrentLevel', () => { makeWall([4, 3], [0, 3], 'wall_top'), makeWall([0, 3], [0, 0], 'wall_left'), ] - seedLevel(walls) + const initialRoom = detectSpacesForLevel(String(LEVEL_ID), walls).roomPolygons[0] + expect(initialRoom).toBeDefined() + const slab = SlabNode.parse({ + id: 'slab_main', + parentId: LEVEL_ID, + polygon: initialRoom!.map(({ x, y }) => [x, y]), + autoFromWalls: true, + }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: LEVEL_ID, + polygon: slab.polygon, + autoFromWalls: true, + }) + seedLevel(walls, [slab, ceiling]) const editorState = { spaces: {}, setSpaces(spaces: Record) { @@ -404,6 +418,61 @@ describe('createWallOnCurrentLevel', () => { } }) + test('a straight wall drawn through a curved wall splits both at the crossing', () => { + const curvedWall = { + ...makeWall([0, 0], [4, 0], 'wall_curve'), + curveOffset: 1, + } + const crossing = getWallCurveFrameAt(curvedWall, 0.5).point + seedLevel([curvedWall]) + + const created = createWallOnCurrentLevel([2, -2], [2, 2]) + const walls = levelWalls() + const curvedSegments = walls.filter(isCurvedWall) + const straightSegments = walls.filter((wall) => !isCurvedWall(wall)) + + expect(created).not.toBeNull() + expect(useScene.getState().nodes[curvedWall.id]).toBeUndefined() + expect(curvedSegments).toHaveLength(2) + expect(straightSegments).toHaveLength(2) + expect( + walls.filter( + (wall) => + (wall.start[0] === crossing.x && wall.start[1] === crossing.y) || + (wall.end[0] === crossing.x && wall.end[1] === crossing.y), + ), + ).toHaveLength(4) + }) + + test('two crossings on the same curved wall split it at both intersections', () => { + const curvedWall = { + ...makeWall([0, 0], [4, 0], 'wall_curve'), + curveOffset: 2, + } + seedLevel([curvedWall]) + + const created = createWallOnCurrentLevel([-1, -1], [5, -1]) + const walls = levelWalls() + + expect(created).not.toBeNull() + expect(useScene.getState().nodes[curvedWall.id]).toBeUndefined() + expect(walls.filter(isCurvedWall)).toHaveLength(3) + expect(walls.filter((wall) => !isCurvedWall(wall))).toHaveLength(3) + const crossings: WallPlanPoint[] = [ + [2 - Math.sqrt(3), -1], + [2 + Math.sqrt(3), -1], + ] + expect( + walls.filter((wall) => + crossings.some( + (point) => + Math.hypot(wall.start[0] - point[0], wall.start[1] - point[1]) < 1e-6 || + Math.hypot(wall.end[0] - point[0], wall.end[1] - point[1]) < 1e-6, + ), + ), + ).toHaveLength(6) + }) + test('crossing divider walls split into four joined segments and four room surfaces', () => { const walls = [ makeWall([0, 0], [4, 0], 'wall_bottom'), diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index ecacc8e0f..c237a5cf5 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -577,8 +577,11 @@ export function createWallOnCurrentLevel( for (const crossing of crossings) { if (crossing.wallT > interiorEpsilon && crossing.wallT < 1 - interiorEpsilon) { + const hostIntersection = workingWalls.some((wall) => wall.id === crossing.wallId) + ? { wallId: crossing.wallId, point: crossing.point, wallT: crossing.wallT } + : findWallIntersection(crossing.point, workingWalls, 1e-5) const splitHost = splitWallIfNeeded( - { wallId: crossing.wallId, point: crossing.point, wallT: crossing.wallT }, + hostIntersection, workingWalls, nodes, createNodes, diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 280da806a..bf3052255 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -233,6 +233,43 @@ function segmentIntersection( } } +function curvedWallSegmentIntersections(start: WallPlanPoint, end: WallPlanPoint, wall: WallNode) { + const arc = getWallArcData(wall) + if (!arc) return [] + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const offsetX = start[0] - arc.center.x + const offsetZ = start[1] - arc.center.y + const a = dx * dx + dz * dz + if (a < 1e-12) return [] + const b = 2 * (offsetX * dx + offsetZ * dz) + const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius + const discriminant = b * b - 4 * a * c + if (discriminant < -1e-9) return [] + + const root = Math.sqrt(Math.max(0, discriminant)) + const draftParameters = [(-b - root) / (2 * a), (-b + root) / (2 * a)] + const intersections: Array<{ point: WallPlanPoint; draftT: number; wallT: number }> = [] + for (const draftT of draftParameters) { + if (draftT < -1e-9 || draftT > 1 + 1e-9) continue + const point: WallPlanPoint = [start[0] + draftT * dx, start[1] + draftT * dz] + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + let directedAngle = (angle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const wallT = directedAngle / Math.abs(arc.delta) + if (wallT < -1e-9 || wallT > 1 + 1e-9) continue + if (intersections.some((candidate) => distanceSquared(candidate.point, point) < 1e-12)) { + continue + } + intersections.push({ + point, + draftT: Math.max(0, Math.min(1, draftT)), + wallT: Math.max(0, Math.min(1, wallT)), + }) + } + return intersections +} + export function findWallSegmentIntersections( start: WallPlanPoint, end: WallPlanPoint, @@ -246,7 +283,12 @@ export function findWallSegmentIntersections( }> = [] for (const wall of walls) { - if (isCurvedWall(wall)) continue + if (isCurvedWall(wall)) { + for (const intersection of curvedWallSegmentIntersections(start, end, wall)) { + intersections.push({ wallId: wall.id, ...intersection }) + } + continue + } const intersection = segmentIntersection(start, end, wall.start, wall.end) if (!intersection) continue intersections.push({ diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts new file mode 100644 index 000000000..7bcbdf60f --- /dev/null +++ b/packages/editor/src/lib/scene.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + BuildingNode, + initSpaceDetectionSync, + LevelNode, + SiteNode, + useScene, + WallNode, +} from '@pascal-app/core' +import useEditor from '../store/use-editor' +import { applySceneGraphToEditor } from './scene' + +if (typeof globalThis.requestAnimationFrame === 'undefined') { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0)) as unknown as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = ((id: number) => + clearTimeout(id)) as typeof cancelAnimationFrame +} + +function loadedRoomWithoutSurfaces() { + const walls = [ + WallNode.parse({ id: 'wall_1', start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_2', start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_3', start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_4', start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const level = LevelNode.parse({ + id: 'level_0', + parentId: 'building_0', + children: walls.map((wall) => wall.id), + }) + const building = BuildingNode.parse({ + id: 'building_0', + parentId: 'site_0', + children: [level.id], + }) + const site = SiteNode.parse({ id: 'site_0', children: [building.id] }) + const nodes = [site, building, level, ...walls] + + return { + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: [site.id], + } +} + +describe('applySceneGraphToEditor', () => { + afterEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + installedPlugins: [], + hasExplicitPluginInstallState: false, + }) + useScene.temporal.getState().clear() + }) + + test('loading a closed room does not recreate a slab or ceiling deleted before reload', () => { + const unsubscribe = initSpaceDetectionSync(useScene, useEditor) + + try { + applySceneGraphToEditor(loadedRoomWithoutSurfaces()) + + const surfaces = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'slab' || node.type === 'ceiling', + ) + expect(surfaces).toHaveLength(0) + } finally { + unsubscribe() + } + }) +}) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index af9943d07..9aa0a1444 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -3,7 +3,9 @@ import { clearSceneHistory, nodeRegistry, + pauseSceneHistory, resolveLevelId, + resumeSceneHistory, sceneRegistry, useScene, } from '@pascal-app/core' @@ -384,23 +386,25 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() - if (hasUsableSceneGraph(sceneGraph)) { - const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any, { - collections: collections as any, - materials: materials as any, - installedPlugins: installedPlugins ?? defaultInstalledPlugins, - hasExplicitPluginInstallState: installedPlugins !== undefined, - }) - } else { - useScene.getState().clearScene() - useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false }) + pauseSceneHistory(useScene) + try { + if (hasUsableSceneGraph(sceneGraph)) { + const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph + useScene.getState().setScene(nodes as any, rootNodeIds as any, { + collections: collections as any, + materials: materials as any, + installedPlugins: installedPlugins ?? defaultInstalledPlugins, + hasExplicitPluginInstallState: installedPlugins !== undefined, + }) + } else { + useScene.getState().clearScene() + useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false }) + } + } finally { + resumeSceneHistory(useScene) } - // The loaded scene is the undo floor. Loading records history entries of - // its own (`unloadScene` + `setScene`/`clearScene` are tracked writes), so - // without this reset a few Ctrl+Z presses could step past the load into the - // pre-load — often empty — state and wipe the whole project. + // The loaded scene is the undo floor; discard history from the previous scene. clearSceneHistory() syncEditorSelectionFromCurrentScene() From e5f83c1721ae00462f1e0208df57a351c5335249 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 01:07:30 +0530 Subject: [PATCH 09/14] perf(core): reconcile rooms with local topology index --- packages/core/src/index.ts | 1 + .../core/src/lib/space-detection-undo.test.ts | 99 +++ packages/core/src/lib/space-detection.test.ts | 221 ++++++- packages/core/src/lib/space-detection.ts | 598 ++++++++++++++++-- .../store/history-control-node-ids.test.ts | 81 +++ packages/core/src/store/history-control.ts | 46 +- packages/core/src/store/use-scene.ts | 42 +- .../src/components/editor/floorplan-panel.tsx | 7 +- .../tools/wall/wall-drafting.test.ts | 17 + packages/editor/src/lib/scene.test.ts | 1 + packages/editor/src/lib/scene.ts | 13 + packages/nodes/src/wall/tool.tsx | 2 + 12 files changed, 1052 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/lib/space-detection-undo.test.ts create mode 100644 packages/core/src/store/history-control-node-ids.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3b633ebae..0c9ea7a66 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -197,6 +197,7 @@ export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getSceneHistoryPauseDepth, + notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, diff --git a/packages/core/src/lib/space-detection-undo.test.ts b/packages/core/src/lib/space-detection-undo.test.ts new file mode 100644 index 000000000..866aff0fe --- /dev/null +++ b/packages/core/src/lib/space-detection-undo.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import useScene, { clearSceneHistory } from '../store/use-scene' +import { initSpaceDetectionSync, type Space } from './space-detection' + +type RafFn = (cb: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => { + cb(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +function editorStoreStub() { + const state = { + spaces: {} as Record, + setSpaces(spaces: Record) { + state.spaces = spaces + }, + } + return { getState: () => state } +} + +function resetRoom() { + const walls = [ + WallNode.parse({ id: 'wall_1', start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_2', start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_3', start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ id: 'wall_4', start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const level = LevelNode.parse({ + id: 'level_0', + parentId: 'building_0', + children: walls.map((wall) => wall.id), + }) + const building = BuildingNode.parse({ + id: 'building_0', + parentId: null, + children: [level.id], + }) + const nodes = Object.fromEntries( + [building, level, ...walls].map((node) => [node.id, node]), + ) as Record + useScene.setState({ + nodes, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() +} + +describe('space topology index undo and redo', () => { + afterEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() + }) + + test('rebuilds its disposable room lookup after undo and redo', async () => { + resetRoom() + const editorStore = editorStoreStub() + const unsubscribe = initSpaceDetectionSync(useScene, editorStore) + + try { + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(1) + + useScene.getState().createNode( + WallNode.parse({ + id: 'wall_divider', + start: [2, 0], + end: [2, 3], + parentId: 'level_0', + }), + 'level_0', + ) + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(2) + + useScene.temporal.getState().undo() + await Promise.resolve() + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(1) + + useScene.temporal.getState().redo() + await Promise.resolve() + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(2) + } finally { + unsubscribe() + } + }) +}) diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 1ab01d66d..24fe7b735 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -530,7 +530,11 @@ function createSceneStoreStub(initialNodes: Record) { return () => listeners.delete(listener) }, temporal: { getState: () => ({ pause() {}, resume() {} }) }, - setNodes(next: Record, origin: SceneCommitOrigin = 'local') { + setNodes( + next: Record, + origin: SceneCommitOrigin = 'local', + changedNodeIds?: ReadonlySet, + ) { const before = state.nodes state.nodes = next notify() @@ -545,6 +549,7 @@ function createSceneStoreStub(initialNodes: Record) { origin, before: snapshot(before), current: snapshot(next), + changedNodeIds, }) }, } @@ -716,6 +721,132 @@ function splitRoomWithGeneratedSurfaces() { return nodes } +function fourRoomGridWithSplitA() { + const walls = [ + WallNode.parse({ + id: 'wall_bottom_left', + start: [0, 0], + end: [4, 0], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_bottom_right', + start: [4, 0], + end: [8, 0], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_right_bottom', + start: [8, 0], + end: [8, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_right_top', + start: [8, 3], + end: [8, 6], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_top_right', + start: [8, 6], + end: [4, 6], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_top_a2', + start: [4, 6], + end: [2, 6], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_top_a1', + start: [2, 6], + end: [0, 6], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_left_top', + start: [0, 6], + end: [0, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_left_bottom', + start: [0, 3], + end: [0, 0], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_horizontal_a1', + start: [0, 3], + end: [2, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_horizontal_a2', + start: [2, 3], + end: [4, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_horizontal_right', + start: [4, 3], + end: [8, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_vertical_bottom', + start: [4, 0], + end: [4, 3], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_vertical_top', + start: [4, 3], + end: [4, 6], + parentId: 'level_0', + }), + WallNode.parse({ + id: 'wall_a_split', + start: [2, 3], + end: [2, 6], + parentId: 'level_0', + }), + ] + const distantSlab = SlabNode.parse({ + id: 'slab_d', + parentId: 'level_0', + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + }) + const distantCeiling = CeilingNode.parse({ + id: 'ceiling_d', + parentId: 'level_0', + polygon: distantSlab.polygon, + autoFromWalls: true, + }) + const level = LevelNode.parse({ + id: 'level_0', + parentId: 'building_a', + children: [...walls.map((wall) => wall.id), distantSlab.id, distantCeiling.id], + }) + return Object.fromEntries( + [ + BuildingNode.parse({ id: 'building_a', children: [level.id] }), + level, + ...walls, + distantSlab, + distantCeiling, + ].map((node) => [node.id, node]), + ) as Record +} + describe('generated surface deletion through the detection sync', () => { test('a deleted generated slab stays deleted', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) @@ -896,6 +1027,94 @@ describe('generated surface deletion through the detection sync', () => { }) describe('topology-delta room surface reconciliation', () => { + test('indexes only room A while its divider is deleted, recreated, and moved', () => { + const sceneStore = createSceneStoreStub(fourRoomGridWithSplitA()) + const editorStore = createEditorStoreStub() + const events: Array<{ + strategy: string + examinedWallIds: string[] + affectedBeforeRoomCount: number + affectedCurrentRoomCount: number + }> = [] + const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore, { + onTopologyReconcile: (event) => events.push(event), + }) + const distantSlabBefore = sceneStore.getState().nodes.slab_d + const distantCeilingBefore = sceneStore.getState().nodes.ceiling_d + + try { + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + + const current = sceneStore.getState().nodes + const splitWall = current.wall_a_split as WallNode + const level = current.level_0 as LevelNode + const { wall_a_split: _deleted, ...withoutSplit } = current + sceneStore.setNodes( + { + ...withoutSplit, + level_0: { + ...level, + children: level.children.filter((id) => id !== splitWall.id), + }, + }, + 'local', + new Set([splitWall.id as AnyNodeId]), + ) + + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(4) + expect(events.at(-1)?.strategy).toBe('indexed') + expect(events.at(-1)?.examinedWallIds).not.toEqual( + expect.arrayContaining([ + 'wall_bottom_right', + 'wall_right_bottom', + 'wall_horizontal_right', + 'wall_vertical_bottom', + ]), + ) + expect(sceneStore.getState().nodes.slab_d).toEqual(distantSlabBefore) + expect(sceneStore.getState().nodes.ceiling_d).toEqual(distantCeilingBefore) + + const afterDelete = sceneStore.getState().nodes + const levelAfterDelete = afterDelete.level_0 as LevelNode + sceneStore.setNodes( + { + ...afterDelete, + [splitWall.id]: splitWall, + level_0: { + ...levelAfterDelete, + children: [...levelAfterDelete.children, splitWall.id], + }, + }, + 'local', + new Set([splitWall.id as AnyNodeId]), + ) + + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + expect(events.at(-1)?.examinedWallIds).not.toContain('wall_bottom_right') + + const afterCreate = sceneStore.getState().nodes + sceneStore.setNodes( + { + ...afterCreate, + [splitWall.id]: { + ...(afterCreate[splitWall.id] as WallNode), + start: [2.5, 3], + end: [2.5, 6], + }, + }, + 'local', + new Set([splitWall.id as AnyNodeId]), + ) + + expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + expect(events.at(-1)?.examinedWallIds).not.toContain('wall_bottom_right') + expect(sceneStore.getState().nodes.slab_d).toEqual(distantSlabBefore) + expect(sceneStore.getState().nodes.ceiling_d).toEqual(distantCeilingBefore) + } finally { + unsubscribe() + } + }) + test('closing a genuinely new room creates its initial slab and ceiling', () => { const initial = roomWithGeneratedSurfaces() delete initial.wall_4 diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 789dbbfcf..5c4df8aba 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -13,6 +13,7 @@ import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' import { CEILING_CLAMP_MARGIN, getCeilingClampBound, + getLevelBelow, getStoredLevelHeight, } from '../services/storey' import { @@ -1655,14 +1656,29 @@ function wallsForLevel(nodes: SceneNodes, levelId: string) { ) } -function changedWallIdsByLevel(before: SceneNodes, current: SceneNodes) { +function levelChildren(nodes: SceneNodes, levelId: string) { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') return [] + return level.children.flatMap((id) => { + const node = nodes[id as AnyNodeId] + return node ? [node] : [] + }) +} + +function changedWallIdsByLevel( + before: SceneNodes, + current: SceneNodes, + candidateIds?: ReadonlySet, +) { const changes = new Map>() - const wallIds = new Set() - for (const node of Object.values(before)) { - if (node?.type === 'wall') wallIds.add(node.id) - } - for (const node of Object.values(current)) { - if (node?.type === 'wall') wallIds.add(node.id) + const wallIds = new Set(candidateIds) + if (!candidateIds) { + for (const node of Object.values(before)) { + if (node?.type === 'wall') wallIds.add(node.id) + } + for (const node of Object.values(current)) { + if (node?.type === 'wall') wallIds.add(node.id) + } } const markChanged = (levelId: string | null | undefined, wallId: string) => { @@ -1690,6 +1706,366 @@ function changedWallIdsByLevel(before: SceneNodes, current: SceneNodes) { return changes } +const TOPOLOGY_INDEX_CELL_SIZE = 2 +const TOPOLOGY_INDEX_QUERY_MARGIN = WALL_JUNCTION_TOLERANCE + 0.02 + +type IndexedLevelTopology = { + walls: Map + rooms: ExtractedRoom[] + wallIdsByCell: Map> + cellKeysByWallId: Map +} + +export type SpaceTopologyReconcileEvent = { + levelId: string + strategy: 'indexed' | 'fallback' + examinedWallIds: string[] + affectedBeforeRoomCount: number + affectedCurrentRoomCount: number +} + +export type SpaceDetectionSyncOptions = { + onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void +} + +type IndexedTopologyDelta = { + strategy: SpaceTopologyReconcileEvent['strategy'] + beforeRooms: ExtractedRoom[] + currentRooms: ExtractedRoom[] + allBeforeRooms: ExtractedRoom[] + allCurrentRooms: ExtractedRoom[] + currentWalls: WallNode[] + examinedWallIds: string[] +} + +function expandedBbox(box: ReturnType, margin: number) { + return { + minX: box.minX - margin, + minY: box.minY - margin, + maxX: box.maxX + margin, + maxY: box.maxY + margin, + } +} + +function wallBbox(wall: WallNode) { + return bboxOf(sampleWallPointsForRoomDetection(wall)) +} + +function topologyCellKey(x: number, y: number) { + return `${x},${y}` +} + +function cellKeysForBbox(box: ReturnType) { + const minCellX = Math.floor(box.minX / TOPOLOGY_INDEX_CELL_SIZE) + const maxCellX = Math.floor(box.maxX / TOPOLOGY_INDEX_CELL_SIZE) + const minCellY = Math.floor(box.minY / TOPOLOGY_INDEX_CELL_SIZE) + const maxCellY = Math.floor(box.maxY / TOPOLOGY_INDEX_CELL_SIZE) + const keys: string[] = [] + + for (let x = minCellX; x <= maxCellX; x += 1) { + for (let y = minCellY; y <= maxCellY; y += 1) { + keys.push(topologyCellKey(x, y)) + } + } + return keys +} + +function createIndexedLevelTopology(walls: WallNode[]): IndexedLevelTopology { + const level: IndexedLevelTopology = { + walls: new Map(), + rooms: extractRooms(walls), + wallIdsByCell: new Map(), + cellKeysByWallId: new Map(), + } + + for (const wall of walls) { + level.walls.set(wall.id, wall) + const keys = cellKeysForBbox(expandedBbox(wallBbox(wall), TOPOLOGY_INDEX_QUERY_MARGIN)) + level.cellKeysByWallId.set(wall.id, keys) + for (const key of keys) { + const ids = level.wallIdsByCell.get(key) ?? new Set() + ids.add(wall.id) + level.wallIdsByCell.set(key, ids) + } + } + return level +} + +function removeIndexedWall(level: IndexedLevelTopology, wallId: string) { + for (const key of level.cellKeysByWallId.get(wallId) ?? []) { + const ids = level.wallIdsByCell.get(key) + ids?.delete(wallId) + if (ids?.size === 0) level.wallIdsByCell.delete(key) + } + level.cellKeysByWallId.delete(wallId) + level.walls.delete(wallId) +} + +function setIndexedWall(level: IndexedLevelTopology, wall: WallNode) { + removeIndexedWall(level, wall.id) + level.walls.set(wall.id, wall) + const keys = cellKeysForBbox(expandedBbox(wallBbox(wall), TOPOLOGY_INDEX_QUERY_MARGIN)) + level.cellKeysByWallId.set(wall.id, keys) + for (const key of keys) { + const ids = level.wallIdsByCell.get(key) ?? new Set() + ids.add(wall.id) + level.wallIdsByCell.set(key, ids) + } +} + +function queryIndexedWalls( + level: IndexedLevelTopology, + box: ReturnType, +): Set { + const ids = new Set() + for (const key of cellKeysForBbox(expandedBbox(box, TOPOLOGY_INDEX_QUERY_MARGIN))) { + for (const id of level.wallIdsByCell.get(key) ?? []) ids.add(id) + } + return ids +} + +function roomContainsWallInterior(room: ExtractedRoom, wall: WallNode) { + const points = sampleWallPointsForRoomDetection(wall) + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]! + const end = points[index + 1]! + for (const t of [0.25, 0.5, 0.75]) { + if ( + pointInPolygon( + { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }, + room.polygon, + ) + ) { + return true + } + } + } + return false +} + +function roomIdsByWall(rooms: ExtractedRoom[]) { + const result = new Map>() + rooms.forEach((room, roomIndex) => { + for (const wallId of roomWallIds(room)) { + const indices = result.get(wallId) ?? new Set() + indices.add(roomIndex) + result.set(wallId, indices) + } + }) + return result +} + +function sameIndexedWall(left: WallNode | null | undefined, right: WallNode | null | undefined) { + if (!(left && right)) return left === right + return ( + left.parentId === right.parentId && wallGeometrySignature(left) === wallGeometrySignature(right) + ) +} + +class RoomTopologyIndex { + private readonly levels = new Map() + + rebuild(nodes: SceneNodes) { + this.levels.clear() + const wallsByLevel = new Map() + for (const node of Object.values(nodes)) { + if (node?.type !== 'wall' || !node.parentId) continue + const walls = wallsByLevel.get(node.parentId) ?? [] + walls.push(node) + wallsByLevel.set(node.parentId, walls) + } + for (const [levelId, walls] of wallsByLevel) { + this.levels.set(levelId, createIndexedLevelTopology(walls)) + } + } + + spaces() { + return [...this.levels.entries()].flatMap(([levelId, level]) => + level.rooms.map((room) => buildSpace(levelId, room)), + ) + } + + spacesForLevel(levelId: string) { + return (this.levels.get(levelId)?.rooms ?? []).map((room) => buildSpace(levelId, room)) + } + + private rebuildLevel(levelId: string, nodes: SceneNodes) { + const walls = wallsForLevel(nodes, levelId) + const level = createIndexedLevelTopology(walls) + this.levels.set(levelId, level) + return level + } + + private ensureBeforeLevel( + levelId: string, + changedWallIds: ReadonlySet, + beforeNodes: SceneNodes, + ) { + const cached = this.levels.get(levelId) + if (!cached) return { level: this.rebuildLevel(levelId, beforeNodes), fallback: true } + + for (const wallId of changedWallIds) { + const beforeNode = beforeNodes[wallId as AnyNodeId] + const beforeWall = + beforeNode?.type === 'wall' && beforeNode.parentId === levelId ? beforeNode : null + if (!sameIndexedWall(cached.walls.get(wallId), beforeWall)) { + return { level: this.rebuildLevel(levelId, beforeNodes), fallback: true } + } + } + return { level: cached, fallback: false } + } + + applyWallDelta( + levelId: string, + changedWallIds: ReadonlySet, + beforeNodes: SceneNodes, + currentNodes: SceneNodes, + ): IndexedTopologyDelta { + const ensured = this.ensureBeforeLevel(levelId, changedWallIds, beforeNodes) + const level = ensured.level + const allBeforeRooms = [...level.rooms] + const previousChangedWalls = [...changedWallIds].flatMap((wallId) => { + const wall = level.walls.get(wallId) + return wall ? [wall] : [] + }) + + for (const wallId of changedWallIds) { + const node = currentNodes[wallId as AnyNodeId] + if (node?.type === 'wall' && node.parentId === levelId) setIndexedWall(level, node) + else removeIndexedWall(level, wallId) + } + + const currentChangedWalls = [...changedWallIds].flatMap((wallId) => { + const wall = level.walls.get(wallId) + return wall ? [wall] : [] + }) + const changedWalls = [...previousChangedWalls, ...currentChangedWalls] + const affectedBeforeIndices = new Set() + allBeforeRooms.forEach((room, roomIndex) => { + if ( + room.boundaryFaces.some((boundary) => changedWallIds.has(boundary.wallId)) || + changedWalls.some((wall) => roomContainsWallInterior(room, wall)) + ) { + affectedBeforeIndices.add(roomIndex) + } + }) + + const candidateWallIds = new Set(changedWallIds) + const relevantBeforeIndices = new Set(affectedBeforeIndices) + const indexedRoomsByWall = roomIdsByWall(allBeforeRooms) + + if (affectedBeforeIndices.size > 0) { + for (const roomIndex of affectedBeforeIndices) { + const room = allBeforeRooms[roomIndex] + if (!room) continue + for (const wallId of roomWallIds(room)) candidateWallIds.add(wallId) + for (const wallId of queryIndexedWalls(level, bboxOf(room.polygon))) { + const wall = level.walls.get(wallId) + if (wall && roomContainsWallInterior(room, wall)) candidateWallIds.add(wallId) + } + } + } else { + const queue = [...currentChangedWalls] + const visited = new Set(changedWallIds) + while (queue.length > 0) { + const wall = queue.pop() + if (!wall) continue + for (const neighborId of queryIndexedWalls(level, wallBbox(wall))) { + if (visited.has(neighborId)) continue + const neighbor = level.walls.get(neighborId) + if (!(neighbor && wallTouchesOthers(wall, [neighbor]))) continue + visited.add(neighborId) + candidateWallIds.add(neighborId) + const roomIndices = indexedRoomsByWall.get(neighborId) + if (roomIndices && roomIndices.size > 0) { + for (const roomIndex of roomIndices) relevantBeforeIndices.add(roomIndex) + } else { + queue.push(neighbor) + } + } + } + for (const roomIndex of relevantBeforeIndices) { + const room = allBeforeRooms[roomIndex] + if (!room) continue + for (const wallId of roomWallIds(room)) candidateWallIds.add(wallId) + } + } + + const effectiveChangedWallIds = new Set(changedWallIds) + for (const wallId of [...candidateWallIds]) { + const beforeNode = beforeNodes[wallId as AnyNodeId] + const currentNode = currentNodes[wallId as AnyNodeId] + const beforeWall = + beforeNode?.type === 'wall' && beforeNode.parentId === levelId ? beforeNode : null + const currentWall = + currentNode?.type === 'wall' && currentNode.parentId === levelId ? currentNode : null + if (!sameIndexedWall(beforeWall, currentWall)) effectiveChangedWallIds.add(wallId) + if (currentWall) setIndexedWall(level, currentWall) + else removeIndexedWall(level, wallId) + } + + const relevantBeforeRooms = [...relevantBeforeIndices] + .map((roomIndex) => allBeforeRooms[roomIndex]) + .filter((room): room is ExtractedRoom => Boolean(room)) + const beforeCandidateWalls = [...candidateWallIds].flatMap((wallId) => { + const node = beforeNodes[wallId as AnyNodeId] + return node?.type === 'wall' && node.parentId === levelId ? [node] : [] + }) + const currentCandidateWalls = [...candidateWallIds].flatMap((wallId) => { + const node = currentNodes[wallId as AnyNodeId] + return node?.type === 'wall' && node.parentId === levelId ? [node] : [] + }) + + if (relevantBeforeRooms.length === 0 && beforeCandidateWalls.length >= 3) { + const localBeforeRooms = extractRooms(beforeCandidateWalls) + localBeforeRooms.forEach((room) => { + if (room.boundaryFaces.some((boundary) => effectiveChangedWallIds.has(boundary.wallId))) { + relevantBeforeRooms.push(room) + } + }) + } + + const localCurrentRooms = extractRooms(currentCandidateWalls) + const affected = affectedRoomsForWallDelta( + relevantBeforeRooms, + localCurrentRooms, + effectiveChangedWallIds, + ) + + const affectedBeforeSignatures = new Set( + affected.before.map((room) => polygonSignature(room.polygon)), + ) + const affectedCurrentSignatures = new Set( + affected.current.map((room) => polygonSignature(room.polygon)), + ) + const allCurrentRooms = allBeforeRooms.filter( + (room) => !affectedBeforeSignatures.has(polygonSignature(room.polygon)), + ) + for (const room of affected.current) { + const signature = polygonSignature(room.polygon) + const existingIndex = allCurrentRooms.findIndex( + (candidate) => polygonSignature(candidate.polygon) === signature, + ) + if (existingIndex >= 0) allCurrentRooms.splice(existingIndex, 1) + allCurrentRooms.push(room) + } + level.rooms = allCurrentRooms + + return { + strategy: ensured.fallback ? 'fallback' : 'indexed', + beforeRooms: affected.before, + currentRooms: affected.current, + allBeforeRooms, + allCurrentRooms, + currentWalls: currentCandidateWalls, + examinedWallIds: [...candidateWallIds].sort(), + } + } +} + function zoneGeometrySignature(zone: ZoneNodeType) { return [ zone.autoFromWalls ? 'auto' : 'manual', @@ -1698,14 +2074,20 @@ function zoneGeometrySignature(zone: ZoneNodeType) { ].join('|') } -function changedZoneIdsByLevel(before: SceneNodes, current: SceneNodes) { +function changedZoneIdsByLevel( + before: SceneNodes, + current: SceneNodes, + candidateIds?: ReadonlySet, +) { const changes = new Map>() - const zoneIds = new Set() - for (const node of Object.values(before)) { - if (node?.type === 'zone') zoneIds.add(node.id) - } - for (const node of Object.values(current)) { - if (node?.type === 'zone') zoneIds.add(node.id) + const zoneIds = new Set(candidateIds) + if (!candidateIds) { + for (const node of Object.values(before)) { + if (node?.type === 'zone') zoneIds.add(node.id) + } + for (const node of Object.values(current)) { + if (node?.type === 'zone') zoneIds.add(node.id) + } } const markChanged = (levelId: string | null | undefined, zoneId: string) => { @@ -1852,13 +2234,17 @@ function updateSpacesForLevel(levelId: string, spaces: Space[], editorStore: any editorStore.getState().setSpaces(nextSpaces) } +function replaceIndexedSpaces(spaces: Space[], editorStore: any) { + editorStore.getState().setSpaces(Object.fromEntries(spaces.map((space) => [space.id, space]))) +} + function reconcileChangedZones( levelId: string, changedZoneIds: ReadonlySet, currentNodes: SceneNodes, sceneStore: any, + spaces: Space[], ) { - const spaces = detectSpacesFromWalls(levelId, wallsForLevel(currentNodes, levelId)).spaces const zones = [...changedZoneIds].flatMap((id) => { const node = currentNodes[id as AnyNodeId] return node?.type === 'zone' && node.parentId === levelId ? [ZoneNode.parse(node)] : [] @@ -1869,27 +2255,26 @@ function reconcileChangedZones( function reconcileWallTopologyDelta( levelId: string, - changedWallIds: ReadonlySet, - beforeNodes: SceneNodes, + topologyDelta: IndexedTopologyDelta, currentNodes: SceneNodes, sceneStore: any, editorStore: any, ): void { const { updateNodes } = sceneStore.getState() - const beforeRooms = extractRooms(wallsForLevel(beforeNodes, levelId)) - const currentWalls = wallsForLevel(currentNodes, levelId) - const detection = detectSpacesFromWalls(levelId, currentWalls) - const currentRooms = extractRooms(currentWalls) - const affected = affectedRoomsForWallDelta(beforeRooms, currentRooms, changedWallIds) - const scopedRooms = [...affected.before, ...affected.current] - - const changedWallUpdates = detection.wallUpdates.filter((update) => { - const wall = currentNodes[update.wallId as AnyNodeId] - return ( - wall?.type === 'wall' && - (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) - ) - }) + const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] + const allCurrentRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) + const changedWallUpdates = topologyDelta.currentWalls + .map((wall) => ({ + wallId: wall.id, + ...resolveWallSurfaceSides(wall, allCurrentRoomPolygons), + })) + .filter((update) => { + const wall = currentNodes[update.wallId as AnyNodeId] + return ( + wall?.type === 'wall' && + (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) + ) + }) if (changedWallUpdates.length > 0) { updateNodes( changedWallUpdates.map((update) => ({ @@ -1902,29 +2287,35 @@ function reconcileWallTopologyDelta( ) } - if (scopedRooms.length > 0 && affected.current.length > 0) { + if (scopedRooms.length > 0 && topologyDelta.currentRooms.length > 0) { const unaffectedRooms = [ - ...beforeRooms.filter((room) => !affected.before.includes(room)), - ...currentRooms.filter((room) => !affected.current.includes(room)), + ...topologyDelta.allBeforeRooms.filter((room) => !topologyDelta.beforeRooms.includes(room)), + ...topologyDelta.allCurrentRooms.filter((room) => !topologyDelta.currentRooms.includes(room)), ] - const allSlabs = Object.values(currentNodes) - .filter((node): node is SlabNodeType => node?.type === 'slab' && node.parentId === levelId) + const allSlabs = levelChildren(currentNodes, levelId) + .filter((node): node is SlabNodeType => node.type === 'slab') .map((slab) => SlabNode.parse(slab)) const slabs = allSlabs.filter( (slab) => surfaceTouchesRooms(slab, scopedRooms) && !surfaceTouchesRooms(slab, unaffectedRooms), ) - const allCeilings = Object.values(currentNodes) - .filter( - (node): node is CeilingNodeType => node?.type === 'ceiling' && node.parentId === levelId, - ) + const allCeilings = levelChildren(currentNodes, levelId) + .filter((node): node is CeilingNodeType => node.type === 'ceiling') .map((ceiling) => CeilingNode.parse(ceiling)) const ceilings = allCeilings.filter( (ceiling) => surfaceTouchesRooms(ceiling, scopedRooms) && !surfaceTouchesRooms(ceiling, unaffectedRooms), ) - const slabRooms = roomsEligibleForAutoSurface(affected.before, affected.current, slabs) - const ceilingRooms = roomsEligibleForAutoSurface(affected.before, affected.current, ceilings) + const slabRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + slabs, + ) + const ceilingRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + ceilings, + ) syncAutoSlabsForLevel( levelId, @@ -1951,19 +2342,26 @@ function reconcileWallTopologyDelta( ) } - const zones = Object.values(currentNodes) - .filter((node): node is ZoneNodeType => node?.type === 'zone' && node.parentId === levelId) + const zones = levelChildren(currentNodes, levelId) + .filter((node): node is ZoneNodeType => node.type === 'zone') .map((zone) => ZoneNode.parse(zone)) - const zonePlan = planAutoZonesForLevel(detection.spaces, zones) + const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) + const zonePlan = planAutoZonesForLevel(spaces, zones) if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - updateSpacesForLevel(levelId, detection.spaces, editorStore) + updateSpacesForLevel(levelId, spaces, editorStore) } -function ceilingClampInputsChanged(before: SceneNodes, current: SceneNodes) { - const ids = new Set([ - ...(Object.keys(before) as AnyNodeId[]), - ...(Object.keys(current) as AnyNodeId[]), - ]) +function ceilingClampInputsChanged( + before: SceneNodes, + current: SceneNodes, + candidateIds?: ReadonlySet, +) { + const ids = + candidateIds ?? + new Set([ + ...(Object.keys(before) as AnyNodeId[]), + ...(Object.keys(current) as AnyNodeId[]), + ]) for (const id of ids) { const previous = before[id] const next = current[id] @@ -1983,11 +2381,15 @@ function reconcileLoweredCeilingBounds( beforeNodes: SceneNodes, currentNodes: SceneNodes, sceneStore: any, + candidateLevelIds?: ReadonlySet, ) { const liveNodes = sceneStore.getState().nodes as SceneNodes const updates: Array<{ id: CeilingNodeType['id']; data: Partial }> = [] + const candidates = candidateLevelIds + ? [...candidateLevelIds].flatMap((levelId) => levelChildren(liveNodes, levelId)) + : Object.values(liveNodes) - for (const node of Object.values(liveNodes)) { + for (const node of candidates) { if (node?.type !== 'ceiling' || node.height == null || !node.parentId) continue const currentBound = getCeilingClampBound(node.parentId, currentNodes, node.polygon) const previousBound = getCeilingClampBound(node.parentId, beforeNodes, node.polygon) @@ -2025,39 +2427,93 @@ export function isSpaceDetectionPaused(): boolean { return spaceDetectionPauseDepth > 0 } -export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { +export function initSpaceDetectionSync( + sceneStore: any, + editorStore: any, + options: SpaceDetectionSyncOptions = {}, +): () => void { let isProcessing = false + const topologyIndex = new RoomTopologyIndex() + topologyIndex.rebuild(sceneStore.getState().nodes as SceneNodes) + replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + const temporalState = sceneStore.temporal?.getState?.() + let previousPastLength = temporalState?.pastStates?.length ?? 0 + let previousFutureLength = temporalState?.futureStates?.length ?? 0 const processCommit = (commit: SceneCommit) => { if (isProcessing) return - if (spaceDetectionPauseDepth > 0 || commit.origin === 'load') return - const changedWalls = changedWallIdsByLevel(commit.before.nodes, commit.current.nodes) - const changedZones = changedZoneIdsByLevel(commit.before.nodes, commit.current.nodes) + if (commit.origin === 'load') { + topologyIndex.rebuild(commit.current.nodes) + replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + return + } + const changedWalls = changedWallIdsByLevel( + commit.before.nodes, + commit.current.nodes, + commit.changedNodeIds, + ) + const changedZones = changedZoneIdsByLevel( + commit.before.nodes, + commit.current.nodes, + commit.changedNodeIds, + ) const shouldReconcileCeilingBounds = ceilingClampInputsChanged( commit.before.nodes, commit.current.nodes, + commit.changedNodeIds, ) if (changedWalls.size === 0 && changedZones.size === 0 && !shouldReconcileCeilingBounds) return + if (spaceDetectionPauseDepth > 0) { + topologyIndex.rebuild(commit.current.nodes) + replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + return + } isProcessing = true pauseSceneHistory(sceneStore) try { for (const [levelId, wallIds] of changedWalls) { - reconcileWallTopologyDelta( + const topologyDelta = topologyIndex.applyWallDelta( levelId, wallIds, commit.before.nodes, commit.current.nodes, + ) + reconcileWallTopologyDelta( + levelId, + topologyDelta, + commit.current.nodes, sceneStore, editorStore, ) + options.onTopologyReconcile?.({ + levelId, + strategy: topologyDelta.strategy, + examinedWallIds: topologyDelta.examinedWallIds, + affectedBeforeRoomCount: topologyDelta.beforeRooms.length, + affectedCurrentRoomCount: topologyDelta.currentRooms.length, + }) } for (const [levelId, zoneIds] of changedZones) { if (changedWalls.has(levelId)) continue - reconcileChangedZones(levelId, zoneIds, commit.current.nodes, sceneStore) + reconcileChangedZones( + levelId, + zoneIds, + commit.current.nodes, + sceneStore, + topologyIndex.spacesForLevel(levelId), + ) } - if (shouldReconcileCeilingBounds || changedWalls.size > 0) { + if (shouldReconcileCeilingBounds) { reconcileLoweredCeilingBounds(commit.before.nodes, sceneStore.getState().nodes, sceneStore) + } else if (changedWalls.size > 0) { + const liveNodes = sceneStore.getState().nodes as SceneNodes + const lowerLevelIds = new Set() + for (const levelId of changedWalls.keys()) { + const lowerLevel = getLevelBelow(levelId, liveNodes) + if (lowerLevel) lowerLevelIds.add(lowerLevel.id) + } + reconcileLoweredCeilingBounds(commit.before.nodes, liveNodes, sceneStore, lowerLevelIds) } } finally { resumeSceneHistory(sceneStore) @@ -2065,7 +2521,29 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } } - return subscribeSceneCommits(processCommit) + const unsubscribeCommits = subscribeSceneCommits(processCommit) + const unsubscribeTemporal = + sceneStore.temporal?.subscribe?.( + (state: { pastStates?: unknown[]; futureStates?: unknown[] }) => { + const pastLength = state.pastStates?.length ?? 0 + const futureLength = state.futureStates?.length ?? 0 + const didUndo = futureLength > previousFutureLength + const didRedo = pastLength > previousPastLength && futureLength < previousFutureLength + previousPastLength = pastLength + previousFutureLength = futureLength + if (!(didUndo || didRedo)) return + + queueMicrotask(() => { + topologyIndex.rebuild(sceneStore.getState().nodes as SceneNodes) + replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + }) + }, + ) ?? (() => {}) + + return () => { + unsubscribeCommits() + unsubscribeTemporal() + } } export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { diff --git a/packages/core/src/store/history-control-node-ids.test.ts b/packages/core/src/store/history-control-node-ids.test.ts new file mode 100644 index 000000000..b41ae82f9 --- /dev/null +++ b/packages/core/src/store/history-control-node-ids.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { BuildingNode } from '../schema/nodes/building' +import { LevelNode } from '../schema/nodes/level' +import { WallNode } from '../schema/nodes/wall' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + runAsSingleSceneHistoryStep, + type SceneCommit, + subscribeSceneCommits, +} from './history-control' +import useScene, { clearSceneHistory } from './use-scene' + +type RafFn = (cb: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => { + cb(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const BUILDING_ID = 'building_changed_ids' as AnyNodeId +const LEVEL_ID = 'level_changed_ids' as AnyNodeId + +let unsubscribe = () => {} + +describe('scene commit changed node IDs', () => { + beforeEach(() => { + const level = LevelNode.parse({ + id: LEVEL_ID, + parentId: BUILDING_ID, + children: [], + }) + const building = BuildingNode.parse({ + id: BUILDING_ID, + parentId: null, + children: [LEVEL_ID], + }) + useScene.setState({ + nodes: { [BUILDING_ID]: building, [LEVEL_ID]: level }, + rootNodeIds: [BUILDING_ID], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() + }) + + afterEach(() => { + unsubscribe() + unsubscribe = () => {} + }) + + test('carries local mutation IDs and unions them across a compound history step', () => { + const commits: SceneCommit[] = [] + unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) + const first = WallNode.parse({ + id: 'wall_changed_1', + parentId: LEVEL_ID, + start: [0, 0], + end: [4, 0], + }) + const second = WallNode.parse({ + id: 'wall_changed_2', + parentId: LEVEL_ID, + start: [4, 0], + end: [4, 3], + }) + + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().createNode(first, LEVEL_ID) + useScene.getState().createNode(second, LEVEL_ID) + useScene.getState().updateNode(first.id, { + end: [5, 0], + } as Partial) + }) + + expect(commits).toHaveLength(1) + expect(commits[0]?.changedNodeIds).toEqual(new Set([first.id, second.id])) + }) +}) diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index f5bd00cad..741f64d3a 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -18,6 +18,7 @@ export type SceneCommit = { origin: SceneCommitOrigin before: SceneSnapshot current: SceneSnapshot + changedNodeIds?: ReadonlySet } export type SceneCommitListener = (commit: SceneCommit) => void @@ -43,6 +44,37 @@ type TemporalHistoryStoreLike = { const sceneCommitListeners = new Set() let sceneCommitTransactionDepth = 0 let pendingSceneCommit: SceneCommit | null = null +const sceneCommitNodeIdScopes: Set[] = [] + +function mergedNodeIds( + left: ReadonlySet | undefined, + right: ReadonlySet | undefined, +) { + if (!(left || right)) return undefined + return new Set([...(left ?? []), ...(right ?? [])]) +} + +function activeSceneCommitNodeIds() { + if (sceneCommitNodeIdScopes.length === 0) return undefined + const ids = new Set() + for (const scope of sceneCommitNodeIdScopes) { + for (const id of scope) ids.add(id) + } + return ids +} + +export function runWithSceneCommitNodeIds( + nodeIds: Iterable, + run: () => TResult, +): TResult { + const scope = new Set(nodeIds) + sceneCommitNodeIdScopes.push(scope) + try { + return run() + } finally { + sceneCommitNodeIdScopes.pop() + } +} function areSemanticValuesEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true @@ -98,21 +130,29 @@ function emitSceneCommit(commit: SceneCommit): void { export function notifySceneCommit(commit: SceneCommit): void { if (areSceneSnapshotsEqual(commit.before, commit.current)) return + const contextualCommit: SceneCommit = { + ...commit, + changedNodeIds: mergedNodeIds(commit.changedNodeIds, activeSceneCommitNodeIds()), + } if (sceneCommitTransactionDepth > 0) { if (pendingSceneCommit) { pendingSceneCommit = { origin: pendingSceneCommit.origin, before: pendingSceneCommit.before, - current: commit.current, + current: contextualCommit.current, + changedNodeIds: mergedNodeIds( + pendingSceneCommit.changedNodeIds, + contextualCommit.changedNodeIds, + ), } } else { - pendingSceneCommit = commit + pendingSceneCommit = contextualCommit } return } - emitSceneCommit(commit) + emitSceneCommit(contextualCommit) } function beginSceneCommitTransaction(): void { diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 60f9a2329..7c0996b00 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -45,6 +45,7 @@ import { pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, + runWithSceneCommitNodeIds, type SceneCommitOrigin, type SceneSnapshot, } from './history-control' @@ -1446,18 +1447,42 @@ const useScene: UseSceneStore = create()( get().dirtyNodes.delete(id) }, - createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), - createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), - applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes), - - updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), - updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), + createNodes: (ops) => + runWithSceneCommitNodeIds( + ops.map(({ node }) => node.id), + () => nodeActions.createNodesAction(set, get, ops), + ), + createNode: (node, parentId) => + runWithSceneCommitNodeIds([node.id], () => + nodeActions.createNodesAction(set, get, [{ node, parentId }]), + ), + applyNodeChanges: (changes) => + runWithSceneCommitNodeIds( + [ + ...(changes.create ?? []).map(({ node }) => node.id), + ...(changes.update ?? []).map(({ id }) => id), + ...(changes.delete ?? []), + ], + () => nodeActions.applyNodeChangesAction(set, get, changes), + ), + + updateNodes: (updates) => + runWithSceneCommitNodeIds( + updates.map(({ id }) => id), + () => nodeActions.updateNodesAction(set, get, updates), + ), + updateNode: (id, data) => + runWithSceneCommitNodeIds([id], () => + nodeActions.updateNodesAction(set, get, [{ id, data }]), + ), // --- DELETE --- - deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), + deleteNodes: (ids) => + runWithSceneCommitNodeIds(ids, () => nodeActions.deleteNodesAction(set, get, ids)), - deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), + deleteNode: (id) => + runWithSceneCommitNodeIds([id], () => nodeActions.deleteNodesAction(set, get, [id])), // --- COLLECTIONS --- @@ -1988,6 +2013,7 @@ export function applySceneOperationPatch(changes: SceneOperationPatch): boolean origin: 'host', before, current, + changedNodeIds: touchedNodeIds, }) return true } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 69128b51b..f18570402 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9717,10 +9717,9 @@ export function FloorplanPanel({ // pipelines resolved endpoints ≥1e-6 apart (the duplicate // check compares exact endpoints). // - // That 3D path is dead in 2D-only view — the canvas is - // `display:none`, so the tool never commits. Mirror the slab / - // ceiling 2D-only committers: create locally here, gated on the - // view, so split / 3D keep their single-owner tool commit. + // The mounted 3D wall tool explicitly ignores floorplan events in + // 2D-only view. Mirror the slab / ceiling 2D-only committers: create + // locally here, gated on the view, so every view has one commit owner. const viewIs2DOnly = useEditor.getState().viewMode === '2d' const createdWall = viewIs2DOnly ? createWallOnCurrentLevel(draftStart, point) : null if (createdWall) { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 1e31cf734..41b25a5cc 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -167,6 +167,23 @@ describe('createWallOnCurrentLevel', () => { ).toBe(true) }) + test('projecting a magnetic endpoint onto an angled host does not create a sliver', () => { + seedLevel([ + makeWall([-6.5, 2.5], [-0.5, -3.5], 'wall_top'), + makeWall([-0.5, -3.5], [4, 1.5], 'wall_right'), + makeWall([4, 1.5], [-2, 7], 'wall_bottom'), + makeWall([-2, 7], [-6.5, 2.5], 'wall_left'), + ]) + + const created = createWallOnCurrentLevel( + [-3.5, -0.5], + [0.994_492_060_526_807_5, 4.005_494_489_071_548], + ) + + expect(created?.end).not.toEqual([0.994_492_060_526_807_5, 4.005_494_489_071_548]) + expect(levelWalls()).toHaveLength(7) + }) + test('exact duplicate segment is rejected', () => { expect(createWallOnCurrentLevel([0, 0], [4, 0])).toBeNull() expect(levelWalls()).toHaveLength(1) diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts index 7bcbdf60f..6614d137b 100644 --- a/packages/editor/src/lib/scene.test.ts +++ b/packages/editor/src/lib/scene.test.ts @@ -67,6 +67,7 @@ describe('applySceneGraphToEditor', () => { (node) => node.type === 'slab' || node.type === 'ceiling', ) expect(surfaces).toHaveLength(0) + expect(Object.values(useEditor.getState().spaces)).toHaveLength(1) } finally { unsubscribe() } diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 9aa0a1444..5d3d95266 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -3,9 +3,11 @@ import { clearSceneHistory, nodeRegistry, + notifySceneCommit, pauseSceneHistory, resolveLevelId, resumeSceneHistory, + type SceneSnapshot, sceneRegistry, useScene, } from '@pascal-app/core' @@ -384,8 +386,14 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } +function currentSceneSnapshot(): SceneSnapshot { + const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() + return { nodes, rootNodeIds, collections, materials, installedPlugins } +} + export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() + const before = currentSceneSnapshot() pauseSceneHistory(useScene) try { if (hasUsableSceneGraph(sceneGraph)) { @@ -406,6 +414,11 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { // The loaded scene is the undo floor; discard history from the previous scene. clearSceneHistory() + notifySceneCommit({ + origin: 'load', + before, + current: currentSceneSnapshot(), + }) syncEditorSelectionFromCurrentScene() } diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 3bd49e078..cf108080f 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -558,6 +558,7 @@ export const WallTool: React.FC = () => { } const onGridMove = (event: GridEvent) => { + if (useEditor.getState().viewMode === '2d') return if (!(cursorRef.current && wallPreviewRef.current)) return // Ride the grid event plane on the pointed surface: aiming at an @@ -652,6 +653,7 @@ export const WallTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { + if (useEditor.getState().viewMode === '2d') return if (!wallPreviewRef.current) return if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { From 9031917a0aebf4de448ad85ced987b0ac590b7f5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 01:45:31 +0530 Subject: [PATCH 10/14] refactor wall topology into core --- packages/core/src/index.ts | 14 +- .../core/src/store/use-scene-commits.test.ts | 8 +- packages/core/src/store/use-scene.ts | 54 +- .../src/systems/wall/wall-topology.test.ts | 122 ++++ .../core/src/systems/wall/wall-topology.ts | 545 ++++++++++++++++++ .../components/tools/wall/wall-drafting.ts | 441 ++------------ .../tools/wall/wall-snap-geometry.ts | 159 +---- packages/editor/src/lib/scene.ts | 46 +- 8 files changed, 769 insertions(+), 620 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-topology.test.ts create mode 100644 packages/core/src/systems/wall/wall-topology.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c9ea7a66..c57110d4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -197,7 +197,6 @@ export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getSceneHistoryPauseDepth, - notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, @@ -234,6 +233,7 @@ export { applyScenePatch, applySceneSnapshot, clearSceneHistory, + createDefaultSceneSnapshot, default as useScene, type SceneMaterialPatch, type SceneNodePatch, @@ -345,6 +345,18 @@ export { resolveWallEffectiveHeight, resolveWallTop, } from './systems/wall/wall-top' +export { + findWallSegmentIntersections, + planWallInsertion, + planWallSplitAtPoint, + projectPointOntoWallCenterline, + type WallInsertionPlan, + type WallPointSplitPlan, + type WallProjection, + type WallSegmentIntersection, + type WallTopologyChanges, + wallSegmentsCoverSegment, +} from './systems/wall/wall-topology' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index 88b9a82f2..af40e71ed 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -675,9 +675,15 @@ describe('scene commit boundary', () => { unsubscribe = subscribeSceneCommits((commit) => commits.push(commit)) useScene.getState().dirtyNodes.clear() - expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true) + expect( + applySceneSnapshot(snapshot, { + origin: 'host', + hasExplicitPluginInstallState: true, + }), + ).toBe(true) expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8) expect(useScene.getState().installedPlugins).toEqual(['pascal:trees']) + expect(useScene.getState().hasExplicitPluginInstallState).toBe(true) expect(commits.map((commit) => commit.origin)).toEqual(['host']) expect(useScene.temporal.getState().pastStates).toHaveLength(0) expect(useScene.temporal.getState().futureStates).toHaveLength(0) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 7c0996b00..e51433b28 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1285,6 +1285,28 @@ function sceneHistorySnapshotFromState( return { nodes, rootNodeIds, collections, materials, installedPlugins } } +export function createDefaultSceneSnapshot(installedPlugins: string[] = []): SceneSnapshot { + const level = LevelNode.parse({ + level: 0, + children: [], + height: 2.5, + }) + const building = BuildingNode.parse({ children: [level.id] }) + const site = SiteNode.parse({ children: [building.id] }) + + return { + nodes: { + [site.id]: site, + [building.id]: building, + [level.id]: level, + }, + rootNodeIds: [site.id], + collections: {}, + materials: {}, + installedPlugins, + } +} + const useScene: UseSceneStore = create()( temporal( (set, get) => ({ @@ -1408,32 +1430,8 @@ const useScene: UseSceneStore = create()( return // Scene already loaded } - // Create hierarchy: Site → Building → Level - const level0 = LevelNode.parse({ - level: 0, - children: [], - height: 2.5, - }) - - const building = BuildingNode.parse({ - children: [level0.id], - }) - - const site = SiteNode.parse({ - children: [building.id], - }) - - // Define all nodes flat - const nodes: Record = { - [site.id]: site, - [building.id]: building, - [level0.id]: level0, - } - - // Site is the root - const rootNodeIds = [site.id] - - set({ nodes, rootNodeIds }) + const snapshot = createDefaultSceneSnapshot(get().installedPlugins) + set({ nodes: snapshot.nodes, rootNodeIds: snapshot.rootNodeIds }) }, markDirty: (id) => { @@ -2028,6 +2026,7 @@ export function applyScenePatch(changes: ScenePatch): boolean { export type ApplySceneSnapshotOptions = { origin: Extract + hasExplicitPluginInstallState?: boolean } export function applySceneSnapshot( @@ -2045,11 +2044,12 @@ export function applySceneSnapshot( collections: snapshot.collections, installedPlugins: snapshot.installedPlugins, materials: snapshot.materials, + hasExplicitPluginInstallState: options.hasExplicitPluginInstallState, }) - useScene.temporal.getState().clear() } finally { resumeSceneHistory(useScene) } + clearSceneHistory() useLiveNodeOverrides.getState().clearAll() useLiveTransforms.getState().clearAll() diff --git a/packages/core/src/systems/wall/wall-topology.test.ts b/packages/core/src/systems/wall/wall-topology.test.ts new file mode 100644 index 000000000..b0efd8875 --- /dev/null +++ b/packages/core/src/systems/wall/wall-topology.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, DoorNode, WallNode } from '../../schema' +import { getWallCurveFrameAt } from './wall-curve' +import { planWallInsertion, planWallSplitAtPoint } from './wall-topology' + +const LEVEL_ID = 'level_topology' as AnyNodeId + +function nodeMap(nodes: AnyNode[]) { + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('planWallInsertion', () => { + test('returns one atomic plan for host splits and inserted wall segments', () => { + const horizontal = WallNode.parse({ + id: 'wall_horizontal', + parentId: LEVEL_ID, + start: [0, 0], + end: [4, 0], + }) + + const plan = planWallInsertion(nodeMap([horizontal]), { + levelId: LEVEL_ID, + start: [2, -2], + end: [2, 2], + joinRadius: 0.05, + }) + + expect(plan).not.toBeNull() + expect(plan?.changes.delete).toEqual([horizontal.id]) + expect(plan?.changes.update).toHaveLength(0) + expect(plan?.changes.create).toHaveLength(4) + expect(plan?.insertedWalls).toHaveLength(2) + expect(plan?.insertedWalls[0]?.end).toEqual([2, 0]) + expect(plan?.insertedWalls[1]?.start).toEqual([2, 0]) + }) + + test('splits one curved host at every crossing without exposing intermediate mutations', () => { + const curved = WallNode.parse({ + id: 'wall_curved', + parentId: LEVEL_ID, + start: [0, 0], + end: [4, 0], + curveOffset: 1, + }) + + const plan = planWallInsertion(nodeMap([curved]), { + levelId: LEVEL_ID, + start: [1, -2], + end: [1, 2], + joinRadius: 0.05, + }) + + expect(plan).not.toBeNull() + expect(plan?.changes.delete).toEqual([curved.id]) + const replacements = plan?.changes.create + .map(({ node }) => node) + .filter( + (node): node is WallNode => node.type === 'wall' && !plan.insertedWalls.includes(node), + ) + expect(replacements).toHaveLength(2) + expect(replacements?.every((wall) => Math.abs(wall.curveOffset ?? 0) > 0)).toBe(true) + }) + + test('keeps an attached opening on the replacement segment that contains it', () => { + const door = DoorNode.parse({ + id: 'door_attached', + parentId: 'wall_host', + wallId: 'wall_host', + position: [1, 0, 0], + width: 0.8, + }) + const host = WallNode.parse({ + id: 'wall_host', + parentId: LEVEL_ID, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + + const plan = planWallSplitAtPoint(nodeMap([host, door]), { + levelId: LEVEL_ID, + point: [3, 0.01], + radius: 0.05, + }) + + expect(plan).not.toBeNull() + expect(plan?.changes.delete).toEqual([host.id]) + expect(plan?.changes.create).toHaveLength(2) + expect(plan?.changes.update).toHaveLength(1) + const doorUpdate = plan?.changes.update[0] + const newParent = plan?.changes.create.find(({ node }) => node.id === doorUpdate?.data.parentId) + expect(newParent?.node.type).toBe('wall') + expect(newParent?.node.type === 'wall' ? newParent.node.children : []).toContain(door.id) + }) + + test('resolves onto a host but refuses to split through an opening', () => { + const door = DoorNode.parse({ + id: 'door_straddling', + parentId: 'wall_blocked', + wallId: 'wall_blocked', + position: [2, 0, 0], + width: 1, + }) + const host = WallNode.parse({ + id: 'wall_blocked', + parentId: LEVEL_ID, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const midpoint = getWallCurveFrameAt(host, 0.5).point + + const plan = planWallSplitAtPoint(nodeMap([host, door]), { + levelId: LEVEL_ID, + point: [midpoint.x, midpoint.y], + radius: 0.05, + }) + + expect(plan?.point).toEqual([2, 0]) + expect(plan?.changes).toEqual({ create: [], update: [], delete: [] }) + }) +}) diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts new file mode 100644 index 000000000..b9990359e --- /dev/null +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -0,0 +1,545 @@ +import { + type AnyNode, + type AnyNodeId, + type DoorNode, + getScaledDimensions, + type ItemNode, + type WallNode, + WallNode as WallSchema, + type WindowNode, +} from '../../schema' +import { getWallArcData, getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from './wall-curve' +import type { WallPlanPoint } from './wall-move' + +const WALL_MIN_LENGTH = 0.01 +const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 +const WALL_INTERSECTION_EPSILON = 1e-6 + +export type WallProjection = { + point: WallPlanPoint + wallT: number +} + +export type WallSegmentIntersection = { + wallId: WallNode['id'] + point: WallPlanPoint + draftT: number + wallT: number +} + +export type WallTopologyChanges = { + create: Array<{ node: AnyNode; parentId?: AnyNodeId }> + update: Array<{ id: AnyNodeId; data: Partial }> + delete: AnyNodeId[] +} + +export type WallInsertionPlan = { + changes: WallTopologyChanges + insertedWalls: WallNode[] + terminalWallId: WallNode['id'] + resolvedStart: WallPlanPoint + resolvedEnd: WallPlanPoint +} + +export type WallPointSplitPlan = { + changes: WallTopologyChanges + point: WallPlanPoint +} + +type WallSplitRequest = { + point: WallPlanPoint + wallT: number +} + +type PlannedWallSplit = { + create: WallNode[] + update: WallTopologyChanges['update'] +} + +function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = WALL_INTERSECTION_EPSILON) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint) { + return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH +} + +export function projectPointOntoWallCenterline( + point: WallPlanPoint, + wall: WallNode, +): WallProjection | null { + if (isCurvedWall(wall)) { + const arc = getWallArcData(wall) + if (!arc) return null + + const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + let directedAngle = (pointAngle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + + const wallT = directedAngle / Math.abs(arc.delta) + if (wallT <= 0 || wallT >= 1) return null + + const frame = getWallCurveFrameAt(wall, wallT) + return { point: [frame.point.x, frame.point.y], wallT } + } + + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) return null + + const wallT = ((point[0] - wall.start[0]) * dx + (point[1] - wall.start[1]) * dz) / lengthSquared + if (wallT <= 0 || wallT >= 1) return null + + return { + point: [wall.start[0] + dx * wallT, wall.start[1] + dz * wallT], + wallT, + } +} + +function straightSegmentIntersection( + start: WallPlanPoint, + end: WallPlanPoint, + wall: WallNode, +): Omit | null { + const rx = end[0] - start[0] + const rz = end[1] - start[1] + const sx = wall.end[0] - wall.start[0] + const sz = wall.end[1] - wall.start[1] + const denominator = rx * sz - rz * sx + if (Math.abs(denominator) < 1e-9) return null + + const offsetX = wall.start[0] - start[0] + const offsetZ = wall.start[1] - start[1] + const draftT = (offsetX * sz - offsetZ * sx) / denominator + const wallT = (offsetX * rz - offsetZ * rx) / denominator + if (draftT < 0 || draftT > 1 || wallT < 0 || wallT > 1) return null + + return { + point: [start[0] + draftT * rx, start[1] + draftT * rz], + draftT, + wallT, + } +} + +function curvedSegmentIntersections( + start: WallPlanPoint, + end: WallPlanPoint, + wall: WallNode, +): Array> { + const arc = getWallArcData(wall) + if (!arc) return [] + + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const offsetX = start[0] - arc.center.x + const offsetZ = start[1] - arc.center.y + const a = dx * dx + dz * dz + if (a < 1e-12) return [] + + const b = 2 * (offsetX * dx + offsetZ * dz) + const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius + const discriminant = b * b - 4 * a * c + if (discriminant < -1e-9) return [] + + const root = Math.sqrt(Math.max(0, discriminant)) + const results: Array> = [] + for (const rawDraftT of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) { + if (rawDraftT < -1e-9 || rawDraftT > 1 + 1e-9) continue + const point: WallPlanPoint = [start[0] + rawDraftT * dx, start[1] + rawDraftT * dz] + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + let directedAngle = (angle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const rawWallT = directedAngle / Math.abs(arc.delta) + if (rawWallT < -1e-9 || rawWallT > 1 + 1e-9) continue + if (results.some((candidate) => distanceSquared(candidate.point, point) < 1e-12)) continue + + results.push({ + point, + draftT: Math.max(0, Math.min(1, rawDraftT)), + wallT: Math.max(0, Math.min(1, rawWallT)), + }) + } + return results +} + +export function findWallSegmentIntersections( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): WallSegmentIntersection[] { + return walls.flatMap((wall) => { + const intersections = isCurvedWall(wall) + ? curvedSegmentIntersections(start, end, wall) + : [straightSegmentIntersection(start, end, wall)].filter( + (entry): entry is Omit => entry !== null, + ) + return intersections.map((intersection) => ({ wallId: wall.id, ...intersection })) + }) +} + +export function wallSegmentsCoverSegment( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], + tolerance = WALL_INTERSECTION_EPSILON, +) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= tolerance * tolerance) return false + + const length = Math.sqrt(lengthSquared) + const intervals: Array<[number, number]> = [] + for (const wall of walls) { + if (isCurvedWall(wall)) continue + const startDistance = + Math.abs((wall.start[0] - start[0]) * dz - (wall.start[1] - start[1]) * dx) / length + const endDistance = + Math.abs((wall.end[0] - start[0]) * dz - (wall.end[1] - start[1]) * dx) / length + if (startDistance > tolerance || endDistance > tolerance) continue + + const wallStartT = + ((wall.start[0] - start[0]) * dx + (wall.start[1] - start[1]) * dz) / lengthSquared + const wallEndT = ((wall.end[0] - start[0]) * dx + (wall.end[1] - start[1]) * dz) / lengthSquared + const intervalStart = Math.max(0, Math.min(wallStartT, wallEndT)) + const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT)) + if (intervalEnd >= intervalStart) intervals.push([intervalStart, intervalEnd]) + } + + intervals.sort((left, right) => left[0] - right[0]) + const parameterTolerance = tolerance / length + let coveredUntil = 0 + for (const [intervalStart, intervalEnd] of intervals) { + if (intervalStart > coveredUntil + parameterTolerance) return false + coveredUntil = Math.max(coveredUntil, intervalEnd) + if (coveredUntil >= 1 - parameterTolerance) return true + } + return false +} + +function wallsForLevel(nodes: Record, levelId: AnyNodeId) { + return Object.values(nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === levelId, + ) +} + +function nearestWallProjection( + point: WallPlanPoint, + walls: WallNode[], + radius: number, + ignoreWallIds: ReadonlySet, +): { wall: WallNode | null; point: WallPlanPoint; wallT: number } | null { + let best: { wall: WallNode | null; point: WallPlanPoint; wallT: number } | null = null + let bestDistance = Number.POSITIVE_INFINITY + + for (const wall of walls) { + if (ignoreWallIds.has(wall.id)) continue + const projection = projectPointOntoWallCenterline(point, wall) + if (!projection) continue + const candidateDistance = distanceSquared(point, projection.point) + if (candidateDistance > radius * radius || candidateDistance >= bestDistance) continue + + const corner = ([wall.start, wall.end] as WallPlanPoint[]).find( + (candidate) => + distanceSquared(projection.point, candidate) <= + WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, + ) + best = corner + ? { wall: null, point: [corner[0], corner[1]], wallT: projection.wallT } + : { wall, ...projection } + bestDistance = candidateDistance + } + return best +} + +function wallLength(wall: WallNode) { + return isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +function attachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null { + if (node.type === 'door') { + const door = node as DoorNode + return { + min: door.position[0] - door.width / 2, + max: door.position[0] + door.width / 2, + center: door.position[0], + } + } + if (node.type === 'window') { + const window = node as WindowNode + return { + min: window.position[0] - window.width / 2, + max: window.position[0] + window.width / 2, + center: window.position[0], + } + } + if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') return null + const [width] = getScaledDimensions(item) + return { + min: item.position[0] - width / 2, + max: item.position[0] + width / 2, + center: item.position[0], + } + } + return null +} + +function wallAttachments(wall: WallNode, nodes: Record) { + const ids = new Set((wall.children ?? []) as AnyNodeId[]) + for (const node of Object.values(nodes)) { + if ( + node.parentId === wall.id || + ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) + ) { + ids.add(node.id) + } + } + return [...ids].flatMap((id) => { + const node = nodes[id] + return node ? [node] : [] + }) +} + +function splitPoint(wall: WallNode, wallT: number): WallPlanPoint { + if (wallT <= WALL_INTERSECTION_EPSILON) return wall.start + if (wallT >= 1 - WALL_INTERSECTION_EPSILON) return wall.end + const frame = getWallCurveFrameAt(wall, wallT) + return [frame.point.x, frame.point.y] +} + +function segmentCurveOffset(wall: WallNode, startT: number, endT: number) { + const arc = getWallArcData(wall) + if (!arc) return wall.curveOffset + const angle = Math.abs(arc.delta) * (endT - startT) + return arc.direction * arc.radius * (1 - Math.cos(angle / 2)) +} + +function remapAttachment( + node: AnyNode, + wall: WallNode, + nextLocalX: number, +): Partial | null { + if (!(node.type === 'door' || node.type === 'window' || node.type === 'item')) return null + const nextLength = wallLength(wall) + const clampedX = Math.max(0, Math.min(nextLength, nextLocalX)) + return { + parentId: wall.id, + wallId: wall.id, + position: [clampedX, node.position[1], node.position[2]], + ...(node.type === 'item' ? { wallT: nextLength > 1e-6 ? clampedX / nextLength : 0 } : {}), + } as Partial +} + +function planWallSplit( + wall: WallNode, + requests: WallSplitRequest[], + nodes: Record, +): PlannedWallSplit | null { + const splitParameters = [...new Set(requests.map(({ wallT }) => wallT))] + .filter((wallT) => wallT > WALL_INTERSECTION_EPSILON && wallT < 1 - WALL_INTERSECTION_EPSILON) + .sort((left, right) => left - right) + if (splitParameters.length === 0) return { create: [], update: [] } + + const parameters = [0, ...splitParameters, 1] + const segments = parameters.slice(0, -1).map((startT, index) => { + const endT = parameters[index + 1]! + const { id: _id, parentId: _parentId, children: _children, ...properties } = wall + return WallSchema.parse({ + ...properties, + start: splitPoint(wall, startT), + end: splitPoint(wall, endT), + curveOffset: segmentCurveOffset(wall, startT, endT), + children: [], + }) + }) + + const totalLength = wallLength(wall) + const segmentChildren = segments.map(() => [] as AnyNodeId[]) + const updates: WallTopologyChanges['update'] = [] + for (const attachment of wallAttachments(wall, nodes)) { + const span = attachmentSpan(attachment) + if (!span) return null + + const segmentIndex = parameters.slice(0, -1).findIndex((startT, index) => { + const endT = parameters[index + 1]! + return span.min >= totalLength * startT - 1e-4 && span.max <= totalLength * endT + 1e-4 + }) + if (segmentIndex < 0) return null + + const segment = segments[segmentIndex]! + const nextData = remapAttachment( + attachment, + segment, + span.center - totalLength * parameters[segmentIndex]!, + ) + if (!nextData) return null + segmentChildren[segmentIndex]!.push(attachment.id) + updates.push({ id: attachment.id, data: nextData }) + } + + return { + create: segments.map((segment, index) => + WallSchema.parse({ ...segment, children: segmentChildren[index] }), + ), + update: updates, + } +} + +function emptyChanges(): WallTopologyChanges { + return { create: [], update: [], delete: [] } +} + +export function planWallSplitAtPoint( + nodes: Record, + args: { + levelId: AnyNodeId | null + point: WallPlanPoint + radius: number + ignoreWallIds?: readonly string[] + }, +): WallPointSplitPlan | null { + if (!args.levelId) return null + const walls = wallsForLevel(nodes, args.levelId) + const projection = nearestWallProjection( + args.point, + walls, + args.radius, + new Set(args.ignoreWallIds ?? []), + ) + if (!projection) return null + if (!projection.wall) return { point: projection.point, changes: emptyChanges() } + + const split = planWallSplit( + projection.wall, + [{ point: projection.point, wallT: projection.wallT }], + nodes, + ) + if (!split) return { point: projection.point, changes: emptyChanges() } + + return { + point: projection.point, + changes: { + create: split.create.map((node) => ({ node, parentId: args.levelId ?? undefined })), + update: split.update, + delete: [projection.wall.id], + }, + } +} + +export function planWallInsertion( + nodes: Record, + args: { + levelId: AnyNodeId + start: WallPlanPoint + end: WallPlanPoint + joinRadius: number + wallDefaults?: Partial + }, +): WallInsertionPlan | null { + const walls = wallsForLevel(nodes, args.levelId) + const endpointRequests = new Map() + const addRequest = (wall: WallNode, request: WallSplitRequest) => { + const requests = endpointRequests.get(wall.id) ?? [] + if ( + !requests.some(({ wallT }) => Math.abs(wallT - request.wallT) <= WALL_INTERSECTION_EPSILON) + ) { + requests.push(request) + } + endpointRequests.set(wall.id, requests) + } + + const endProjection = nearestWallProjection(args.end, walls, args.joinRadius, new Set()) + const resolvedEnd = endProjection?.point ?? args.end + if (endProjection?.wall) { + addRequest(endProjection.wall, { point: endProjection.point, wallT: endProjection.wallT }) + } + + const startProjection = nearestWallProjection(args.start, walls, args.joinRadius, new Set()) + const resolvedStart = startProjection?.point ?? args.start + if (startProjection?.wall) { + addRequest(startProjection.wall, { + point: startProjection.point, + wallT: startProjection.wallT, + }) + } + + if ( + !isSegmentLongEnough(resolvedStart, resolvedEnd) || + pointsEqual(resolvedStart, resolvedEnd) || + wallSegmentsCoverSegment(resolvedStart, resolvedEnd, walls) + ) { + return null + } + + const crossings = findWallSegmentIntersections(resolvedStart, resolvedEnd, walls) + .filter( + ({ draftT }) => draftT > WALL_INTERSECTION_EPSILON && draftT < 1 - WALL_INTERSECTION_EPSILON, + ) + .sort((left, right) => left.draftT - right.draftT) + for (const crossing of crossings) { + if ( + crossing.wallT > WALL_INTERSECTION_EPSILON && + crossing.wallT < 1 - WALL_INTERSECTION_EPSILON + ) { + const wall = walls.find((candidate) => candidate.id === crossing.wallId) + if (wall) addRequest(wall, { point: crossing.point, wallT: crossing.wallT }) + } + } + + const splitPlans = new Map() + for (const [wallId, requests] of endpointRequests) { + const wall = walls.find((candidate) => candidate.id === wallId) + if (!wall) continue + const split = planWallSplit(wall, requests, nodes) + if (split) splitPlans.set(wallId, split) + } + + const splitPoints: WallPlanPoint[] = [] + for (const crossing of crossings) { + const doesNotNeedHostSplit = + crossing.wallT <= WALL_INTERSECTION_EPSILON || crossing.wallT >= 1 - WALL_INTERSECTION_EPSILON + if (!(doesNotNeedHostSplit || splitPlans.has(crossing.wallId))) continue + if (!splitPoints.some((point) => pointsEqual(point, crossing.point))) { + splitPoints.push(crossing.point) + } + } + + const existingWallCount = Object.values(nodes).filter((node) => node.type === 'wall').length + const vertices = [resolvedStart, ...splitPoints, resolvedEnd] + const insertedWalls = vertices.slice(0, -1).map((start, index) => + WallSchema.parse({ + ...(args.wallDefaults ?? {}), + name: `Wall ${existingWallCount + index + 1}`, + start, + end: vertices[index + 1]!, + }), + ) + if (insertedWalls.length === 0) return null + + const replacementWalls = [...splitPlans.values()].flatMap(({ create }) => create) + return { + changes: { + create: [...replacementWalls, ...insertedWalls].map((node) => ({ + node, + parentId: args.levelId, + })), + update: [...splitPlans.values()].flatMap(({ update }) => update), + delete: [...splitPlans.keys()] as AnyNodeId[], + }, + insertedWalls, + terminalWallId: insertedWalls.at(-1)!.id, + resolvedStart, + resolvedEnd, + } +} diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index c237a5cf5..ad0f5b58d 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -1,20 +1,13 @@ import { - type AnyNode, type AnyNodeId, DEFAULT_ANGLE_STEP, - type DoorNode, - getScaledDimensions, - getWallArcData, - getWallCurveLength, - type ItemNode, - isCurvedWall, + planWallInsertion, + planWallSplitAtPoint, resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, useScene, type WallNode, - WallNode as WallSchema, - type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -22,16 +15,13 @@ import { resolveSnapFlags } from '../../../lib/snapping-mode' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import { distanceSquared, - findWallSegmentIntersections, findWallSnapTarget, findWallSpecialPointSnap, - projectPointOntoWallDetailed, WALL_CONNECT_SNAP_RADIUS, WALL_JOIN_SNAP_RADIUS, type WallDraftSnapResult, type WallPlanPoint, type WallSnapRadii, - wallSegmentsCoverSegment, } from './wall-snap-geometry' // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so @@ -49,18 +39,6 @@ export { export const WALL_GRID_STEP = 0.5 export const WALL_MIN_LENGTH = 0.01 -// An endpoint projecting within this distance of an existing wall's corner -// resolves to the corner without splitting — splitting there would mint a -// sliver segment a hair longer than `WALL_MIN_LENGTH` that no snap radius -// can ever target again. -const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 - -type WallSplitIntersection = { - /** `null` = snap-only outcome: resolve to `point` but split no wall. */ - wallId: WallNode['id'] | null - point: WallPlanPoint - wallT: number -} export function getSegmentGridStep(): number { // A 0 step means "no grid lattice" — every grid-snap consumer guards on @@ -79,277 +57,12 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] } -function splitWallAtPoint( - wall: WallNode, - splitPoint: WallPlanPoint, - wallT: number, -): [WallNode, WallNode] { - const { id: _id, parentId: _parentId, children, ...rest } = wall - const arc = getWallArcData(wall) - const curveOffsets = arc - ? ([wallT, 1 - wallT].map((fraction) => { - const subArcAngle = Math.abs(arc.delta) * fraction - return arc.direction * arc.radius * (1 - Math.cos(subArcAngle / 2)) - }) as [number, number]) - : ([wall.curveOffset, wall.curveOffset] as const) - - const first = WallSchema.parse({ - ...rest, - start: wall.start, - end: splitPoint, - curveOffset: curveOffsets[0], - children: [], - }) - const second = WallSchema.parse({ - ...rest, - start: splitPoint, - end: wall.end, - curveOffset: curveOffsets[1], - children: [], - }) - - return [first, second] -} - -function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean { - return distanceSquared(a, b) <= tolerance * tolerance -} - -function findWallIntersection( - point: WallPlanPoint, - walls: WallNode[], - radius: number, - ignoreWallIds?: string[], -): WallSplitIntersection | null { - const ignore = new Set(ignoreWallIds ?? []) - let best: WallSplitIntersection | null = null - let bestDistanceSquared = Number.POSITIVE_INFINITY - - for (const wall of walls) { - if (ignore.has(wall.id)) continue - - const projection = projectPointOntoWallDetailed(point, wall) - if (!projection) continue - const { point: projected, wallT } = projection - - const candidateDistanceSquared = distanceSquared(point, projected) - if ( - candidateDistanceSquared > radius * radius || - candidateDistanceSquared >= bestDistanceSquared - ) { - continue - } - - const nearCorner = ([wall.start, wall.end] as WallPlanPoint[]).find( - (corner) => - distanceSquared(projected, corner) <= - WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, - ) - best = nearCorner - ? { wallId: null, point: [nearCorner[0], nearCorner[1]], wallT } - : { wallId: wall.id, point: projected, wallT } - bestDistanceSquared = candidateDistanceSquared - } - - return best -} - -function wallHasAttachments(wall: WallNode, nodes: ReturnType['nodes']) { - if ((wall.children?.length ?? 0) > 0) { - return true - } - - return Object.values(nodes).some((node) => { - if (!node) return false - if ('parentId' in node && node.parentId === wall.id) return true - if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true - return false - }) -} - -function wallLength(wall: WallNode) { - return isCurvedWall(wall) - ? getWallCurveLength(wall) - : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) -} - -function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null { - if (node.type === 'door') { - const door = node as DoorNode - return { - min: door.position[0] - door.width / 2, - max: door.position[0] + door.width / 2, - center: door.position[0], - } - } - - if (node.type === 'window') { - const win = node as WindowNode - return { - min: win.position[0] - win.width / 2, - max: win.position[0] + win.width / 2, - center: win.position[0], - } - } - - if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') { - return null - } - - const [width] = getScaledDimensions(item) - return { - min: item.position[0] - width / 2, - max: item.position[0] + width / 2, - center: item.position[0], - } - } - - return null -} - -function remapAttachmentToWall( - node: AnyNode, - nextWallId: WallNode['id'], - nextLocalX: number, - nextWallLength: number, -): Partial | null { - const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX)) - - if (node.type === 'door' || node.type === 'window' || node.type === 'item') { - const currentPosition = 'position' in node ? node.position : null - if (!currentPosition) return null - - const nextPosition: typeof currentPosition = [ - clampedX, - currentPosition[1], - currentPosition[2], - ] as typeof currentPosition - - return { - parentId: nextWallId, - position: nextPosition, - ...(node.type === 'item' - ? { - wallId: nextWallId, - wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0, - } - : { - wallId: nextWallId, - }), - } as Partial - } - - return null -} - -function buildAttachmentMigrationPlan( - wall: WallNode, - wallT: number, - firstWall: WallNode, - secondWall: WallNode, - nodes: ReturnType['nodes'], -): { id: AnyNodeId; data: Partial }[] | null { - const splitDistance = wallLength(wall) * wallT - const firstLength = wallLength(firstWall) - const secondLength = wallLength(secondWall) - const tolerance = 1e-4 - const updates: { id: AnyNodeId; data: Partial }[] = [] - - for (const childId of wall.children ?? []) { - const childNode = nodes[childId as AnyNodeId] - if (!childNode) continue - - const span = getWallAttachmentSpan(childNode) - if (!span) { - return null - } - - if (span.max <= splitDistance + tolerance) { - const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength) - if (!nextUpdate) return null - updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate }) - continue - } - - if (span.min >= splitDistance - tolerance) { - const nextUpdate = remapAttachmentToWall( - childNode, - secondWall.id, - span.center - splitDistance, - secondLength, - ) - if (!nextUpdate) return null - updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate }) - continue - } - - return null - } - - return updates -} - -function splitWallIfNeeded( - intersection: WallSplitIntersection | null, - walls: WallNode[], - nodes: ReturnType['nodes'], - createNodes: ReturnType['createNodes'], - updateNodes: ReturnType['updateNodes'], - deleteNode: ReturnType['deleteNode'], -): { walls: WallNode[]; point: WallPlanPoint } | null { - if (!intersection) return null - - if (!intersection.wallId) { - return { walls, point: intersection.point } - } - - const wallToSplit = walls.find((wall) => wall.id === intersection.wallId) - if (!wallToSplit) { - return { walls, point: intersection.point } - } - - const [first, second] = splitWallAtPoint(wallToSplit, intersection.point, intersection.wallT) - const attachmentUpdates = buildAttachmentMigrationPlan( - wallToSplit, - intersection.wallT, - first, - second, - nodes, - ) - - if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) { - return { walls, point: intersection.point } - } - - createNodes([ - { node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined }, - { node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined }, - ]) - if (attachmentUpdates && attachmentUpdates.length > 0) { - updateNodes(attachmentUpdates) - } - deleteNode(wallToSplit.id as AnyNodeId) - - return { - walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second], - point: intersection.point, - } -} - /** - * Commit-time split resolution for an endpoint MOVE — the sibling of the - * inline resolution in `createWallOnCurrentLevel`: when a moved endpoint is - * dropped on another wall's interior, split that host exactly like the draw - * path (duplicate props, migrate attachments by span, skip the split when an - * opening straddles the point). Mutates the scene store (create halves / - * migrate attachments / delete host), so callers MUST run it inside the same - * `runAsSingleSceneHistoryStep` as their endpoint write. + * Applies the core endpoint-split plan inside the caller's history step. * * Returns the resolved endpoint (projection onto the host, or a nearby corner - * when the drop is within `WALL_SPLIT_ENDPOINT_EPSILON` of one — corner joins - * are not splits), or `null` when the point lands on no wall. + * when the drop is close enough to one), or `null` when the point lands on no + * wall. */ export function resolveEndpointWallSplit(args: { point: WallPlanPoint @@ -365,14 +78,22 @@ export function resolveEndpointWallSplit(args: { radius?: number }): WallPlanPoint | null { const { point, levelId, ignoreWallIds, radius = WALL_CONNECT_SNAP_RADIUS } = args - const { nodes, createNodes, updateNodes, deleteNode } = useScene.getState() - const walls = Object.values(nodes).filter( - (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId, - ) - - const intersection = findWallIntersection(point, walls, radius, ignoreWallIds) - const split = splitWallIfNeeded(intersection, walls, nodes, createNodes, updateNodes, deleteNode) - return split ? split.point : null + const { nodes, applyNodeChanges } = useScene.getState() + const plan = planWallSplitAtPoint(nodes, { + point, + levelId: levelId as AnyNodeId | null, + ignoreWallIds, + radius, + }) + if (!plan) return null + if ( + plan.changes.create.length > 0 || + plan.changes.update.length > 0 || + plan.changes.delete.length > 0 + ) { + applyNodeChanges(plan.changes) + } + return plan.point } type SnapWallDraftArgs = { @@ -502,121 +223,27 @@ export function createWallOnCurrentLevel( }, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId - const { createNodes, deleteNode, nodes } = useScene.getState() - const { updateNodes } = useScene.getState() + const { nodes, applyNodeChanges } = useScene.getState() if (!(currentLevelId && isSegmentLongEnough(start, end))) { return null } - let workingWalls = Object.values(nodes).filter( - (node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId, - ) - - let resolvedStart = start - let resolvedEnd = end - - // The corner-join / wall-split resolution follows the snapping mode like the - // draft preview does: magnetic ('lines') keeps the generous join radius, - // every other mode uses the same tight connect radius the draft path already - // sticks endpoints with. So an endpoint the user saw connect to a wall body - // actually splits that wall (and redistributes its attachments) in every - // mode, while `'off'` / `'angles'` gain no residual long-range snap. const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS - // One undo step for the whole commit: the split ops (create halves, migrate - // attachments, delete host) plus the new wall each push their own history - // entry, and a single Ctrl-Z must not strand a half-split wall network. return runAsSingleSceneHistoryStep(useScene, () => { - const endIntersection = findWallIntersection(resolvedEnd, workingWalls, joinRadius) - const splitEnd = splitWallIfNeeded( - endIntersection, - workingWalls, - nodes, - createNodes, - updateNodes, - deleteNode, - ) - if (splitEnd) { - workingWalls = splitEnd.walls - resolvedEnd = splitEnd.point - } - - const startIntersection = findWallIntersection(resolvedStart, workingWalls, joinRadius) - const splitStart = splitWallIfNeeded( - startIntersection, - workingWalls, - nodes, - createNodes, - updateNodes, - deleteNode, - ) - if (splitStart) { - workingWalls = splitStart.walls - resolvedStart = splitStart.point - } - - if ( - !isSegmentLongEnough(resolvedStart, resolvedEnd) || - pointsEqual(resolvedStart, resolvedEnd) - ) { - return null - } - - if (wallSegmentsCoverSegment(resolvedStart, resolvedEnd, workingWalls)) { - return null - } - - const interiorEpsilon = 1e-6 - const splitPoints: WallPlanPoint[] = [] - const crossings = findWallSegmentIntersections(resolvedStart, resolvedEnd, workingWalls) - .filter( - (crossing) => crossing.draftT > interiorEpsilon && crossing.draftT < 1 - interiorEpsilon, - ) - .sort((left, right) => left.draftT - right.draftT) - - for (const crossing of crossings) { - if (crossing.wallT > interiorEpsilon && crossing.wallT < 1 - interiorEpsilon) { - const hostIntersection = workingWalls.some((wall) => wall.id === crossing.wallId) - ? { wallId: crossing.wallId, point: crossing.point, wallT: crossing.wallT } - : findWallIntersection(crossing.point, workingWalls, 1e-5) - const splitHost = splitWallIfNeeded( - hostIntersection, - workingWalls, - nodes, - createNodes, - updateNodes, - deleteNode, - ) - if (!splitHost || splitHost.walls.some((wall) => wall.id === crossing.wallId)) { - continue - } - workingWalls = splitHost.walls - } - - if (!splitPoints.some((point) => pointsEqual(point, crossing.point))) { - splitPoints.push(crossing.point) - } - } - - const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length - // A placed wall preset seeds `toolDefaults.wall` (thickness, height, - // materials, sides) before the tool activates; merge those first so the - // drawn wall reproduces the preset. Identity + endpoints always win. - const defaults = useEditor.getState().toolDefaults.wall ?? {} - const vertices = [resolvedStart, ...splitPoints, resolvedEnd] - const walls = vertices.slice(0, -1).map((segmentStart, index) => - WallSchema.parse({ - ...defaults, - name: `Wall ${wallCount + index + 1}`, - start: segmentStart, - end: vertices[index + 1]!, - }), - ) + const plan = planWallInsertion(nodes, { + levelId: currentLevelId as AnyNodeId, + start, + end, + joinRadius, + wallDefaults: useEditor.getState().toolDefaults.wall ?? {}, + }) + if (!plan) return null - createNodes(walls.map((wall) => ({ node: wall, parentId: currentLevelId }))) + applyNodeChanges(plan.changes) const committedNodes = useScene.getState().nodes - const supportUpdates = walls.flatMap((wall) => { + const supportUpdates = plan.insertedWalls.flatMap((wall) => { const createdWall = committedNodes[wall.id] if (createdWall?.type !== 'wall') return [] return [ @@ -633,8 +260,8 @@ export function createWallOnCurrentLevel( } sfxEmitter.emit('sfx:structure-build') - const terminalWall = walls.at(-1)! - const committedWall = useScene.getState().nodes[terminalWall.id] + const terminalWall = plan.insertedWalls.at(-1)! + const committedWall = useScene.getState().nodes[plan.terminalWallId] return committedWall?.type === 'wall' ? committedWall : terminalWall }) } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index bf3052255..1c72cad0c 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -3,7 +3,12 @@ // (building-local meters). `wall-drafting.ts` layers grid/angle snapping and // scene access on top of these primitives. -import { getWallArcData, getWallCurveFrameAt, isCurvedWall, type WallNode } from '@pascal-app/core' +import { + getWallCurveFrameAt, + isCurvedWall, + projectPointOntoWallCenterline, + type WallNode, +} from '@pascal-app/core' export type WallPlanPoint = [number, number] @@ -57,46 +62,7 @@ export function projectPointOntoWallDetailed( point: WallPlanPoint, wall: WallNode, ): WallProjection | null { - if (isCurvedWall(wall)) { - const arc = getWallArcData(wall) - if (!arc) return null - - const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) - const direction = Math.sign(arc.delta) - let directedAngle = (pointAngle - arc.startAngle) * direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - - const totalAngle = Math.abs(arc.delta) - const wallT = directedAngle / totalAngle - if (wallT <= 0 || wallT >= 1) { - return null - } - - const frame = getWallCurveFrameAt(wall, wallT) - return { - point: [frame.point.x, frame.point.y], - wallT, - } - } - - const [x1, z1] = wall.start - const [x2, z2] = wall.end - const dx = x2 - x1 - const dz = z2 - z1 - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-9) { - return null - } - - const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared - if (t <= 0 || t >= 1) { - return null - } - - return { - point: [x1 + dx * t, z1 + dz * t], - wallT: t, - } + return projectPointOntoWallCenterline(point, wall) } export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { @@ -233,117 +199,6 @@ function segmentIntersection( } } -function curvedWallSegmentIntersections(start: WallPlanPoint, end: WallPlanPoint, wall: WallNode) { - const arc = getWallArcData(wall) - if (!arc) return [] - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const offsetX = start[0] - arc.center.x - const offsetZ = start[1] - arc.center.y - const a = dx * dx + dz * dz - if (a < 1e-12) return [] - const b = 2 * (offsetX * dx + offsetZ * dz) - const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius - const discriminant = b * b - 4 * a * c - if (discriminant < -1e-9) return [] - - const root = Math.sqrt(Math.max(0, discriminant)) - const draftParameters = [(-b - root) / (2 * a), (-b + root) / (2 * a)] - const intersections: Array<{ point: WallPlanPoint; draftT: number; wallT: number }> = [] - for (const draftT of draftParameters) { - if (draftT < -1e-9 || draftT > 1 + 1e-9) continue - const point: WallPlanPoint = [start[0] + draftT * dx, start[1] + draftT * dz] - const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) - let directedAngle = (angle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - const wallT = directedAngle / Math.abs(arc.delta) - if (wallT < -1e-9 || wallT > 1 + 1e-9) continue - if (intersections.some((candidate) => distanceSquared(candidate.point, point) < 1e-12)) { - continue - } - intersections.push({ - point, - draftT: Math.max(0, Math.min(1, draftT)), - wallT: Math.max(0, Math.min(1, wallT)), - }) - } - return intersections -} - -export function findWallSegmentIntersections( - start: WallPlanPoint, - end: WallPlanPoint, - walls: WallNode[], -): Array<{ wallId: WallNode['id']; point: WallPlanPoint; draftT: number; wallT: number }> { - const intersections: Array<{ - wallId: WallNode['id'] - point: WallPlanPoint - draftT: number - wallT: number - }> = [] - - for (const wall of walls) { - if (isCurvedWall(wall)) { - for (const intersection of curvedWallSegmentIntersections(start, end, wall)) { - intersections.push({ wallId: wall.id, ...intersection }) - } - continue - } - const intersection = segmentIntersection(start, end, wall.start, wall.end) - if (!intersection) continue - intersections.push({ - wallId: wall.id, - point: intersection.point, - draftT: intersection.firstT, - wallT: intersection.secondT, - }) - } - - return intersections -} - -export function wallSegmentsCoverSegment( - start: WallPlanPoint, - end: WallPlanPoint, - walls: WallNode[], - tolerance = 1e-6, -): boolean { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const lengthSquared = dx * dx + dz * dz - if (lengthSquared <= tolerance * tolerance) return false - - const length = Math.sqrt(lengthSquared) - const intervals: Array<[number, number]> = [] - for (const wall of walls) { - if (isCurvedWall(wall)) continue - const startDistance = - Math.abs((wall.start[0] - start[0]) * dz - (wall.start[1] - start[1]) * dx) / length - const endDistance = - Math.abs((wall.end[0] - start[0]) * dz - (wall.end[1] - start[1]) * dx) / length - if (startDistance > tolerance || endDistance > tolerance) continue - - const wallStartT = - ((wall.start[0] - start[0]) * dx + (wall.start[1] - start[1]) * dz) / lengthSquared - const wallEndT = ((wall.end[0] - start[0]) * dx + (wall.end[1] - start[1]) * dz) / lengthSquared - const intervalStart = Math.max(0, Math.min(wallStartT, wallEndT)) - const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT)) - if (intervalEnd >= intervalStart) { - intervals.push([intervalStart, intervalEnd]) - } - } - - intervals.sort((left, right) => left[0] - right[0]) - const parameterTolerance = tolerance / length - let coveredUntil = 0 - for (const [intervalStart, intervalEnd] of intervals) { - if (intervalStart > coveredUntil + parameterTolerance) return false - coveredUntil = Math.max(coveredUntil, intervalEnd) - if (coveredUntil >= 1 - parameterTolerance) return true - } - return false -} - /** * Nearest point where two existing straight walls cross, within * `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 5d3d95266..09946a638 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -1,12 +1,10 @@ 'use client' import { - clearSceneHistory, + applySceneSnapshot, + createDefaultSceneSnapshot, nodeRegistry, - notifySceneCommit, - pauseSceneHistory, resolveLevelId, - resumeSceneHistory, type SceneSnapshot, sceneRegistry, useScene, @@ -386,38 +384,22 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } -function currentSceneSnapshot(): SceneSnapshot { - const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState() - return { nodes, rootNodeIds, collections, materials, installedPlugins } -} - export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() - const before = currentSceneSnapshot() - pauseSceneHistory(useScene) - try { - if (hasUsableSceneGraph(sceneGraph)) { - const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any, { - collections: collections as any, - materials: materials as any, - installedPlugins: installedPlugins ?? defaultInstalledPlugins, - hasExplicitPluginInstallState: installedPlugins !== undefined, - }) - } else { - useScene.getState().clearScene() - useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false }) - } - } finally { - resumeSceneHistory(useScene) - } + const hasSceneGraph = hasUsableSceneGraph(sceneGraph) + const snapshot: SceneSnapshot = hasSceneGraph + ? { + nodes: sceneGraph.nodes as SceneSnapshot['nodes'], + rootNodeIds: sceneGraph.rootNodeIds as SceneSnapshot['rootNodeIds'], + collections: (sceneGraph.collections ?? {}) as SceneSnapshot['collections'], + materials: (sceneGraph.materials ?? {}) as SceneSnapshot['materials'], + installedPlugins: sceneGraph.installedPlugins ?? defaultInstalledPlugins, + } + : createDefaultSceneSnapshot(defaultInstalledPlugins) - // The loaded scene is the undo floor; discard history from the previous scene. - clearSceneHistory() - notifySceneCommit({ + applySceneSnapshot(snapshot, { origin: 'load', - before, - current: currentSceneSnapshot(), + hasExplicitPluginInstallState: hasSceneGraph && sceneGraph.installedPlugins !== undefined, }) syncEditorSelectionFromCurrentScene() From 7e6bd1dbb453a060ab61eb3204d351ec9740a18f Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 01:59:39 +0530 Subject: [PATCH 11/14] fix wall topology review regressions --- .../src/systems/wall/wall-topology.test.ts | 70 +++++++++++++++++++ .../core/src/systems/wall/wall-topology.ts | 26 ++++++- .../src/components/editor/floorplan-panel.tsx | 1 + .../tools/wall/wall-drafting.test.ts | 10 +++ .../components/tools/wall/wall-drafting.ts | 4 +- 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/core/src/systems/wall/wall-topology.test.ts b/packages/core/src/systems/wall/wall-topology.test.ts index b0efd8875..09524a8b8 100644 --- a/packages/core/src/systems/wall/wall-topology.test.ts +++ b/packages/core/src/systems/wall/wall-topology.test.ts @@ -10,6 +10,24 @@ function nodeMap(nodes: AnyNode[]) { } describe('planWallInsertion', () => { + test('rejects a covered draft before producing any host split changes', () => { + const host = WallNode.parse({ + id: 'wall_covered', + parentId: LEVEL_ID, + start: [0, 0], + end: [4, 0], + }) + + const plan = planWallInsertion(nodeMap([host]), { + levelId: LEVEL_ID, + start: [0, 0], + end: [4, 0], + joinRadius: 0.05, + }) + + expect(plan).toBeNull() + }) + test('returns one atomic plan for host splits and inserted wall segments', () => { const horizontal = WallNode.parse({ id: 'wall_horizontal', @@ -34,6 +52,28 @@ describe('planWallInsertion', () => { expect(plan?.insertedWalls[1]?.start).toEqual([2, 0]) }) + test('joins at a nearby host endpoint without minting a sliver wall', () => { + const host = WallNode.parse({ + id: 'wall_near_endpoint', + parentId: LEVEL_ID, + start: [2, -0.005], + end: [2, 2], + }) + + const plan = planWallInsertion(nodeMap([host]), { + levelId: LEVEL_ID, + start: [-2, 0], + end: [4, 0], + joinRadius: 0.05, + }) + + expect(plan).not.toBeNull() + expect(plan?.changes.delete).not.toContain(host.id) + expect(plan?.insertedWalls).toHaveLength(2) + expect(plan?.insertedWalls[0]?.end).toEqual(host.start) + expect(plan?.insertedWalls[1]?.start).toEqual(host.start) + }) + test('splits one curved host at every crossing without exposing intermediate mutations', () => { const curved = WallNode.parse({ id: 'wall_curved', @@ -93,6 +133,36 @@ describe('planWallInsertion', () => { expect(newParent?.node.type === 'wall' ? newParent.node.children : []).toContain(door.id) }) + test('splits the inserted wall when an opening prevents splitting the crossed host', () => { + const door = DoorNode.parse({ + id: 'door_crossing', + parentId: 'wall_crossing_blocked', + wallId: 'wall_crossing_blocked', + position: [2, 0, 0], + width: 1, + }) + const host = WallNode.parse({ + id: 'wall_crossing_blocked', + parentId: LEVEL_ID, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + + const plan = planWallInsertion(nodeMap([host, door]), { + levelId: LEVEL_ID, + start: [2, -2], + end: [2, 2], + joinRadius: 0.05, + }) + + expect(plan).not.toBeNull() + expect(plan?.changes.delete).not.toContain(host.id) + expect(plan?.insertedWalls).toHaveLength(2) + expect(plan?.insertedWalls[0]?.end).toEqual([2, 0]) + expect(plan?.insertedWalls[1]?.start).toEqual([2, 0]) + }) + test('resolves onto a host but refuses to split through an opening', () => { const door = DoorNode.parse({ id: 'door_straddling', diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index b9990359e..19dad6545 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -259,6 +259,28 @@ function nearestWallProjection( return best } +function joinCrossingAtNearbyWallEndpoint( + crossing: WallSegmentIntersection, + walls: WallNode[], +): WallSegmentIntersection { + const wall = walls.find((candidate) => candidate.id === crossing.wallId) + if (!wall) return crossing + + const endpointIndex = ([wall.start, wall.end] as WallPlanPoint[]).findIndex( + (endpoint) => + distanceSquared(crossing.point, endpoint) <= + WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, + ) + if (endpointIndex < 0) return crossing + + const endpoint = endpointIndex === 0 ? wall.start : wall.end + return { + ...crossing, + point: [endpoint[0], endpoint[1]], + wallT: endpointIndex, + } +} + function wallLength(wall: WallNode) { return isCurvedWall(wall) ? getWallCurveLength(wall) @@ -483,6 +505,7 @@ export function planWallInsertion( } const crossings = findWallSegmentIntersections(resolvedStart, resolvedEnd, walls) + .map((crossing) => joinCrossingAtNearbyWallEndpoint(crossing, walls)) .filter( ({ draftT }) => draftT > WALL_INTERSECTION_EPSILON && draftT < 1 - WALL_INTERSECTION_EPSILON, ) @@ -507,9 +530,6 @@ export function planWallInsertion( const splitPoints: WallPlanPoint[] = [] for (const crossing of crossings) { - const doesNotNeedHostSplit = - crossing.wallT <= WALL_INTERSECTION_EPSILON || crossing.wallT >= 1 - WALL_INTERSECTION_EPSILON - if (!(doesNotNeedHostSplit || splitPlans.has(crossing.wallId))) continue if (!splitPoints.some((point) => pointsEqual(point, crossing.point))) { splitPoints.push(crossing.point) } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f18570402..e5b6897ca 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9768,6 +9768,7 @@ export function FloorplanPanel({ shouldStopWallDraftAfterCommit({ locallyCreatedWall: createdWall, publishedNextStart, + locallyOwnsCommit: viewIs2DOnly, }) ) { // The mounted wall tool owns both the commit and the continuation diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 41b25a5cc..8b7575035 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -64,6 +64,16 @@ describe('shouldStopWallDraftAfterCommit', () => { }), ).toBe(false) }) + + test('keeps the 2D chain alive when its local commit is rejected', () => { + const rejectedLocalCommit = { + locallyCreatedWall: null, + publishedNextStart: null, + locallyOwnsCommit: true, + } + + expect(shouldStopWallDraftAfterCommit(rejectedLocalCommit)).toBe(false) + }) }) function makeWall(start: WallPlanPoint, end: WallPlanPoint, id: string): WallNode { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index ad0f5b58d..113e27e23 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -200,11 +200,13 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b export function shouldStopWallDraftAfterCommit({ locallyCreatedWall, publishedNextStart, + locallyOwnsCommit = false, }: { locallyCreatedWall: WallNode | null publishedNextStart: WallPlanPoint | null + locallyOwnsCommit?: boolean }): boolean { - return locallyCreatedWall === null && publishedNextStart === null + return !locallyOwnsCommit && locallyCreatedWall === null && publishedNextStart === null } export function createWallOnCurrentLevel( From 092ab8181bd02ec31c8125256de6f4c84b5d07e9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 11:41:05 +0530 Subject: [PATCH 12/14] fix: prevent duplicate 2d ceiling creation --- .../nodes/src/ceiling/placement-ownership.test.ts | 14 ++++++++++++++ packages/nodes/src/ceiling/placement-ownership.ts | 3 +++ packages/nodes/src/ceiling/tool.tsx | 13 +++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 packages/nodes/src/ceiling/placement-ownership.test.ts create mode 100644 packages/nodes/src/ceiling/placement-ownership.ts diff --git a/packages/nodes/src/ceiling/placement-ownership.test.ts b/packages/nodes/src/ceiling/placement-ownership.test.ts new file mode 100644 index 000000000..ceabb0a6d --- /dev/null +++ b/packages/nodes/src/ceiling/placement-ownership.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { shouldRegistryCommitCeiling } from './placement-ownership' + +function ceilingCreatorCount(viewMode: '2d' | '3d' | 'split'): number { + const floorplanCommits = viewMode === '2d' + const registryCommits = shouldRegistryCommitCeiling(viewMode) + return Number(floorplanCommits) + Number(registryCommits) +} + +describe('ceiling placement ownership', () => { + test.each(['2d', '3d', 'split'] as const)('commits one ceiling in %s', (viewMode) => { + expect(ceilingCreatorCount(viewMode)).toBe(1) + }) +}) diff --git a/packages/nodes/src/ceiling/placement-ownership.ts b/packages/nodes/src/ceiling/placement-ownership.ts new file mode 100644 index 000000000..a4d10e33f --- /dev/null +++ b/packages/nodes/src/ceiling/placement-ownership.ts @@ -0,0 +1,3 @@ +export function shouldRegistryCommitCeiling(viewMode: '2d' | '3d' | 'split'): boolean { + return viewMode !== '2d' +} diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index df6f85e56..ae3639eb6 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -25,6 +25,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { mix, positionLocal } from 'three/tsl' +import { shouldRegistryCommitCeiling } from './placement-ownership' import { CeilingNode } from './schema' /** @@ -160,8 +161,10 @@ export const CeilingTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) + if (shouldRegistryCommitCeiling(useEditor.getState().viewMode)) { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + } setPoints([]) clearCeilingSnapFeedback() } else { @@ -175,8 +178,10 @@ export const CeilingTool: React.FC = () => { const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return if (points.length >= 3) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) + if (shouldRegistryCommitCeiling(useEditor.getState().viewMode)) { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + } setPoints([]) clearCeilingSnapFeedback() } From 58808f913356d048ee75f1f7109895b8a0ca09b0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 12:30:10 +0530 Subject: [PATCH 13/14] fix scene refresh framing and wall junction snapping --- .../editor/src/components/editor/index.tsx | 4 +- .../tools/wall/wall-drafting.test.ts | 58 +++++++++++++++++++ .../components/tools/wall/wall-drafting.ts | 15 +++-- .../tools/wall/wall-snap-geometry.ts | 42 ++++++++++++++ packages/editor/src/hooks/use-auto-frame.ts | 45 -------------- packages/editor/src/lib/scene-bounds.ts | 8 +-- packages/editor/src/lib/scene.test.ts | 37 ++++++++++++ packages/editor/src/lib/scene.ts | 12 +++- wiki/architecture/tools.md | 8 ++- 9 files changed, 170 insertions(+), 59 deletions(-) delete mode 100644 packages/editor/src/hooks/use-auto-frame.ts diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 8d6e56c81..ec9c5105a 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1201,13 +1201,13 @@ export default function Editor({ try { const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() if (!cancelled) { - applySceneGraphToEditor(sceneGraph) + applySceneGraphToEditor(sceneGraph, { fitCamera: true }) setIsViewerSceneReady(false) setSceneReadyKey((key) => key + 1) } } catch { if (!cancelled) { - applySceneGraphToEditor(null) + applySceneGraphToEditor(null, { fitCamera: true }) setIsViewerSceneReady(false) setSceneReadyKey((key) => key + 1) } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 8b7575035..c6b1d5f9b 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -219,6 +219,28 @@ describe('createWallOnCurrentLevel', () => { expect(levelWalls()).toHaveLength(2) }) + test('grid mode: a nearby multi-wall junction does not produce a micro room', () => { + useEditor.getState().setSnappingMode('wall', 'grid') + const junction: WallPlanPoint = [-1.409_952_606_635_071_6, 1.350_710_900_473_934] + seedLevel([ + makeWall([-4.829_297_820_823_244, 3.503_631_961_259_08], junction, 'wall_upper'), + makeWall(junction, [2.002_421_307_506_052_6, -0.797_820_823_244_551_4], 'wall_lower'), + ]) + const snapResult = snapWallDraftPointDetailed({ + point: [-1.5, 1.5], + walls: levelWalls(), + magnetic: false, + step: 0.5, + }) + + const created = createWallOnCurrentLevel([-1.5, -3], snapResult.point) + const { roomPolygons } = detectSpacesForLevel(String(LEVEL_ID), levelWalls()) + + expect(created?.end).toEqual(junction) + expect(levelWalls()).toHaveLength(3) + expect(roomPolygons).toHaveLength(0) + }) + test('mid-span split migrates the host attachments to the covering half', () => { const door = DoorSchema.parse({ position: [1, 1.05, 0], @@ -711,6 +733,42 @@ describe('snapWallDraftPointDetailed', () => { expect(result.snap).toBeNull() }) + test('grid snapping prefers a nearby multi-wall junction over creating a micro room', () => { + const junction: WallPlanPoint = [-1.409_952_606_635_071_6, 1.350_710_900_473_934] + const walls = [ + makeWall([-4.829_297_820_823_244, 3.503_631_961_259_08], junction, 'wall_upper'), + makeWall(junction, [2.002_421_307_506_052_6, -0.797_820_823_244_551_4], 'wall_lower'), + ] + + const result = snapWallDraftPointDetailed({ + point: [-1.5, 1.5], + walls, + magnetic: false, + step: 0.5, + }) + + expect(result.point).toEqual(junction) + expect(result.snap).toBe('intersection') + }) + + test('grid snapping does not widen capture for an ordinary wall endpoint', () => { + const result = snapWallDraftPointDetailed({ + point: [-1.5, 1.5], + walls: [ + makeWall( + [-4, 1.350_710_900_473_934], + [-1.409_952_606_635_071_6, 1.350_710_900_473_934], + 'wall_a', + ), + ], + magnetic: false, + step: 0.5, + }) + + expect(result.point).toEqual([-1.5, 1.5]) + expect(result.snap).toBeNull() + }) + // Endpoint-move regression: walls attached to the moving corner keep their // pre-drag coordinates in the scene during the drag, so their stale corner // recreates the old junction inside the connect radius. The move tools must diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 113e27e23..c279cc995 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -15,6 +15,8 @@ import { resolveSnapFlags } from '../../../lib/snapping-mode' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import { distanceSquared, + findWallIntersectionFromRaw, + findWallJunctionFromRaw, findWallSnapTarget, findWallSpecialPointSnap, WALL_CONNECT_SNAP_RADIUS, @@ -145,6 +147,11 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn if (magnetic) { const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii) if (special) return special + } else { + const intersection = + findWallJunctionFromRaw(point, walls, ignoreWallIds) ?? + findWallIntersectionFromRaw(point, walls, ignoreWallIds) + if (intersection) return { point: intersection, snap: 'intersection' } } const step = overrideStep ?? getSegmentGridStep() @@ -168,10 +175,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn } // Non-magnetic modes (grid / off / angles): connectivity still sticks so a - // room can close, but only within a tight radius — placement elsewhere is left - // to the mode (grid quantise / angle lock / free). Snap from the already - // positioned `basePoint` so the mode's placement is respected right up to the - // wall, then the last few cm stick onto it (and the beacon shows). + // room can close. Multi-wall intersections are captured from the raw cursor + // above so a grid or angle constraint cannot route a draft through both sides + // of a nearby junction and create a micro room. Ordinary endpoints, midpoints, + // and wall bodies keep the tight radius below, preserving the mode's placement. const connectRadii: WallSnapRadii = { endpoint: WALL_CONNECT_SNAP_RADIUS, midpoint: WALL_CONNECT_SNAP_RADIUS, diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 1c72cad0c..5c6b26c9c 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -139,6 +139,48 @@ export function findWallEndpointFromRaw( return best } +/** Nearest endpoint shared by at least two walls. */ +export function findWallJunctionFromRaw( + point: WallPlanPoint, + walls: WallNode[], + ignoreWallIds?: string[], + radius = WALL_INTERSECTION_SNAP_RADIUS, +): WallPlanPoint | null { + const ignored = new Set(ignoreWallIds ?? []) + const candidates = walls + .filter((wall) => !ignored.has(wall.id)) + .flatMap((wall) => + ([wall.start, wall.end] as WallPlanPoint[]).map((endpoint) => ({ + endpoint, + wallId: wall.id, + })), + ) + const radiusSquared = radius * radius + const joinToleranceSquared = WALL_CHAIN_JOIN_TOLERANCE * WALL_CHAIN_JOIN_TOLERANCE + let best: WallPlanPoint | null = null + let bestDistSq = Number.POSITIVE_INFINITY + + for (const candidate of candidates) { + if ( + !candidates.some( + (other) => + other.wallId !== candidate.wallId && + distanceSquared(candidate.endpoint, other.endpoint) <= joinToleranceSquared, + ) + ) { + continue + } + + const candidateDistanceSquared = distanceSquared(point, candidate.endpoint) + if (candidateDistanceSquared <= radiusSquared && candidateDistanceSquared < bestDistSq) { + best = candidate.endpoint + bestDistSq = candidateDistanceSquared + } + } + + return best +} + /** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */ function wallMidpoint(wall: WallNode): WallPlanPoint { if (isCurvedWall(wall)) { diff --git a/packages/editor/src/hooks/use-auto-frame.ts b/packages/editor/src/hooks/use-auto-frame.ts deleted file mode 100644 index 6b20238fb..000000000 --- a/packages/editor/src/hooks/use-auto-frame.ts +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import { emitter, useScene } from '@pascal-app/core' -import { useEffect, useRef } from 'react' -import { computeSceneBoundsXZ } from '../lib/scene-bounds' - -/** - * Auto-frame the camera onto a freshly loaded scene. - * - * Motivation: when the MCP `setScene` tool (or any other entry point) swaps - * the scene graph while the default camera is pointing at empty space, the - * user sees a black viewport. This hook subscribes to the core scene store - * and, whenever `nodes` transitions from empty → non-empty, computes the - * XZ bounds of the new scene and emits `camera-controls:fit-scene`. The - * `` component picks up that event and frames the - * camera onto the bounds. - * - * Mount in exactly ONE component (the Editor). It holds no state of its own; - * the subscription is torn down on unmount. - */ -export function useAutoFrame(): void { - // Track the previous node count so we can detect the empty → non-empty edge. - const wasEmptyRef = useRef(true) - - useEffect(() => { - // Initialise from current store state so a remount after a setScene - // doesn't re-frame an already-populated scene. - wasEmptyRef.current = Object.keys(useScene.getState().nodes).length === 0 - - const unsubscribe = useScene.subscribe((state) => { - const isEmpty = Object.keys(state.nodes).length === 0 - const wasEmpty = wasEmptyRef.current - wasEmptyRef.current = isEmpty - - // Only react to empty → non-empty transitions. Normal edits keep both - // flags false; a `clearScene()` goes non-empty → empty and is ignored. - if (!wasEmpty || isEmpty) return - - const bounds = computeSceneBoundsXZ(state.nodes) - emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {}) - }) - - return unsubscribe - }, []) -} diff --git a/packages/editor/src/lib/scene-bounds.ts b/packages/editor/src/lib/scene-bounds.ts index c2b749a8d..a50d41442 100644 --- a/packages/editor/src/lib/scene-bounds.ts +++ b/packages/editor/src/lib/scene-bounds.ts @@ -1,11 +1,9 @@ /** * Scene bounds in the X/Z plane. * - * Used by the auto-frame hook to fit the camera onto a freshly loaded scene - * (see `../hooks/use-auto-frame`). The hook subscribes to the core scene - * store and, when `nodes` transitions from empty → non-empty, fires a - * `camera-controls:fit-scene` event on the core event bus carrying the - * computed bounds. + * Used by initial scene loading to fit the camera onto freshly loaded geometry. + * The loader emits `camera-controls:fit-scene` on the core event bus carrying + * the computed bounds. * * This module contains no rendering code: it only walks the flat-dict node * tree and derives an axis-aligned bounding box on the XZ (plan) plane. diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts index 6614d137b..794f898ef 100644 --- a/packages/editor/src/lib/scene.test.ts +++ b/packages/editor/src/lib/scene.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, test } from 'bun:test' import { BuildingNode, + type CameraControlFitSceneEvent, + emitter, initSpaceDetectionSync, LevelNode, SiteNode, @@ -72,4 +74,39 @@ describe('applySceneGraphToEditor', () => { unsubscribe() } }) + + test('initial load requests a camera fit for the refreshed 3D viewport', () => { + let fitBounds: CameraControlFitSceneEvent['bounds'] + const handleFitScene = (event: CameraControlFitSceneEvent) => { + fitBounds = event.bounds + } + + emitter.on('camera-controls:fit-scene', handleFitScene) + try { + applySceneGraphToEditor(loadedRoomWithoutSurfaces(), { fitCamera: true }) + expect(fitBounds).toEqual({ + min: [0, 0], + max: [4, 3], + center: [2, 1.5], + size: [4, 3], + }) + } finally { + emitter.off('camera-controls:fit-scene', handleFitScene) + } + }) + + test('ordinary scene application does not reframe an active camera', () => { + let fitEventCount = 0 + const handleFitScene = () => { + fitEventCount += 1 + } + + emitter.on('camera-controls:fit-scene', handleFitScene) + try { + applySceneGraphToEditor(loadedRoomWithoutSurfaces()) + expect(fitEventCount).toBe(0) + } finally { + emitter.off('camera-controls:fit-scene', handleFitScene) + } + }) }) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 09946a638..5b558aa81 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -3,6 +3,7 @@ import { applySceneSnapshot, createDefaultSceneSnapshot, + emitter, nodeRegistry, resolveLevelId, type SceneSnapshot, @@ -16,6 +17,7 @@ import useEditor, { type PersistedEditorUiState, } from '../store/use-editor' import { editorHostPanelRegistry } from './plugin-panels' +import { computeSceneBoundsXZ } from './scene-bounds' export type SceneGraph = { nodes: Record @@ -384,7 +386,10 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } -export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { +export function applySceneGraphToEditor( + sceneGraph?: SceneGraph | null, + options: { fitCamera?: boolean } = {}, +) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() const hasSceneGraph = hasUsableSceneGraph(sceneGraph) const snapshot: SceneSnapshot = hasSceneGraph @@ -403,6 +408,11 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { }) syncEditorSelectionFromCurrentScene() + + if (options.fitCamera) { + const bounds = hasSceneGraph ? computeSceneBoundsXZ(snapshot.nodes) : null + emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {}) + } } const LOCAL_STORAGE_KEY = 'pascal-editor-scene' diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 883c5b467..c8f3b5568 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -94,8 +94,12 @@ export function MyTool() { existing wall's endpoint / midpoint / crossing / body, the drafted point sticks onto it (and the beacon shows). This is *connectivity*, not alignment — the snap runs from the already mode-positioned point, so grid quantise / angle lock / free placement are respected right up to - the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on - modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. + the wall and only the last few cm stick. A multi-wall intersection is the narrow exception to + that radius: it is captured from the raw cursor with the standard intersection radius so a grid + or angle constraint cannot pass through both branches beside the junction and create a micro + room. Ordinary single-wall endpoints and bodies remain on the 0.05 m rule. It is **not** a Shift + bypass and must not be gated on modifiers. See `snapWallDraftPointDetailed` in + `components/tools/wall/wall-drafting.ts`. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained From f018494f459ecca6da5263d55fdec09236d72b83 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 30 Jul 2026 13:31:47 +0530 Subject: [PATCH 14/14] fix space sync boundary and snapping --- .../core/src/lib/space-detection-undo.test.ts | 20 +++--- packages/core/src/lib/space-detection.test.ts | 68 ++++++++++--------- packages/core/src/lib/space-detection.ts | 42 ++++++++---- .../editor/src/components/editor/index.tsx | 4 +- .../tools/wall/wall-drafting.test.ts | 12 +++- .../components/tools/wall/wall-drafting.ts | 8 ++- packages/editor/src/lib/scene.test.ts | 4 +- 7 files changed, 93 insertions(+), 65 deletions(-) diff --git a/packages/core/src/lib/space-detection-undo.test.ts b/packages/core/src/lib/space-detection-undo.test.ts index 866aff0fe..c3c128191 100644 --- a/packages/core/src/lib/space-detection-undo.test.ts +++ b/packages/core/src/lib/space-detection-undo.test.ts @@ -12,14 +12,14 @@ type RafFn = (cb: (time: number) => void) => number ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {} -function editorStoreStub() { +function spacesSink() { const state = { spaces: {} as Record, - setSpaces(spaces: Record) { + onSpacesChanged(spaces: Record) { state.spaces = spaces }, } - return { getState: () => state } + return state } function resetRoom() { @@ -68,11 +68,13 @@ describe('space topology index undo and redo', () => { test('rebuilds its disposable room lookup after undo and redo', async () => { resetRoom() - const editorStore = editorStoreStub() - const unsubscribe = initSpaceDetectionSync(useScene, editorStore) + const sink = spacesSink() + const unsubscribe = initSpaceDetectionSync(useScene, { + onSpacesChanged: sink.onSpacesChanged, + }) try { - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(1) + expect(Object.keys(sink.spaces)).toHaveLength(1) useScene.getState().createNode( WallNode.parse({ @@ -83,15 +85,15 @@ describe('space topology index undo and redo', () => { }), 'level_0', ) - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(2) + expect(Object.keys(sink.spaces)).toHaveLength(2) useScene.temporal.getState().undo() await Promise.resolve() - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(1) + expect(Object.keys(sink.spaces)).toHaveLength(1) useScene.temporal.getState().redo() await Promise.resolve() - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(2) + expect(Object.keys(sink.spaces)).toHaveLength(2) } finally { unsubscribe() } diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 24fe7b735..e42a18d86 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -15,6 +15,7 @@ import { planAutoSlabsForLevel, planAutoZonesForLevel, resolveAutoZonePolygon, + type Space, wallClosesRoom, } from './space-detection' @@ -487,7 +488,7 @@ describe('stage 3-B ceiling clamp bound', () => { // Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped // scene store (getState/subscribe/temporal) whose write methods mutate the -// nodes record and re-notify, and an editor store carrying `spaces`. +// nodes record and re-notify, and a sink for derived spaces. function createSceneStoreStub(initialNodes: Record) { const listeners = new Set<(state: unknown) => void>() const state: Record & { nodes: Record } = { @@ -555,14 +556,14 @@ function createSceneStoreStub(initialNodes: Record) { } } -function createEditorStoreStub() { - const state = { - spaces: {} as Record, - setSpaces(next: Record) { - state.spaces = next +function createSpacesSink() { + const sink = { + spaces: {} as Record, + onSpacesChanged(next: Record) { + sink.spaces = next }, } - return { getState: () => state } + return sink } function roomWithGeneratedSurfaces() { @@ -850,7 +851,7 @@ function fourRoomGridWithSplitA() { describe('generated surface deletion through the detection sync', () => { test('a deleted generated slab stays deleted', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const { slab_main: _deleted, ...withoutSlab } = sceneStore.getState().nodes @@ -869,7 +870,7 @@ describe('generated surface deletion through the detection sync', () => { test('a deleted generated ceiling stays deleted after the next wall edit', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const { ceiling_main: _deleted, ...withoutCeiling } = sceneStore.getState().nodes @@ -893,7 +894,7 @@ describe('generated surface deletion through the detection sync', () => { test('a reshaped room keeps its missing surfaces after the sync is reinitialized', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) - let unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + let unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) const { slab_main: _slab, @@ -903,7 +904,7 @@ describe('generated surface deletion through the detection sync', () => { sceneStore.setNodes(withoutSurfaces) unsubscribe() - unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes sceneStore.setNodes({ @@ -929,7 +930,7 @@ describe('generated surface deletion through the detection sync', () => { const initial = roomWithGeneratedSurfaces() delete initial.slab_main const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -963,7 +964,7 @@ describe('generated surface deletion through the detection sync', () => { ...withoutLeftSurfaces } = initialNodes const sceneStore = createSceneStoreStub(withoutLeftSurfaces) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1005,7 +1006,7 @@ describe('generated surface deletion through the detection sync', () => { test('loading an older project does not generate its missing surfaces', () => { const sceneStore = createSceneStoreStub({}) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) const loaded = roomWithGeneratedSurfaces() delete loaded.slab_main delete loaded.ceiling_main @@ -1029,21 +1030,22 @@ describe('generated surface deletion through the detection sync', () => { describe('topology-delta room surface reconciliation', () => { test('indexes only room A while its divider is deleted, recreated, and moved', () => { const sceneStore = createSceneStoreStub(fourRoomGridWithSplitA()) - const editorStore = createEditorStoreStub() + const sink = createSpacesSink() const events: Array<{ strategy: string examinedWallIds: string[] affectedBeforeRoomCount: number affectedCurrentRoomCount: number }> = [] - const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore, { + const unsubscribe = initSpaceDetectionSync(sceneStore, { + onSpacesChanged: sink.onSpacesChanged, onTopologyReconcile: (event) => events.push(event), }) const distantSlabBefore = sceneStore.getState().nodes.slab_d const distantCeilingBefore = sceneStore.getState().nodes.ceiling_d try { - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + expect(Object.keys(sink.spaces)).toHaveLength(5) const current = sceneStore.getState().nodes const splitWall = current.wall_a_split as WallNode @@ -1061,7 +1063,7 @@ describe('topology-delta room surface reconciliation', () => { new Set([splitWall.id as AnyNodeId]), ) - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(4) + expect(Object.keys(sink.spaces)).toHaveLength(4) expect(events.at(-1)?.strategy).toBe('indexed') expect(events.at(-1)?.examinedWallIds).not.toEqual( expect.arrayContaining([ @@ -1089,7 +1091,7 @@ describe('topology-delta room surface reconciliation', () => { new Set([splitWall.id as AnyNodeId]), ) - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + expect(Object.keys(sink.spaces)).toHaveLength(5) expect(events.at(-1)?.examinedWallIds).not.toContain('wall_bottom_right') const afterCreate = sceneStore.getState().nodes @@ -1106,7 +1108,7 @@ describe('topology-delta room surface reconciliation', () => { new Set([splitWall.id as AnyNodeId]), ) - expect(Object.keys(editorStore.getState().spaces)).toHaveLength(5) + expect(Object.keys(sink.spaces)).toHaveLength(5) expect(events.at(-1)?.examinedWallIds).not.toContain('wall_bottom_right') expect(sceneStore.getState().nodes.slab_d).toEqual(distantSlabBefore) expect(sceneStore.getState().nodes.ceiling_d).toEqual(distantCeilingBefore) @@ -1130,7 +1132,7 @@ describe('topology-delta room surface reconciliation', () => { } } const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1167,7 +1169,7 @@ describe('topology-delta room surface reconciliation', () => { delete initial.slab_main delete initial.ceiling_main const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1216,7 +1218,7 @@ describe('topology-delta room surface reconciliation', () => { delete initial.slab_main delete initial.ceiling_main const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1252,7 +1254,7 @@ describe('topology-delta room surface reconciliation', () => { test('deleting a divider merges compatible generated surfaces', () => { const initial = splitRoomWithGeneratedSurfaces() const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const { wall_divider: _deleted, ...withoutDivider } = sceneStore.getState().nodes @@ -1276,7 +1278,7 @@ describe('topology-delta room surface reconciliation', () => { const initial = splitRoomWithGeneratedSurfaces() delete initial.slab_left const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const { wall_divider: _deleted, ...withoutDivider } = sceneStore.getState().nodes @@ -1342,7 +1344,7 @@ describe('topology-delta room surface reconciliation', () => { } } const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1377,7 +1379,7 @@ describe('topology-delta room surface reconciliation', () => { delete initial.slab_left delete initial.ceiling_left const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1430,7 +1432,7 @@ describe('topology-delta room surface reconciliation', () => { } } const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1465,7 +1467,7 @@ describe('topology-delta room surface reconciliation', () => { test('deleting an exterior wall leaves generated slabs and ceilings unchanged', () => { const sceneStore = createSceneStoreStub(roomWithGeneratedSurfaces()) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) const slabBefore = sceneStore.getState().nodes.slab_main const ceilingBefore = sceneStore.getState().nodes.ceiling_main @@ -1515,8 +1517,8 @@ describe('reactive ceiling re-clamp through the detection sync', () => { ) as Record const sceneStore = createSceneStoreStub(initialNodes) - const editorStore = createEditorStoreStub() - const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore) + const sink = createSpacesSink() + const unsubscribe = initSpaceDetectionSync(sceneStore, sink) try { // Scenario gate 11's reactive half: the deck lands on the level @@ -1600,7 +1602,7 @@ describe('reactive ceiling re-clamp through the detection sync', () => { ].map((node) => [node.id, node]), ) as Record const sceneStore = createSceneStoreStub(initialNodes) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes @@ -1632,7 +1634,7 @@ describe('procedural zone sync isolation', () => { delete initial.slab_main delete initial.ceiling_main const sceneStore = createSceneStoreStub(initial) - const unsubscribe = initSpaceDetectionSync(sceneStore, createEditorStoreStub()) + const unsubscribe = initSpaceDetectionSync(sceneStore, createSpacesSink()) try { const current = sceneStore.getState().nodes diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 5c4df8aba..dc9760df9 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1725,6 +1725,7 @@ export type SpaceTopologyReconcileEvent = { } export type SpaceDetectionSyncOptions = { + onSpacesChanged?: (spaces: Record) => void onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void } @@ -2224,18 +2225,21 @@ function roomsEligibleForAutoSurface( }) } -function updateSpacesForLevel(levelId: string, spaces: Space[], editorStore: any) { - const existingSpaces = editorStore.getState().spaces as Record +function replaceSpacesForLevel( + existingSpaces: Record, + levelId: string, + spaces: Space[], +) { const nextSpaces: Record = {} for (const [spaceId, space] of Object.entries(existingSpaces)) { if (space.levelId !== levelId) nextSpaces[spaceId] = space } for (const space of spaces) nextSpaces[space.id] = space - editorStore.getState().setSpaces(nextSpaces) + return nextSpaces } -function replaceIndexedSpaces(spaces: Space[], editorStore: any) { - editorStore.getState().setSpaces(Object.fromEntries(spaces.map((space) => [space.id, space]))) +function indexSpaces(spaces: Space[]) { + return Object.fromEntries(spaces.map((space) => [space.id, space])) } function reconcileChangedZones( @@ -2258,8 +2262,7 @@ function reconcileWallTopologyDelta( topologyDelta: IndexedTopologyDelta, currentNodes: SceneNodes, sceneStore: any, - editorStore: any, -): void { +): Space[] { const { updateNodes } = sceneStore.getState() const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] const allCurrentRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) @@ -2348,7 +2351,7 @@ function reconcileWallTopologyDelta( const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) const zonePlan = planAutoZonesForLevel(spaces, zones) if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - updateSpacesForLevel(levelId, spaces, editorStore) + return spaces } function ceilingClampInputsChanged( @@ -2429,13 +2432,22 @@ export function isSpaceDetectionPaused(): boolean { export function initSpaceDetectionSync( sceneStore: any, - editorStore: any, options: SpaceDetectionSyncOptions = {}, ): () => void { let isProcessing = false const topologyIndex = new RoomTopologyIndex() + let spacesById: Record = {} + const publishAllSpaces = (spaces: Space[]) => { + spacesById = indexSpaces(spaces) + options.onSpacesChanged?.(spacesById) + } + const publishSpacesForLevel = (levelId: string, spaces: Space[]) => { + spacesById = replaceSpacesForLevel(spacesById, levelId, spaces) + options.onSpacesChanged?.(spacesById) + } + topologyIndex.rebuild(sceneStore.getState().nodes as SceneNodes) - replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + publishAllSpaces(topologyIndex.spaces()) const temporalState = sceneStore.temporal?.getState?.() let previousPastLength = temporalState?.pastStates?.length ?? 0 let previousFutureLength = temporalState?.futureStates?.length ?? 0 @@ -2444,7 +2456,7 @@ export function initSpaceDetectionSync( if (isProcessing) return if (commit.origin === 'load') { topologyIndex.rebuild(commit.current.nodes) - replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + publishAllSpaces(topologyIndex.spaces()) return } const changedWalls = changedWallIdsByLevel( @@ -2465,7 +2477,7 @@ export function initSpaceDetectionSync( if (changedWalls.size === 0 && changedZones.size === 0 && !shouldReconcileCeilingBounds) return if (spaceDetectionPauseDepth > 0) { topologyIndex.rebuild(commit.current.nodes) - replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + publishAllSpaces(topologyIndex.spaces()) return } @@ -2479,13 +2491,13 @@ export function initSpaceDetectionSync( commit.before.nodes, commit.current.nodes, ) - reconcileWallTopologyDelta( + const spaces = reconcileWallTopologyDelta( levelId, topologyDelta, commit.current.nodes, sceneStore, - editorStore, ) + publishSpacesForLevel(levelId, spaces) options.onTopologyReconcile?.({ levelId, strategy: topologyDelta.strategy, @@ -2535,7 +2547,7 @@ export function initSpaceDetectionSync( queueMicrotask(() => { topologyIndex.rebuild(sceneStore.getState().nodes as SceneNodes) - replaceIndexedSpaces(topologyIndex.spaces(), editorStore) + publishAllSpaces(topologyIndex.spaces()) }) }, ) ?? (() => {}) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index ec9c5105a..be63364c0 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -116,7 +116,9 @@ const EDITOR_DEFAULT_RENDER = { shading: 'solid' } as const */ function initializeEditorRuntime(): () => void { const unsubscribeSpatialGrid = initSpatialGridSync() - const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, useEditor) + const unsubscribeSpaceDetection = initSpaceDetectionSync(useScene, { + onSpacesChanged: (spaces) => useEditor.getState().setSpaces(spaces), + }) initSFXBus() return () => { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index c6b1d5f9b..ccad56740 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -300,7 +300,9 @@ describe('createWallOnCurrentLevel', () => { editorState.spaces = spaces }, } - const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + const unsubscribe = initSpaceDetectionSync(useScene, { + onSpacesChanged: editorState.setSpaces, + }) try { const created = createWallOnCurrentLevel([2, 0], [2, 3]) @@ -397,7 +399,9 @@ describe('createWallOnCurrentLevel', () => { editorState.spaces = spaces }, } - const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + const unsubscribe = initSpaceDetectionSync(useScene, { + onSpacesChanged: editorState.setSpaces, + }) try { const divider = createWallOnCurrentLevel([curveMidpoint.x, curveMidpoint.y], [2, 3]) @@ -553,7 +557,9 @@ describe('createWallOnCurrentLevel', () => { editorState.spaces = spaces }, } - const unsubscribe = initSpaceDetectionSync(useScene, { getState: () => editorState }) + const unsubscribe = initSpaceDetectionSync(useScene, { + onSpacesChanged: editorState.setSpaces, + }) try { expect(createWallOnCurrentLevel([0, 0], [4, 3])).not.toBeNull() diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index c279cc995..4b2c46544 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -149,9 +149,11 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn if (special) return special } else { const intersection = - findWallJunctionFromRaw(point, walls, ignoreWallIds) ?? - findWallIntersectionFromRaw(point, walls, ignoreWallIds) - if (intersection) return { point: intersection, snap: 'intersection' } + findWallJunctionFromRaw(point, walls, ignoreWallIds, snapRadii?.intersection) ?? + findWallIntersectionFromRaw(point, walls, ignoreWallIds, snapRadii?.intersection) + if (intersection && distanceSquared(point, intersection) > WALL_CONNECT_SNAP_RADIUS ** 2) { + return { point: intersection, snap: 'intersection' } + } } const step = overrideStep ?? getSegmentGridStep() diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts index 794f898ef..0f616be5f 100644 --- a/packages/editor/src/lib/scene.test.ts +++ b/packages/editor/src/lib/scene.test.ts @@ -60,7 +60,9 @@ describe('applySceneGraphToEditor', () => { }) test('loading a closed room does not recreate a slab or ceiling deleted before reload', () => { - const unsubscribe = initSpaceDetectionSync(useScene, useEditor) + const unsubscribe = initSpaceDetectionSync(useScene, { + onSpacesChanged: (spaces) => useEditor.getState().setSpaces(spaces), + }) try { applySceneGraphToEditor(loadedRoomWithoutSurfaces())