From ddc08f11445ad8fd857b14019ce877f230251ef5 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Wed, 12 Aug 2026 10:54:37 +0200 Subject: [PATCH 01/12] feat(wall): add endHeightOffset for sloped top edge --- packages/core/src/schema/nodes/wall.ts | 5 ++++ packages/nodes/src/wall/panel.tsx | 19 ++++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d4afd49ffd..6dc74970c7 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -150,6 +150,10 @@ export const WallNode = BaseNode.extend({ slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), height: z.number().optional(), + // Added to the wall's top only at its `end` point (`start` is unaffected), + // tilting the top edge along the wall's length so one side is taller than + // the other — e.g. a knee wall following a single-pitch roof slope. + endHeightOffset: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), @@ -174,6 +178,7 @@ export const WallNode = BaseNode.extend({ Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters + - endHeightOffset: added to the top only at the wall's end point, tilting the top edge so one side is taller than the other - fillToTerrain: extends the wall downward to the terrain without changing its authored height - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0841857d8e..cfb22f9a54 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -185,12 +185,14 @@ export default function WallPanel() { const followsTerrain = node.fillToTerrain === true const height = node.height ?? resolvedHeightMeters ?? 2.5 + const endHeightOffset = node.endHeightOffset ?? 0 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) const unitLabel = getLinearUnitLabel(unit) const displayLength = metersToLinearUnit(length, unit) const displayHeight = metersToLinearUnit(height, unit) + const displayEndHeightOffset = metersToLinearUnit(endHeightOffset, unit) const displayThickness = metersToLinearUnit(thickness, unit) const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) @@ -237,6 +239,23 @@ export default function WallPanel() { unit={unitLabel} value={Math.round(displayHeight * 100) / 100} /> + + handleUpdate({ + endHeightOffset: linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters: -3, + }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayEndHeightOffset * 100) / 100} + />
Base
diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index f84f48c0ff..93993012a2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -933,6 +933,31 @@ function mergeWallTerrainFill( return merged } +/** + * Tilts a wall's top edge along its length so the `end` side sits taller (or + * shorter) than the `start` side — e.g. a knee wall following a single-pitch + * roof slope — instead of requiring a non-rectangular footprint. Only + * vertices sitting exactly at the flat extruded top (`topY`) move; a + * vertex's local X (0 at `start`, `wallLength` at `end`) linearly + * interpolates the offset from 0 to `wallNode.endHeightOffset`. + */ +function applyWallEndHeightSlope( + geometry: THREE.BufferGeometry, + wallNode: WallNode, + wallLength: number, + topY: number, +): void { + const endHeightOffset = wallNode.endHeightOffset + if (!endHeightOffset || wallLength < 1e-9) return + const position = geometry.getAttribute('position') as THREE.BufferAttribute + for (let i = 0; i < position.count; i++) { + if (Math.abs(position.getY(i) - topY) > 1e-4) continue + const t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) + position.setY(i, topY + endHeightOffset * t) + } + position.needsUpdate = true +} + export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], @@ -1019,6 +1044,7 @@ export function generateExtrudedWall( // Rotate so extrusion direction (Z) becomes height direction (Y) geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) From f9213c2bfdc360f89759ba0a68be854dc62e8886 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Wed, 12 Aug 2026 19:08:53 +0200 Subject: [PATCH 02/12] fix(wall): enforce strict zero minimum for endHeightOffset Fixes a bug where negative offset values caused wall geometry to self-intersect and drop below the base. The value is now strictly clamped to 0 at the Zod schema, UI, and geometry generation layers. --- packages/core/src/schema/nodes/wall.ts | 3 ++- packages/nodes/src/wall/panel.tsx | 17 ++++++++++------- .../viewer/src/systems/wall/wall-system.tsx | 9 +++++++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 6dc74970c7..07cb8e4c86 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -153,7 +153,8 @@ export const WallNode = BaseNode.extend({ // Added to the wall's top only at its `end` point (`start` is unaffected), // tilting the top edge along the wall's length so one side is taller than // the other — e.g. a knee wall following a single-pitch roof slope. - endHeightOffset: z.number().optional(), + /** Height offset at the end point (default 0). Must be non-negative. */ + endHeightOffset: z.number().min(0).optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index cfb22f9a54..5fa05b9ca5 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -242,15 +242,18 @@ export default function WallPanel() { + min={0} + onChange={(v) => { handleUpdate({ - endHeightOffset: linearControlValueToMeters(v, unit, { - maxMeters: 3, - minMeters: -3, - }), + endHeightOffset: Math.max( + 0, + linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters: 0, + }), + ), }) - } + }} precision={2} step={0.1} unit={unitLabel} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 93993012a2..231239fde2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -947,8 +947,13 @@ function applyWallEndHeightSlope( wallLength: number, topY: number, ): void { - const endHeightOffset = wallNode.endHeightOffset - if (!endHeightOffset || wallLength < 1e-9) return + const rawOffset = wallNode.endHeightOffset + console.log('[applyWallEndHeightSlope] called', { rawOffset, wallLength, topY, height: wallNode.height }) + if (!rawOffset || wallLength < 1e-9) { + console.log('[applyWallEndHeightSlope] early return', { rawOffset, wallLength }) + return + } + const endHeightOffset = Math.max(0, rawOffset) const position = geometry.getAttribute('position') as THREE.BufferAttribute for (let i = 0; i < position.count; i++) { if (Math.abs(position.getY(i) - topY) > 1e-4) continue From 83d085458165d5d459952021e6f056ef014b8e51 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Thu, 13 Aug 2026 11:29:27 +0200 Subject: [PATCH 03/12] fix(wall): resolve end height offset bounds and curved wall sloped geometry - **Geometry Generation**: - `applyWallEndHeightSlope`: Replaced naive X-axis interpolation with exact radial angle-based parameterization (`t`) when the wall footprint is curved. Implemented robust `getSignedAngleDiff` and angle-unwrapping based on expected linear distance to prevent severe Y-axis warping on the wall end caps when `Math.atan2` boundaries are crossed on steep semicircular curves. - `generateExtrudedWall`: Added `getWallArcData` injection, mapping the arc center into wall-local coordinate space, and passing `localArc` down into the height slope generator. - **Height Resolution Core**: - `resolveWallTop`: Added an optional `t?: number` (parametric position along the chord) to correctly account for the sloped top. It injects `endHeightOffset * t` directly into the final top boundary calculation. - `resolveWallEffectiveHeight`: Cascaded the optional `t` parameter from the caller down into `resolveWallTop` and adjusted elevation offsets. - **Spatial Grid Placement**: - `getWallHeight`: Now accepts an optional parametric position and passes it directly to `resolveWallTop`. - `canPlaceOnWall`: Computes the exact parametric `tCenter` for wall-hosted items based on the projection of the item's position onto the wall chord. This allows the placement bounding box constraints to accurately read the sloped height of the wall at the item's insertion point, fixing the bug where placement validators treated the entire wall as completely flat. - **UI and Constraints**: - `panel.tsx`: Refactored the `SliderControl` minimum constraint for the `endHeightOffset` input to explicitly prevent the wall top from dropping below a safe minimum height threshold (`0.01m`). --- .../spatial-grid/spatial-grid-manager.ts | 2640 +++++++++-------- packages/core/src/schema/nodes/wall.ts | 4 +- packages/core/src/systems/wall/wall-top.ts | 123 +- packages/nodes/src/wall/panel.tsx | 14 +- .../viewer/src/systems/wall/wall-system.tsx | 60 +- 5 files changed, 1451 insertions(+), 1390 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index a3ffb678fc..6299469dbb 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,1319 +1,1321 @@ -import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { levelBaseElevationAt } from '../../lib/terrain-support' -import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' -import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' -import { getWallPlaneTop } from '../../services/storey' -import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' -import useLiveTransforms from '../../store/use-live-transforms' -import useScene from '../../store/use-scene' -import { - computeWallSlabSupport, - pointInPolygon, - SUPPORT_ELEVATION_EPSILON, - type WallSlabSupport, -} from '../../systems/slab/slab-support' -import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' -import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' -import { getFloorPlacedFootprints } from './floor-placed-elevation' -import { SpatialGrid } from './spatial-grid' -import { GROUND_SUPPORT_ID } from './support-host-id' -import { WallSpatialGrid } from './wall-spatial-grid' - -export { - computeWallSlabElevation, - computeWallSlabSupport, - pointInPolygon, - SUPPORT_ELEVATION_EPSILON, - type WallOverlapInput, - type WallSlabSupport, - type WallSlabSupportSegment, - wallOverlapsPolygon, -} from '../../systems/slab/slab-support' - -// ============================================================================ -// GEOMETRY HELPERS -// ============================================================================ - -/** - * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. - */ -function getItemFootprint( - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - inset = 0, -): Array<[number, number]> { - const [x, , z] = position - const [w, , d] = dimensions - const yRot = rotation[1] - const halfW = Math.max(0, w / 2 - inset) - const halfD = Math.max(0, d / 2 - inset) - const cos = Math.cos(yRot) - const sin = Math.sin(yRot) - - return [ - [x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)], - [x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)], - [x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)], - [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)], - ] -} - -/** - * Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The - * rotated width/depth is the same conservative bound the floor-placement draft - * uses, so a draft and an existing node are compared with identical math. - */ -function footprintBoundsXZ( - position: [number, number, number], - dimensions: [number, number, number], - yRot: number, -): { minX: number; maxX: number; minZ: number; maxZ: number } { - const [width, , depth] = dimensions - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - return { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } -} - -type ItemLocalBounds = { - min: [number, number, number] - max: [number, number, number] -} - -type ItemParentAabb = { - minX: number - maxX: number - minY: number - maxY: number - minZ: number - maxZ: number -} - -function getItemLocalBounds(item: ItemNode): ItemLocalBounds { - const [width, height, depth] = getScaledDimensions(item) - const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 - const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 - return { - min: [-width / 2, 0, minZ], - max: [width / 2, height, maxZ], - } -} - -function getItemParentAabb(item: ItemNode): ItemParentAabb { - const bounds = getItemLocalBounds(item) - const corners: Array<[number, number, number]> = [ - [bounds.min[0], bounds.min[1], bounds.min[2]], - [bounds.min[0], bounds.min[1], bounds.max[2]], - [bounds.min[0], bounds.max[1], bounds.min[2]], - [bounds.min[0], bounds.max[1], bounds.max[2]], - [bounds.max[0], bounds.min[1], bounds.min[2]], - [bounds.max[0], bounds.min[1], bounds.max[2]], - [bounds.max[0], bounds.max[1], bounds.min[2]], - [bounds.max[0], bounds.max[1], bounds.max[2]], - ] - const yRot = item.rotation[1] ?? 0 - const cos = Math.cos(yRot) - const sin = Math.sin(yRot) - - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - - for (const [cx, cy, cz] of corners) { - const rotatedX = cx * cos + cz * sin - const rotatedZ = -cx * sin + cz * cos - const worldX = rotatedX + item.position[0] - const worldY = cy + item.position[1] - const worldZ = rotatedZ + item.position[2] - minX = Math.min(minX, worldX) - minY = Math.min(minY, worldY) - minZ = Math.min(minZ, worldZ) - maxX = Math.max(maxX, worldX) - maxY = Math.max(maxY, worldY) - maxZ = Math.max(maxZ, worldZ) - } - - return { minX, maxX, minY, maxY, minZ, maxZ } -} - -function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { - return minA < maxB - epsilon && maxA > minB + epsilon -} - -function resolveNodeLevelId(node: AnyNode, nodes: Record): string { - if (node.type === 'level') return node.id - - let current: AnyNode | undefined = node - while (current) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId] : undefined - } - - return 'default' -} - -function expandIgnoredNodeIds( - ignoreIds: readonly string[] | undefined, - nodes: Record, -): Set { - const ignored = new Set(ignoreIds ?? []) - const queue = [...ignored] - - while (queue.length > 0) { - const id = queue.pop()! - const node = nodes[id] - const children = (node as { children?: unknown } | undefined)?.children - if (!Array.isArray(children)) continue - for (const childId of children) { - if (typeof childId !== 'string' || ignored.has(childId)) continue - ignored.add(childId) - queue.push(childId) - } - } - - return ignored -} - -/** - * Test if two line segments (a1->a2) and (b1->b2) intersect. - */ -function segmentsIntersect( - ax1: number, - az1: number, - ax2: number, - az2: number, - bx1: number, - bz1: number, - bx2: number, - bz2: number, -): boolean { - const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) => - (ax - ox) * (bz - oz) - (az - oz) * (bx - ox) - - const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) - const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) - const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) - const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) - - if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { - return true - } - - // Collinear touching cases - const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => - Math.min(px, qx) <= rx && - rx <= Math.max(px, qx) && - Math.min(pz, qz) <= rz && - rz <= Math.max(pz, qz) - - if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true - if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true - if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true - if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true - - return false -} - -/** - * Test if a line segment intersects any edge of a polygon. - */ -function segmentIntersectsPolygon( - sx1: number, - sz1: number, - sx2: number, - sz2: number, - polygon: Array<[number, number]>, -): boolean { - const n = polygon.length - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - if ( - segmentsIntersect( - sx1, - sz1, - sx2, - sz2, - polygon[i]![0], - polygon[i]![1], - polygon[j]![0], - polygon[j]![1], - ) - ) { - return true - } - } - return false -} - -/** - * Test if an item's footprint overlaps with a polygon. - * Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect. - */ -export function itemOverlapsPolygon( - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - polygon: Array<[number, number]>, - inset = 0, -): boolean { - const corners = getItemFootprint(position, dimensions, rotation, inset) - - // Check if any item corner is inside the polygon - for (const [cx, cz] of corners) { - if (pointInPolygon(cx, cz, polygon)) return true - } - - // Check if any polygon vertex is inside the item footprint - // (handles case where slab is fully inside a large item) - for (const [px, pz] of polygon) { - if (pointInPolygon(px, pz, corners)) return true - } - - // Check if any item edge intersects any polygon edge - for (let i = 0; i < 4; i++) { - const j = (i + 1) % 4 - if ( - segmentIntersectsPolygon( - corners[i]![0], - corners[i]![1], - corners[j]![0], - corners[j]![1], - polygon, - ) - ) - return true - } - - return false -} - -/** One slab overlapping a queried footprint, as seen by support election. */ -export type SlabSupportCandidate = { - slabId: string - elevation: number -} - -export type ItemSlabSupport = { - elevation: number - /** The winning slab, or null when no slab overlaps the footprint. */ - slabId: string | null -} - -export type PointedSupportSurface = ItemSlabSupport & { - /** - * Level-local XZ where the ray meets the pointed surface's plane, or - * null when the ray never reaches it (grazing / aimed above the base). - * This is the plan point the pointer actually indicates: unlike a grid - * event-plane hit — whose XZ shifts with whatever height the event - * plane currently rides at — it depends only on the ray and the - * aimed-at surface, so election/preview at this point cannot flip when - * the event plane changes storey. - */ - point: [number, number] | null -} - -export class SpatialGridManager { - private readonly floorGrids = new Map() // levelId -> grid - private readonly wallGrids = new Map() // levelId -> wall grid - private readonly walls = new Map() // wallId -> wall data (for length calculations) - private readonly slabsByLevel = new Map>() // levelId -> (slabId -> slab) - private readonly ceilingGrids = new Map() // ceilingId -> grid - private readonly ceilings = new Map() // ceilingId -> ceiling data - private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) - - private readonly cellSize: number - - constructor(cellSize = 0.5) { - this.cellSize = cellSize - } - - private getFloorGrid(levelId: string): SpatialGrid { - if (!this.floorGrids.has(levelId)) { - this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize })) - } - return this.floorGrids.get(levelId)! - } - - private getWallGrid(levelId: string): WallSpatialGrid { - if (!this.wallGrids.has(levelId)) { - this.wallGrids.set(levelId, new WallSpatialGrid()) - } - return this.wallGrids.get(levelId)! - } - - private getWallLength(wallId: string): number { - const wall = this.walls.get(wallId) - if (!wall) return 0 - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - return Math.sqrt(dx * dx + dy * dy) - } - - private getWallHeight(wallId: string): number { - const wall = this.walls.get(wallId) - if (!wall) return 0 - if (wall.height != null) return wall.height - - const nodes = useScene.getState().nodes - const levelId = resolveNodeLevelId(wall, nodes) - const support = this.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ) - return resolveWallEffectiveHeight( - wall, - getWallPlaneTop(wall, levelId, nodes), - support.elevation, - ) - } - - private getCeilingGrid(ceilingId: string): SpatialGrid { - if (!this.ceilingGrids.has(ceilingId)) { - this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize })) - } - return this.ceilingGrids.get(ceilingId)! - } - - private getSlabMap(levelId: string): Map { - if (!this.slabsByLevel.has(levelId)) { - this.slabsByLevel.set(levelId, new Map()) - } - return this.slabsByLevel.get(levelId)! - } - - /** - * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item - * support queries run per frame and the projection scans the level's - * walls + sibling slabs, so the result is cached per slab id and - * dropped for the whole level whenever a slab or wall on that level - * flows through the manager's create/update/delete handlers. - */ - private readonly renderedSlabPolygons = new Map>() - - private invalidateRenderedSlabPolygons(levelId: string) { - this.supportInputsRevision += 1 - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return - for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) - } - - /** - * True while a slab or wall on `levelId` has a live preview: group drags - * publish translated slab polygons and wall endpoints to - * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp - * publish a translation DELTA to `useLiveTransforms` — either way the - * scene store commits only on release, so the committed cache and index - * would elect support against pre-drag footprints (items and walls - * visibly drop to ground mid-preview). Support queries then read - * live-effective records and skip the rendered-polygon cache. - */ - private levelHasLivePreview(levelId: string): boolean { - const nodes = useScene.getState().nodes - const structuralOnLevel = (id: string) => { - const node = nodes[id as AnyNodeId] - if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false - return resolveNodeLevelId(node, nodes) === levelId - } - const overrides = useLiveNodeOverrides.getState().overrides - for (const id of overrides.keys()) { - if (structuralOnLevel(id)) return true - } - const transforms = useLiveTransforms.getState().transforms - for (const id of transforms.keys()) { - if (structuralOnLevel(id)) return true - } - return false - } - - /** - * The live-effective slab record: field overrides merged, then the - * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset - * stamp — store a translation, not an absolute position) applied to the - * polygon, holes, and elevation. Mapping happens exactly ONCE at each - * public query's loop entry: `slabSupportsFootprint` / - * `getRenderedSlabPolygon` take the already-effective record and must - * never re-map, or the delta would apply twice. - */ - private effectiveSlabRecord(slab: SlabNode): SlabNode { - let effective = getEffectiveNode(slab) - const live = useLiveTransforms.getState().get(slab.id) - if (live) { - const [dx, dy, dz] = live.position - if (dx !== 0 || dy !== 0 || dz !== 0) { - effective = { - ...effective, - polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), - holes: (effective.holes || []).map((hole) => - hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), - ), - elevation: (effective.elevation ?? 0.05) + dy, - } - } - } - return effective - } - - private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { - const live = this.levelHasLivePreview(levelId) - if (!live) { - const cached = this.renderedSlabPolygons.get(slab.id) - if (cached) return cached - } - - const siblingSlabs: SlabNode[] = [] - for (const other of this.getSlabMap(levelId).values()) { - if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) - } - const walls = this.getLevelWallNodes(levelId) - const polygon = getRenderableSlabPolygon(slab, { - walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, - siblingSlabs, - }) - if (!live) this.renderedSlabPolygons.set(slab.id, polygon) - return polygon - } - - /** - * Support test shared by election, candidate listing, and persisted-host - * validation: the footprint overlaps the slab's RENDERED polygon (what - * users see — matching the wall election in `computeWallSlabSupport`), - * with the center-point hole veto kept against the stored holes (holes - * are data, never render-offset). - */ - private slabSupportsFootprint( - levelId: string, - slab: SlabNode, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): boolean { - if (slab.polygon.length < 3) return false - const rendered = this.getRenderedSlabPolygon(levelId, slab) - if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false - - const [cx, , cz] = position - for (const hole of slab.holes || []) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false - } - return true - } - - // Called when nodes change - handleNodeCreated(node: AnyNode, levelId: string) { - if (node.type === 'slab') { - this.getSlabMap(levelId).set(node.id, node as SlabNode) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'ceiling') { - this.ceilings.set(node.id, node as CeilingNode) - } else if (node.type === 'wall') { - const wall = node as WallNode - this.walls.set(wall.id, wall) - // Rendered slab polygons adopt wall bands — a new wall can extend them. - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Wall-attached item - use parentId as the wall ID - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Ceiling item - use parentId as the ceiling ID - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - // Floor item - this.getFloorGrid(levelId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } - } - } - - handleNodeUpdated(node: AnyNode, levelId: string) { - if (node.type === 'slab') { - this.getSlabMap(levelId).set(node.id, node as SlabNode) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'ceiling') { - this.ceilings.set(node.id, node as CeilingNode) - } else if (node.type === 'wall') { - const wall = node as WallNode - this.walls.set(wall.id, wall) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Remove old placement and re-insert - this.getWallGrid(levelId).removeByItemId(item.id) - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Remove from old ceiling grid - const oldCeilingId = this.itemCeilingMap.get(item.id) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(item.id) - this.itemCeilingMap.delete(item.id) - } - // Insert into new ceiling grid - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - this.getFloorGrid(levelId).update( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } - } - } - - handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { - if (nodeType === 'slab') { - // Invalidate before removal so the deleted slab's own cache entry - // (still keyed in the level map here) is dropped with its siblings'. - this.invalidateRenderedSlabPolygons(levelId) - this.getSlabMap(levelId).delete(nodeId) - } else if (nodeType === 'ceiling') { - this.ceilings.delete(nodeId) - this.ceilingGrids.delete(nodeId) - } else if (nodeType === 'wall') { - this.walls.delete(nodeId) - this.invalidateRenderedSlabPolygons(levelId) - // Remove all items attached to this wall from the spatial grid - const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) - return removedItemIds // Caller can use this to delete the items from scene - } else if (nodeType === 'item') { - this.getFloorGrid(levelId).remove(nodeId) - this.getWallGrid(levelId).removeByItemId(nodeId) - // Also clean up ceiling grid - const oldCeilingId = this.itemCeilingMap.get(nodeId) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(nodeId) - this.itemCeilingMap.delete(nodeId) - } - } - return [] - } - - // Query methods - canPlaceOnFloor( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ignoreIds?: string[], - ) { - return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) - } - - canPlaceOnFloorFootprints( - levelId: string, - footprints: readonly { - position: [number, number, number] - dimensions: [number, number, number] - rotation: [number, number, number] - }[], - ignoreIds?: string[], - ) { - const nodes = useScene.getState().nodes - const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) - const draftBounds = footprints.map((footprint) => - footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), - ) - for (let i = 0; i < draftBounds.length; i += 1) { - const a = draftBounds[i]! - for (let j = i + 1; j < draftBounds.length; j += 1) { - const b = draftBounds[j]! - if ( - intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && - intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) - ) { - return { valid: false, conflictIds: [] } - } - } - } - - // A floor placement conflicts with any other COLLIDING floor-resting node, - // not just items — every kind whose `floorPlaced.collides` is set (item / - // shelf / column / cabinet / stair) contributes its footprint(s) as an - // obstacle. Each candidate's XZ extent is read from the same declarative - // footprint the elevation + sync paths use, so adding a colliding kind - // needs no change here. - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (ignoreSet.has(node.id)) continue - const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced?.collides) continue - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - // Low-profile item surfaces (rugs, mats) are stack-on targets, not - // obstacles — keep the long-standing item-only exemption. - if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue - if (resolveNodeLevelId(node, nodes) !== levelId) continue - - for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { - const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0 - const bounds = footprintBoundsXZ( - footprint.position ?? (node as { position: [number, number, number] }).position, - footprint.dimensions, - fpRotation, - ) - if ( - draftBounds.some( - (draft) => - intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), - ) - ) { - conflicts.push(node.id) - break - } - } - } - - return { valid: conflicts.length === 0, conflictIds: conflicts } - } - - /** - * Check if an item can be placed on a wall - * @param levelId - the level containing the wall - * @param wallId - the wall to check - * @param localX - X position in wall-local space (distance from wall start) - * @param localY - Y position (height from floor) - * @param dimensions - item dimensions [width, height, depth] - * @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side) - * @param side - which side for 'wall-side' items - * @param ignoreIds - item IDs to ignore in collision check - */ - canPlaceOnWall( - levelId: string, - wallId: string, - localX: number, - localY: number, - dimensions: [number, number, number], - attachType: 'wall' | 'wall-side' = 'wall', - side?: 'front' | 'back', - ignoreIds?: string[], - ) { - const wallLength = this.getWallLength(wallId) - if (wallLength === 0) { - return { valid: false, conflictIds: [] } - } - const wallHeight = this.getWallHeight(wallId) - // Convert local X position to parametric t (0-1) - const tCenter = localX / wallLength - const [itemWidth, itemHeight] = dimensions - const baseResult = this.getWallGrid(levelId).canPlaceOnWall( - wallId, - wallLength, - wallHeight, - tCenter, - itemWidth, - localY, - itemHeight, - attachType, - side, - ignoreIds, - ) - - if (!baseResult.valid) return baseResult - - const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) - const draftBounds = { - minX: localX - itemWidth / 2, - maxX: localX + itemWidth / 2, - minY: baseResult.adjustedY, - maxY: baseResult.adjustedY + itemHeight, - } - - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue - if (ignoreSet.has(item.id)) continue - if (item.parentId !== wallId) continue - - if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { - if (side !== item.side) continue - } - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) - ) { - conflicts.push(item.id) - } - } - - return { - ...baseResult, - valid: conflicts.length === 0, - conflictIds: conflicts, - } - } - - getWallForItem(levelId: string, itemId: string): string | undefined { - return this.getWallGrid(levelId).getWallForItem(itemId) - } - - /** - * Get the total slab elevation at a given (x, z) position on a level. - * Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0. - */ - getSlabElevationAt(levelId: string, x: number, z: number): number { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 - - let maxElevation = 0 - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { - // Check if point is in any hole - let inHole = false - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(x, z, hole)) { - inHole = true - break - } - } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } - } - } - return maxElevation - } - - /** - * Get the slab elevation for an item using its full footprint (bounding box). - * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) - * that only need the number. - */ - getSlabElevationForItem( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - maxElevation?: number | null, - ): number { - return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) - .elevation - } - - /** - * Elect the supporting slab for a footprint: the highest-elevation slab - * whose RENDERED polygon the footprint overlaps (center-point hole veto - * applies). Returns `{ elevation: 0, slabId: null }` when nothing - * overlaps. - * - * `maxElevation` is the pointer-decided cap: when set, only slabs whose - * walking surface sits at or below `maxElevation + - * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface - * the cursor ray actually hit never captures the election. - */ - getSlabSupportForItem( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - maxElevation?: number | null, - ): ItemSlabSupport { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return { elevation: 0, slabId: null } - - let winningElevation = Number.NEGATIVE_INFINITY - let winnerId: string | null = null - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - const elevation = slab.elevation ?? 0.05 - if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue - if (elevation > winningElevation) { - winningElevation = elevation - winnerId = slab.id - } - } - return winnerId === null - ? { elevation: 0, slabId: null } - : { elevation: winningElevation, slabId: winnerId } - } - - /** - * The walking surface the pointer actually points at: the nearest slab - * plane the ray crosses INSIDE that slab's rendered polygon (hole veto - * applies), or the level base (`elevation: 0, slabId: null`) when it - * crosses none. Ray origin/direction are level-local. Deliberately a - * point test, not a footprint test — it answers "which surface is under - * the cursor", which then caps the footprint election so a deck hanging - * above the aimed-at floor never lifts the placement. `point` is the - * ray's crossing of that surface's plane — the stable plan point - * callers should elect/preview at (see {@link PointedSupportSurface}). - */ - getPointedSupportSurface( - levelId: string, - rayOrigin: [number, number, number], - rayDirection: [number, number, number], - ): PointedSupportSurface { - const slabMap = this.slabsByLevel.get(levelId) - const [ox, oy, oz] = rayOrigin - const [dx, dy, dz] = rayDirection - if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } - - let best: { t: number; elevation: number; slabId: string } | null = null - if (slabMap) { - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (slab.polygon.length < 3) continue - const elevation = slab.elevation ?? 0.05 - const t = (elevation - oy) / dy - if (t <= 0) continue - if (best && t >= best.t) continue - const x = ox + dx * t - const z = oz + dz * t - const rendered = this.getRenderedSlabPolygon(levelId, slab) - if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue - let inHole = false - for (const hole of slab.holes || []) { - if (hole.length >= 3 && pointInPolygon(x, z, hole)) { - inHole = true - break - } - } - if (inHole) continue - best = { t, elevation, slabId: slab.id } - } - } - if (best) { - return { - elevation: best.elevation, - slabId: best.slabId, - point: [ox + dx * best.t, oz + dz * best.t], - } - } - const tBase = -oy / dy - return { - elevation: 0, - slabId: null, - point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, - } - } - - /** - * All slabs supporting a footprint, one entry per overlapping slab - * (highest elevation first; slab id breaks ties deterministically). - * Commit-side ambiguity check: persist a `supportSlabId` only when the - * candidates carry ≥ 2 distinct elevations. - */ - getSupportCandidatesForFootprint( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): SlabSupportCandidate[] { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return [] - - const candidates: SlabSupportCandidate[] = [] - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue - candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) - } - candidates.sort( - (a, b) => - b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), - ) - return candidates - } - - /** - * Elevation of a persisted support host for a footprint, or null when - * the slab no longer exists on the level or no longer overlaps the - * footprint (same overlap test as election). Deliberately read-only: a - * host reshaped away is NOT cleared — callers fall back to election and - * the stale reference resumes hosting if the slab's polygon returns. - * Slab deletion is the only writer (`deleteNodesAction` strips it). - */ - getHostSlabElevationForFootprint( - levelId: string, - slabId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): number | null { - const stored = this.slabsByLevel.get(levelId)?.get(slabId) - if (!stored) return null - const slab = this.effectiveSlabRecord(stored) - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null - return slab.elevation ?? 0.05 - } - - /** - * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). - * Returns the highest slab elevation found, or 0 if none. - * - * Accepts an optional `curveOffset` so curved walls evaluate overlap - * against their actual centerline samples, not just the chord. - */ - getSlabElevationForWall( - levelId: string, - start: [number, number], - end: [number, number], - curveOffset = 0, - thickness = DEFAULT_WALL_THICKNESS, - preferredSlabId?: string | null, - ): number { - return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) - .elevation - } - - getSlabSupportForWall( - levelId: string, - start: [number, number], - end: [number, number], - curveOffset = 0, - thickness = DEFAULT_WALL_THICKNESS, - preferredSlabId?: string | null, - maxElevation?: number | null, - supportOffset = 0, - ): WallSlabSupport { - // Sampled at the wall's own start point — the same anchor the mesh is - // positioned at, so the resolver and the renderer cannot disagree about - // where the ground is under this wall. - const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) - - if (preferredSlabId === GROUND_SUPPORT_ID) { - const elevation = levelBase + supportOffset - return { - elevation, - electedSlabId: null, - baseElevation: elevation, - baseSegments: [{ start: 0, end: 1, elevation }], - } - } - - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) { - const elevation = levelBase + supportOffset - return { - elevation, - electedSlabId: null, - baseElevation: elevation, - baseSegments: [{ start: 0, end: 1, elevation }], - } - } - - const inputs = this.getSupportInputs(levelId, slabMap) - - const support = computeWallSlabSupport( - { start, end, curveOffset, thickness }, - inputs.slabs, - inputs.walls, - preferredSlabId, - maxElevation, - levelBase, - ) - if (supportOffset === 0) return support - return { - ...support, - elevation: support.elevation + supportOffset, - baseElevation: support.baseElevation + supportOffset, - baseSegments: support.baseSegments.map((segment) => ({ - ...segment, - elevation: segment.elevation + supportOffset, - })), - } - } - - /** - * Effective slab and wall records for a level, held BY IDENTITY. A single - * viewer pass queries support once per wall, and each query used to derive - * both arrays afresh — mapping every wall on the level through - * `getEffectiveNode` — which also defeated the rendered-polygon memo - * downstream in `computeWallSlabSupport`. Rebuilt only when the scene - * nodes, either live-preview store, or the manager's own slab/wall - * bookkeeping changes. - */ - private supportInputsRevision = 0 - private readonly supportInputs = new Map< - string, - { - revision: number - nodes: object - overrides: object - transforms: object - slabs: SlabNode[] - walls: WallNode[] - } - >() - - private getSupportInputs(levelId: string, slabMap: Map) { - const nodes = useScene.getState().nodes - const overrides = useLiveNodeOverrides.getState().overrides - const transforms = useLiveTransforms.getState().transforms - const cached = this.supportInputs.get(levelId) - if ( - cached && - cached.revision === this.supportInputsRevision && - cached.nodes === nodes && - cached.overrides === overrides && - cached.transforms === transforms - ) { - return cached - } - - const next = { - revision: this.supportInputsRevision, - nodes, - overrides, - transforms, - slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), - walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), - } - this.supportInputs.set(levelId, next) - return next - } - - /** - * Walls on a level, resolved fresh from the scene store (the manager's - * own wall map is only maintained on create/delete, not on updates). - * Cached per scene `nodes` record so per-pointer-tick callers - * (door/window move) don't rescan the node map. - */ - private readonly levelWallsCache = new WeakMap>() - - private getLevelWallNodes(levelId: string): WallNode[] { - const nodes = useScene.getState().nodes - let byLevel = this.levelWallsCache.get(nodes) - if (!byLevel) { - byLevel = new Map() - this.levelWallsCache.set(nodes, byLevel) - } - const cached = byLevel.get(levelId) - if (cached) return cached - - const walls: WallNode[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'wall') continue - // Walk the parent chain to the owning level (guarded against cycles). - let current: AnyNode | undefined = node - let guard = 0 - while (current && current.type !== 'level' && guard < 16) { - current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined - guard += 1 - } - if (current?.type === 'level' && current.id === levelId) { - walls.push(node as WallNode) - } - } - byLevel.set(levelId, walls) - return walls - } - - /** - * Check if an item can be placed on a ceiling. - * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items. - */ - canPlaceOnCeiling( - ceilingId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ignoreIds?: string[], - ): { valid: boolean; conflictIds: string[] } { - const ceiling = this.ceilings.get(ceilingId) - if (!ceiling || ceiling.polygon.length < 3) { - return { valid: false, conflictIds: [] } - } - - // Check that the item footprint is entirely within the ceiling polygon - const corners = getItemFootprint(position, dimensions, rotation) - for (const [cx, cz] of corners) { - if (!pointInPolygon(cx, cz, ceiling.polygon)) { - return { valid: false, conflictIds: [] } - } - } - - // Check if item center is in any hole (if so, it cannot be placed) - const [centerX, , centerZ] = position - const holes = ceiling.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) { - return { valid: false, conflictIds: [] } - } - } - - const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) - const [width, , depth] = dimensions - const yRot = rotation[1] - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - const draftBounds = { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } - - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (item.asset.attachTo !== 'ceiling') continue - if (ignoreSet.has(item.id)) continue - if (item.parentId !== ceilingId) continue - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) - ) { - conflicts.push(item.id) - } - } - - return { valid: conflicts.length === 0, conflictIds: conflicts } - } - - clearLevel(levelId: string) { - this.invalidateRenderedSlabPolygons(levelId) - this.floorGrids.delete(levelId) - this.wallGrids.delete(levelId) - this.slabsByLevel.delete(levelId) - } - - clear() { - this.floorGrids.clear() - this.wallGrids.clear() - this.walls.clear() - this.slabsByLevel.clear() - this.ceilingGrids.clear() - this.ceilings.clear() - this.itemCeilingMap.clear() - this.renderedSlabPolygons.clear() - this.supportInputs.clear() - this.supportInputsRevision += 1 - } -} - -// Singleton instance -export const spatialGridManager = new SpatialGridManager() - -/** Level-local Y where the rendered wall mesh begins. */ -export function getWallBaseElevationForNodes( - wall: WallNode, - nodes: Record, -): number { - const levelId = resolveNodeLevelId(wall, nodes) - return spatialGridManager.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ).elevation -} - -/** - * Effective (extruded) height of a wall resolved from a nodes record: - * {@link resolveWallEffectiveHeight} over the covering-clamped plane top - * (`getWallPlaneTop`) and the singleton manager's slab election — so the - * value always agrees with the rendered wall. One shared resolver for the - * editor overlays (measurement label, action menu, side handles) that used - * to copy this derivation locally. - */ -export function getWallEffectiveHeightForNodes( - wall: WallNode, - nodes: Record, -): number { - const levelId = resolveNodeLevelId(wall, nodes) - const baseElevation = getWallBaseElevationForNodes(wall, nodes) - return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) -} +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import { levelBaseElevationAt } from '../../lib/terrain-support' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' +import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { getWallPlaneTop } from '../../services/storey' +import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' +import { + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallSlabSupport, +} from '../../systems/slab/slab-support' +import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' +import { getFloorPlacedFootprints } from './floor-placed-elevation' +import { SpatialGrid } from './spatial-grid' +import { GROUND_SUPPORT_ID } from './support-host-id' +import { WallSpatialGrid } from './wall-spatial-grid' + +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallOverlapInput, + type WallSlabSupport, + type WallSlabSupportSegment, + wallOverlapsPolygon, +} from '../../systems/slab/slab-support' + +// ============================================================================ +// GEOMETRY HELPERS +// ============================================================================ + +/** + * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. + */ +function getItemFootprint( + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + inset = 0, +): Array<[number, number]> { + const [x, , z] = position + const [w, , d] = dimensions + const yRot = rotation[1] + const halfW = Math.max(0, w / 2 - inset) + const halfD = Math.max(0, d / 2 - inset) + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) + + return [ + [x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)], + [x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)], + [x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)], + [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)], + ] +} + +/** + * Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The + * rotated width/depth is the same conservative bound the floor-placement draft + * uses, so a draft and an existing node are compared with identical math. + */ +function footprintBoundsXZ( + position: [number, number, number], + dimensions: [number, number, number], + yRot: number, +): { minX: number; maxX: number; minZ: number; maxZ: number } { + const [width, , depth] = dimensions + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + return { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } +} + +type ItemLocalBounds = { + min: [number, number, number] + max: [number, number, number] +} + +type ItemParentAabb = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number +} + +function getItemLocalBounds(item: ItemNode): ItemLocalBounds { + const [width, height, depth] = getScaledDimensions(item) + const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 + const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 + return { + min: [-width / 2, 0, minZ], + max: [width / 2, height, maxZ], + } +} + +function getItemParentAabb(item: ItemNode): ItemParentAabb { + const bounds = getItemLocalBounds(item) + const corners: Array<[number, number, number]> = [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], + ] + const yRot = item.rotation[1] ?? 0 + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [cx, cy, cz] of corners) { + const rotatedX = cx * cos + cz * sin + const rotatedZ = -cx * sin + cz * cos + const worldX = rotatedX + item.position[0] + const worldY = cy + item.position[1] + const worldZ = rotatedZ + item.position[2] + minX = Math.min(minX, worldX) + minY = Math.min(minY, worldY) + minZ = Math.min(minZ, worldZ) + maxX = Math.max(maxX, worldX) + maxY = Math.max(maxY, worldY) + maxZ = Math.max(maxZ, worldZ) + } + + return { minX, maxX, minY, maxY, minZ, maxZ } +} + +function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return minA < maxB - epsilon && maxA > minB + epsilon +} + +function resolveNodeLevelId(node: AnyNode, nodes: Record): string { + if (node.type === 'level') return node.id + + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + + return 'default' +} + +function expandIgnoredNodeIds( + ignoreIds: readonly string[] | undefined, + nodes: Record, +): Set { + const ignored = new Set(ignoreIds ?? []) + const queue = [...ignored] + + while (queue.length > 0) { + const id = queue.pop()! + const node = nodes[id] + const children = (node as { children?: unknown } | undefined)?.children + if (!Array.isArray(children)) continue + for (const childId of children) { + if (typeof childId !== 'string' || ignored.has(childId)) continue + ignored.add(childId) + queue.push(childId) + } + } + + return ignored +} + +/** + * Test if two line segments (a1->a2) and (b1->b2) intersect. + */ +function segmentsIntersect( + ax1: number, + az1: number, + ax2: number, + az2: number, + bx1: number, + bz1: number, + bx2: number, + bz2: number, +): boolean { + const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) => + (ax - ox) * (bz - oz) - (az - oz) * (bx - ox) + + const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) + const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) + const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) + const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) + + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true + } + + // Collinear touching cases + const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => + Math.min(px, qx) <= rx && + rx <= Math.max(px, qx) && + Math.min(pz, qz) <= rz && + rz <= Math.max(pz, qz) + + if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true + if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true + if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true + if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true + + return false +} + +/** + * Test if a line segment intersects any edge of a polygon. + */ +function segmentIntersectsPolygon( + sx1: number, + sz1: number, + sx2: number, + sz2: number, + polygon: Array<[number, number]>, +): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + if ( + segmentsIntersect( + sx1, + sz1, + sx2, + sz2, + polygon[i]![0], + polygon[i]![1], + polygon[j]![0], + polygon[j]![1], + ) + ) { + return true + } + } + return false +} + +/** + * Test if an item's footprint overlaps with a polygon. + * Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect. + */ +export function itemOverlapsPolygon( + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + polygon: Array<[number, number]>, + inset = 0, +): boolean { + const corners = getItemFootprint(position, dimensions, rotation, inset) + + // Check if any item corner is inside the polygon + for (const [cx, cz] of corners) { + if (pointInPolygon(cx, cz, polygon)) return true + } + + // Check if any polygon vertex is inside the item footprint + // (handles case where slab is fully inside a large item) + for (const [px, pz] of polygon) { + if (pointInPolygon(px, pz, corners)) return true + } + + // Check if any item edge intersects any polygon edge + for (let i = 0; i < 4; i++) { + const j = (i + 1) % 4 + if ( + segmentIntersectsPolygon( + corners[i]![0], + corners[i]![1], + corners[j]![0], + corners[j]![1], + polygon, + ) + ) + return true + } + + return false +} + +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string + elevation: number +} + +export type ItemSlabSupport = { + elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null +} + +export type PointedSupportSurface = ItemSlabSupport & { + /** + * Level-local XZ where the ray meets the pointed surface's plane, or + * null when the ray never reaches it (grazing / aimed above the base). + * This is the plan point the pointer actually indicates: unlike a grid + * event-plane hit — whose XZ shifts with whatever height the event + * plane currently rides at — it depends only on the ray and the + * aimed-at surface, so election/preview at this point cannot flip when + * the event plane changes storey. + */ + point: [number, number] | null +} + +export class SpatialGridManager { + private readonly floorGrids = new Map() // levelId -> grid + private readonly wallGrids = new Map() // levelId -> wall grid + private readonly walls = new Map() // wallId -> wall data (for length calculations) + private readonly slabsByLevel = new Map>() // levelId -> (slabId -> slab) + private readonly ceilingGrids = new Map() // ceilingId -> grid + private readonly ceilings = new Map() // ceilingId -> ceiling data + private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) + + private readonly cellSize: number + + constructor(cellSize = 0.5) { + this.cellSize = cellSize + } + + private getFloorGrid(levelId: string): SpatialGrid { + if (!this.floorGrids.has(levelId)) { + this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize })) + } + return this.floorGrids.get(levelId)! + } + + private getWallGrid(levelId: string): WallSpatialGrid { + if (!this.wallGrids.has(levelId)) { + this.wallGrids.set(levelId, new WallSpatialGrid()) + } + return this.wallGrids.get(levelId)! + } + + private getWallLength(wallId: string): number { + const wall = this.walls.get(wallId) + if (!wall) return 0 + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + return Math.hypot(dx, dy) + } + + private getWallHeight(wallId: string, t?: number): number { + const wall = this.walls.get(wallId) + if (!wall) return 0 + const offset = (wall.endHeightOffset && t !== undefined) ? wall.endHeightOffset * t : 0 + if (wall.height != null) return wall.height + offset + + const nodes = useScene.getState().nodes + const levelId = resolveNodeLevelId(wall, nodes) + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + undefined, + wall.supportOffset, + ) + return resolveWallEffectiveHeight( + wall, + getWallPlaneTop(wall, levelId, nodes), + support.elevation, + t + ) + } + + private getCeilingGrid(ceilingId: string): SpatialGrid { + if (!this.ceilingGrids.has(ceilingId)) { + this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize })) + } + return this.ceilingGrids.get(ceilingId)! + } + + private getSlabMap(levelId: string): Map { + if (!this.slabsByLevel.has(levelId)) { + this.slabsByLevel.set(levelId, new Map()) + } + return this.slabsByLevel.get(levelId)! + } + + /** + * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item + * support queries run per frame and the projection scans the level's + * walls + sibling slabs, so the result is cached per slab id and + * dropped for the whole level whenever a slab or wall on that level + * flows through the manager's create/update/delete handlers. + */ + private readonly renderedSlabPolygons = new Map>() + + private invalidateRenderedSlabPolygons(levelId: string) { + this.supportInputsRevision += 1 + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return + for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) + } + + /** + * True while a slab or wall on `levelId` has a live preview: group drags + * publish translated slab polygons and wall endpoints to + * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp + * publish a translation DELTA to `useLiveTransforms` — either way the + * scene store commits only on release, so the committed cache and index + * would elect support against pre-drag footprints (items and walls + * visibly drop to ground mid-preview). Support queries then read + * live-effective records and skip the rendered-polygon cache. + */ + private levelHasLivePreview(levelId: string): boolean { + const nodes = useScene.getState().nodes + const structuralOnLevel = (id: string) => { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false + return resolveNodeLevelId(node, nodes) === levelId + } + const overrides = useLiveNodeOverrides.getState().overrides + for (const id of overrides.keys()) { + if (structuralOnLevel(id)) return true + } + const transforms = useLiveTransforms.getState().transforms + for (const id of transforms.keys()) { + if (structuralOnLevel(id)) return true + } + return false + } + + /** + * The live-effective slab record: field overrides merged, then the + * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset + * stamp — store a translation, not an absolute position) applied to the + * polygon, holes, and elevation. Mapping happens exactly ONCE at each + * public query's loop entry: `slabSupportsFootprint` / + * `getRenderedSlabPolygon` take the already-effective record and must + * never re-map, or the delta would apply twice. + */ + private effectiveSlabRecord(slab: SlabNode): SlabNode { + let effective = getEffectiveNode(slab) + const live = useLiveTransforms.getState().get(slab.id) + if (live) { + const [dx, dy, dz] = live.position + if (dx !== 0 || dy !== 0 || dz !== 0) { + effective = { + ...effective, + polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (effective.holes || []).map((hole) => + hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + elevation: (effective.elevation ?? 0.05) + dy, + } + } + } + return effective + } + + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { + const live = this.levelHasLivePreview(levelId) + if (!live) { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + } + + const siblingSlabs: SlabNode[] = [] + for (const other of this.getSlabMap(levelId).values()) { + if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) + } + const walls = this.getLevelWallNodes(levelId) + const polygon = getRenderableSlabPolygon(slab, { + walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, + siblingSlabs, + }) + if (!live) this.renderedSlabPolygons.set(slab.id, polygon) + return polygon + } + + /** + * Support test shared by election, candidate listing, and persisted-host + * validation: the footprint overlaps the slab's RENDERED polygon (what + * users see — matching the wall election in `computeWallSlabSupport`), + * with the center-point hole veto kept against the stored holes (holes + * are data, never render-offset). + */ + private slabSupportsFootprint( + levelId: string, + slab: SlabNode, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): boolean { + if (slab.polygon.length < 3) return false + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false + + const [cx, , cz] = position + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false + } + return true + } + + // Called when nodes change + handleNodeCreated(node: AnyNode, levelId: string) { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) + } else if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + // Rendered slab polygons adopt wall bands — a new wall can extend them. + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + // Wall-attached item - use parentId as the wall ID + const wallId = item.parentId + if (wallId && this.walls.has(wallId)) { + const wallLength = this.getWallLength(wallId) + if (wallLength > 0) { + const [width, height] = getScaledDimensions(item) + const halfW = width / wallLength / 2 + // Calculate t from local X position (position[0] is distance along wall) + const t = item.position[0] / wallLength + // position[1] is the bottom of the item + this.getWallGrid(levelId).insert({ + itemId: item.id, + wallId, + tStart: t - halfW, + tEnd: t + halfW, + yStart: item.position[1], + yEnd: item.position[1] + height, + attachType: item.asset.attachTo as 'wall' | 'wall-side', + side: item.side, + }) + } + } + } else if (item.asset.attachTo === 'ceiling') { + // Ceiling item - use parentId as the ceiling ID + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.itemCeilingMap.set(item.id, ceilingId) + } + } else if (!item.asset.attachTo) { + // Floor item + this.getFloorGrid(levelId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + } + } + } + + handleNodeUpdated(node: AnyNode, levelId: string) { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) + } else if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + // Remove old placement and re-insert + this.getWallGrid(levelId).removeByItemId(item.id) + const wallId = item.parentId + if (wallId && this.walls.has(wallId)) { + const wallLength = this.getWallLength(wallId) + if (wallLength > 0) { + const [width, height] = getScaledDimensions(item) + const halfW = width / wallLength / 2 + // Calculate t from local X position (position[0] is distance along wall) + const t = item.position[0] / wallLength + // position[1] is the bottom of the item + this.getWallGrid(levelId).insert({ + itemId: item.id, + wallId, + tStart: t - halfW, + tEnd: t + halfW, + yStart: item.position[1], + yEnd: item.position[1] + height, + attachType: item.asset.attachTo as 'wall' | 'wall-side', + side: item.side, + }) + } + } + } else if (item.asset.attachTo === 'ceiling') { + // Remove from old ceiling grid + const oldCeilingId = this.itemCeilingMap.get(item.id) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(item.id) + this.itemCeilingMap.delete(item.id) + } + // Insert into new ceiling grid + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.itemCeilingMap.set(item.id, ceilingId) + } + } else if (!item.asset.attachTo) { + this.getFloorGrid(levelId).update( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + } + } + } + + handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { + if (nodeType === 'slab') { + // Invalidate before removal so the deleted slab's own cache entry + // (still keyed in the level map here) is dropped with its siblings'. + this.invalidateRenderedSlabPolygons(levelId) + this.getSlabMap(levelId).delete(nodeId) + } else if (nodeType === 'ceiling') { + this.ceilings.delete(nodeId) + this.ceilingGrids.delete(nodeId) + } else if (nodeType === 'wall') { + this.walls.delete(nodeId) + this.invalidateRenderedSlabPolygons(levelId) + // Remove all items attached to this wall from the spatial grid + const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) + return removedItemIds // Caller can use this to delete the items from scene + } else if (nodeType === 'item') { + this.getFloorGrid(levelId).remove(nodeId) + this.getWallGrid(levelId).removeByItemId(nodeId) + // Also clean up ceiling grid + const oldCeilingId = this.itemCeilingMap.get(nodeId) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(nodeId) + this.itemCeilingMap.delete(nodeId) + } + } + return [] + } + + // Query methods + canPlaceOnFloor( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ) { + return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) + } + + canPlaceOnFloorFootprints( + levelId: string, + footprints: readonly { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }[], + ignoreIds?: string[], + ) { + const nodes = useScene.getState().nodes + const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) + const draftBounds = footprints.map((footprint) => + footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), + ) + for (let i = 0; i < draftBounds.length; i += 1) { + const a = draftBounds[i]! + for (let j = i + 1; j < draftBounds.length; j += 1) { + const b = draftBounds[j]! + if ( + intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && + intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) + ) { + return { valid: false, conflictIds: [] } + } + } + } + + // A floor placement conflicts with any other COLLIDING floor-resting node, + // not just items — every kind whose `floorPlaced.collides` is set (item / + // shelf / column / cabinet / stair) contributes its footprint(s) as an + // obstacle. Each candidate's XZ extent is read from the same declarative + // footprint the elevation + sync paths use, so adding a colliding kind + // needs no change here. + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (ignoreSet.has(node.id)) continue + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced?.collides) continue + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + // Low-profile item surfaces (rugs, mats) are stack-on targets, not + // obstacles — keep the long-standing item-only exemption. + if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue + if (resolveNodeLevelId(node, nodes) !== levelId) continue + + for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { + const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0 + const bounds = footprintBoundsXZ( + footprint.position ?? (node as { position: [number, number, number] }).position, + footprint.dimensions, + fpRotation, + ) + if ( + draftBounds.some( + (draft) => + intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), + ) + ) { + conflicts.push(node.id) + break + } + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } + } + + /** + * Check if an item can be placed on a wall + * @param levelId - the level containing the wall + * @param wallId - the wall to check + * @param localX - X position in wall-local space (distance from wall start) + * @param localY - Y position (height from floor) + * @param dimensions - item dimensions [width, height, depth] + * @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side) + * @param side - which side for 'wall-side' items + * @param ignoreIds - item IDs to ignore in collision check + */ + canPlaceOnWall( + levelId: string, + wallId: string, + localX: number, + localY: number, + dimensions: [number, number, number], + attachType: 'wall' | 'wall-side' = 'wall', + side?: 'front' | 'back', + ignoreIds?: string[], + ) { + const wallLength = this.getWallLength(wallId) + if (wallLength === 0) { + return { valid: false, conflictIds: [] } + } + // Convert local X position to parametric t (0-1) + const tCenter = localX / wallLength + const wallHeight = this.getWallHeight(wallId, tCenter) + const [itemWidth, itemHeight] = dimensions + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( + wallId, + wallLength, + wallHeight, + tCenter, + itemWidth, + localY, + itemHeight, + attachType, + side, + ignoreIds, + ) + + if (!baseResult.valid) return baseResult + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const draftBounds = { + minX: localX - itemWidth / 2, + maxX: localX + itemWidth / 2, + minY: baseResult.adjustedY, + maxY: baseResult.adjustedY + itemHeight, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== wallId) continue + + if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { + if (side !== item.side) continue + } + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) + ) { + conflicts.push(item.id) + } + } + + return { + ...baseResult, + valid: conflicts.length === 0, + conflictIds: conflicts, + } + } + + getWallForItem(levelId: string, itemId: string): string | undefined { + return this.getWallGrid(levelId).getWallForItem(itemId) + } + + /** + * Get the total slab elevation at a given (x, z) position on a level. + * Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0. + */ + getSlabElevationAt(levelId: string, x: number, z: number): number { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return 0 + + let maxElevation = 0 + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { + // Check if point is in any hole + let inHole = false + const holes = slab.holes || [] + for (const hole of holes) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { + inHole = true + break + } + } + + if (!inHole) { + const elevation = slab.elevation ?? 0.05 + if (elevation > maxElevation) { + maxElevation = elevation + } + } + } + } + return maxElevation + } + + /** + * Get the slab elevation for an item using its full footprint (bounding box). + * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) + * that only need the number. + */ + getSlabElevationForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): number { + return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) + .elevation + } + + /** + * Elect the supporting slab for a footprint: the highest-elevation slab + * whose RENDERED polygon the footprint overlaps (center-point hole veto + * applies). Returns `{ elevation: 0, slabId: null }` when nothing + * overlaps. + * + * `maxElevation` is the pointer-decided cap: when set, only slabs whose + * walking surface sits at or below `maxElevation + + * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface + * the cursor ray actually hit never captures the election. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): ItemSlabSupport { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return { elevation: 0, slabId: null } + + let winningElevation = Number.NEGATIVE_INFINITY + let winnerId: string | null = null + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + const elevation = slab.elevation ?? 0.05 + if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + if (elevation > winningElevation) { + winningElevation = elevation + winnerId = slab.id + } + } + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: winningElevation, slabId: winnerId } + } + + /** + * The walking surface the pointer actually points at: the nearest slab + * plane the ray crosses INSIDE that slab's rendered polygon (hole veto + * applies), or the level base (`elevation: 0, slabId: null`) when it + * crosses none. Ray origin/direction are level-local. Deliberately a + * point test, not a footprint test — it answers "which surface is under + * the cursor", which then caps the footprint election so a deck hanging + * above the aimed-at floor never lifts the placement. `point` is the + * ray's crossing of that surface's plane — the stable plan point + * callers should elect/preview at (see {@link PointedSupportSurface}). + */ + getPointedSupportSurface( + levelId: string, + rayOrigin: [number, number, number], + rayDirection: [number, number, number], + ): PointedSupportSurface { + const slabMap = this.slabsByLevel.get(levelId) + const [ox, oy, oz] = rayOrigin + const [dx, dy, dz] = rayDirection + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } + + let best: { t: number; elevation: number; slabId: string } | null = null + if (slabMap) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (slab.polygon.length < 3) continue + const elevation = slab.elevation ?? 0.05 + const t = (elevation - oy) / dy + if (t <= 0) continue + if (best && t >= best.t) continue + const x = ox + dx * t + const z = oz + dz * t + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue + let inHole = false + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { + inHole = true + break + } + } + if (inHole) continue + best = { t, elevation, slabId: slab.id } + } + } + if (best) { + return { + elevation: best.elevation, + slabId: best.slabId, + point: [ox + dx * best.t, oz + dz * best.t], + } + } + const tBase = -oy / dy + return { + elevation: 0, + slabId: null, + point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, + } + } + + /** + * All slabs supporting a footprint, one entry per overlapping slab + * (highest elevation first; slab id breaks ties deterministically). + * Commit-side ambiguity check: persist a `supportSlabId` only when the + * candidates carry ≥ 2 distinct elevations. + */ + getSupportCandidatesForFootprint( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): SlabSupportCandidate[] { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return [] + + const candidates: SlabSupportCandidate[] = [] + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) + } + candidates.sort( + (a, b) => + b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), + ) + return candidates + } + + /** + * Elevation of a persisted support host for a footprint, or null when + * the slab no longer exists on the level or no longer overlaps the + * footprint (same overlap test as election). Deliberately read-only: a + * host reshaped away is NOT cleared — callers fall back to election and + * the stale reference resumes hosting if the slab's polygon returns. + * Slab deletion is the only writer (`deleteNodesAction` strips it). + */ + getHostSlabElevationForFootprint( + levelId: string, + slabId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): number | null { + const stored = this.slabsByLevel.get(levelId)?.get(slabId) + if (!stored) return null + const slab = this.effectiveSlabRecord(stored) + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null + return slab.elevation ?? 0.05 + } + + /** + * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). + * Returns the highest slab elevation found, or 0 if none. + * + * Accepts an optional `curveOffset` so curved walls evaluate overlap + * against their actual centerline samples, not just the chord. + */ + getSlabElevationForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + ): number { + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) + .elevation + } + + getSlabSupportForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + maxElevation?: number | null, + supportOffset = 0, + ): WallSlabSupport { + // Sampled at the wall's own start point — the same anchor the mesh is + // positioned at, so the resolver and the renderer cannot disagree about + // where the ground is under this wall. + const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) + + if (preferredSlabId === GROUND_SUPPORT_ID) { + const elevation = levelBase + supportOffset + return { + elevation, + electedSlabId: null, + baseElevation: elevation, + baseSegments: [{ start: 0, end: 1, elevation }], + } + } + + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) { + const elevation = levelBase + supportOffset + return { + elevation, + electedSlabId: null, + baseElevation: elevation, + baseSegments: [{ start: 0, end: 1, elevation }], + } + } + + const inputs = this.getSupportInputs(levelId, slabMap) + + const support = computeWallSlabSupport( + { start, end, curveOffset, thickness }, + inputs.slabs, + inputs.walls, + preferredSlabId, + maxElevation, + levelBase, + ) + if (supportOffset === 0) return support + return { + ...support, + elevation: support.elevation + supportOffset, + baseElevation: support.baseElevation + supportOffset, + baseSegments: support.baseSegments.map((segment) => ({ + ...segment, + elevation: segment.elevation + supportOffset, + })), + } + } + + /** + * Effective slab and wall records for a level, held BY IDENTITY. A single + * viewer pass queries support once per wall, and each query used to derive + * both arrays afresh — mapping every wall on the level through + * `getEffectiveNode` — which also defeated the rendered-polygon memo + * downstream in `computeWallSlabSupport`. Rebuilt only when the scene + * nodes, either live-preview store, or the manager's own slab/wall + * bookkeeping changes. + */ + private supportInputsRevision = 0 + private readonly supportInputs = new Map< + string, + { + revision: number + nodes: object + overrides: object + transforms: object + slabs: SlabNode[] + walls: WallNode[] + } + >() + + private getSupportInputs(levelId: string, slabMap: Map) { + const nodes = useScene.getState().nodes + const overrides = useLiveNodeOverrides.getState().overrides + const transforms = useLiveTransforms.getState().transforms + const cached = this.supportInputs.get(levelId) + if ( + cached && + cached.revision === this.supportInputsRevision && + cached.nodes === nodes && + cached.overrides === overrides && + cached.transforms === transforms + ) { + return cached + } + + const next = { + revision: this.supportInputsRevision, + nodes, + overrides, + transforms, + slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), + walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), + } + this.supportInputs.set(levelId, next) + return next + } + + /** + * Walls on a level, resolved fresh from the scene store (the manager's + * own wall map is only maintained on create/delete, not on updates). + * Cached per scene `nodes` record so per-pointer-tick callers + * (door/window move) don't rescan the node map. + */ + private readonly levelWallsCache = new WeakMap>() + + private getLevelWallNodes(levelId: string): WallNode[] { + const nodes = useScene.getState().nodes + let byLevel = this.levelWallsCache.get(nodes) + if (!byLevel) { + byLevel = new Map() + this.levelWallsCache.set(nodes, byLevel) + } + const cached = byLevel.get(levelId) + if (cached) return cached + + const walls: WallNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + // Walk the parent chain to the owning level (guarded against cycles). + let current: AnyNode | undefined = node + let guard = 0 + while (current && current.type !== 'level' && guard < 16) { + current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined + guard += 1 + } + if (current?.type === 'level' && current.id === levelId) { + walls.push(node as WallNode) + } + } + byLevel.set(levelId, walls) + return walls + } + + /** + * Check if an item can be placed on a ceiling. + * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items. + */ + canPlaceOnCeiling( + ceilingId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ): { valid: boolean; conflictIds: string[] } { + const ceiling = this.ceilings.get(ceilingId) + if (!ceiling || ceiling.polygon.length < 3) { + return { valid: false, conflictIds: [] } + } + + // Check that the item footprint is entirely within the ceiling polygon + const corners = getItemFootprint(position, dimensions, rotation) + for (const [cx, cz] of corners) { + if (!pointInPolygon(cx, cz, ceiling.polygon)) { + return { valid: false, conflictIds: [] } + } + } + + // Check if item center is in any hole (if so, it cannot be placed) + const [centerX, , centerZ] = position + const holes = ceiling.holes || [] + for (const hole of holes) { + if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) { + return { valid: false, conflictIds: [] } + } + } + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo !== 'ceiling') continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== ceilingId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } + } + + clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) + this.floorGrids.delete(levelId) + this.wallGrids.delete(levelId) + this.slabsByLevel.delete(levelId) + } + + clear() { + this.floorGrids.clear() + this.wallGrids.clear() + this.walls.clear() + this.slabsByLevel.clear() + this.ceilingGrids.clear() + this.ceilings.clear() + this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() + this.supportInputs.clear() + this.supportInputsRevision += 1 + } +} + +// Singleton instance +export const spatialGridManager = new SpatialGridManager() + +/** Level-local Y where the rendered wall mesh begins. */ +export function getWallBaseElevationForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + return spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + undefined, + wall.supportOffset, + ).elevation +} + +/** + * Effective (extruded) height of a wall resolved from a nodes record: + * {@link resolveWallEffectiveHeight} over the covering-clamped plane top + * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * value always agrees with the rendered wall. One shared resolver for the + * editor overlays (measurement label, action menu, side handles) that used + * to copy this derivation locally. + */ +export function getWallEffectiveHeightForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const baseElevation = getWallBaseElevationForNodes(wall, nodes) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 07cb8e4c86..0c96096059 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -153,8 +153,8 @@ export const WallNode = BaseNode.extend({ // Added to the wall's top only at its `end` point (`start` is unaffected), // tilting the top edge along the wall's length so one side is taller than // the other — e.g. a knee wall following a single-pitch roof slope. - /** Height offset at the end point (default 0). Must be non-negative. */ - endHeightOffset: z.number().min(0).optional(), + /** Height offset at the end point (default 0). */ + endHeightOffset: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 2bbf475da7..65e1f7a8ae 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -1,56 +1,67 @@ -import type { WallNode } from '../../schema/nodes/wall' - -/** - * Minimum wall body height in meters. Governs both the wall height - * arrow's lower drag bound and the slab-elevation clamp: a slab may not - * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall - * elects it as its base, or the wall's extrusion (plane minus base) - * would collapse below this minimum. - */ -export const MIN_WALL_HEIGHT = 0.5 - -/** - * Wall-top inversion (vertical building model): a wall with no stored - * `height` is plane-bound — its top sits at the storey plane (level-local - * Y = the level's stored height), so a slab lifting the wall's base makes - * the wall shorter, never taller, and no gap can open at the top of a - * level. A wall WITH `height` is an explicit exception (half wall, - * parapet) and keeps the legacy semantics: the top rides a raised elected - * base (`electedBase + height`), while a zero or sunken slab base leaves - * the top at `height` (the legacy negative-slab constraint). Explicit - * ground-hosted walls are the terrain exception: `height` is always body - * height, including below datum, so sculpting cannot stretch the wall. - * - * Returns the top in level-local Y (same frame as `electedBase`). - */ -export function resolveWallTop( - wall: Pick, - storeyHeight: number, - electedBase: number, -): number { - if (wall.height == null) return storeyHeight - if (wall.supportSlabId === 'ground') return electedBase + wall.height - return electedBase > 0 ? electedBase + wall.height : wall.height -} - -/** - * Extruded height of the wall body: {@link resolveWallTop} minus the - * elected base. Base convention: the elected slab-support elevation itself - * — the viewer computes `effectiveBaseElevation = min(baseElevation, - * slabElevation)` and defaults `baseElevation` to the elected elevation, - * so with only the election in hand the two coincide. Fill-down below the - * elected base (`baseSegments`) is a geometry detail the extruder handles - * separately and never changes where the top sits. - * - * Equivalently: the wall-local Y of the wall's top, measured from the wall - * mesh origin (which sits at `electedBase`). May be non-positive when a - * slab reaches the storey plane; callers own the degenerate-geometry - * policy. - */ -export function resolveWallEffectiveHeight( - wall: Pick, - storeyHeight: number, - electedBase: number, -): number { - return resolveWallTop(wall, storeyHeight, electedBase) - electedBase -} +import type { WallNode } from '../../schema/nodes/wall' + +/** + * Minimum wall body height in meters. Governs both the wall height + * arrow's lower drag bound and the slab-elevation clamp: a slab may not + * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall + * elects it as its base, or the wall's extrusion (plane minus base) + * would collapse below this minimum. + */ +export const MIN_WALL_HEIGHT = 0.5 + +/** + * Wall-top inversion (vertical building model): a wall with no stored + * `height` is plane-bound — its top sits at the storey plane (level-local + * Y = the level's stored height), so a slab lifting the wall's base makes + * the wall shorter, never taller, and no gap can open at the top of a + * level. A wall WITH `height` is an explicit exception (half wall, + * parapet) and keeps the legacy semantics: the top rides a raised elected + * base (`electedBase + height`), while a zero or sunken slab base leaves + * the top at `height` (the legacy negative-slab constraint). Explicit + * ground-hosted walls are the terrain exception: `height` is always body + * height, including below datum, so sculpting cannot stretch the wall. + * + * Returns the top in level-local Y (same frame as `electedBase`). + */ +export function resolveWallTop( + wall: Pick, + storeyHeight: number, + electedBase: number, + t?: number, +): number { + let top: number + if (wall.height == null) { + top = storeyHeight + } else if (wall.supportSlabId === 'ground') { + top = electedBase + wall.height + } else { + top = electedBase > 0 ? electedBase + wall.height : wall.height + } + if (wall.endHeightOffset && t !== undefined) { + top += wall.endHeightOffset * t + } + return top +} + +/** + * Extruded height of the wall body: {@link resolveWallTop} minus the + * elected base. Base convention: the elected slab-support elevation itself + * — the viewer computes `effectiveBaseElevation = min(baseElevation, + * slabElevation)` and defaults `baseElevation` to the elected elevation, + * so with only the election in hand the two coincide. Fill-down below the + * elected base (`baseSegments`) is a geometry detail the extruder handles + * separately and never changes where the top sits. + * + * Equivalently: the wall-local Y of the wall's top, measured from the wall + * mesh origin (which sits at `electedBase`). May be non-positive when a + * slab reaches the storey plane; callers own the degenerate-geometry + * policy. + */ +export function resolveWallEffectiveHeight( + wall: Pick, + storeyHeight: number, + electedBase: number, + t?: number, +): number { + return resolveWallTop(wall, storeyHeight, electedBase, t) - electedBase +} diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 91c35268c2..70a04d8bbc 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -305,16 +305,14 @@ export default function WallPanel() { { + const minMeters = -(wallHeightMeters - 0.01) handleUpdate({ - endHeightOffset: Math.max( - 0, - linearControlValueToMeters(v, unit, { - maxMeters: 3, - minMeters: 0, - }), - ), + endHeightOffset: linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters, + }), }) }} precision={2} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 231239fde2..10b16231e2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -13,6 +13,7 @@ import { getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, + getWallArcData, getWallThickness, isCurvedWall, type Point2D, @@ -946,18 +947,62 @@ function applyWallEndHeightSlope( wallNode: WallNode, wallLength: number, topY: number, + localArc?: { center: { x: number; z: number }; direction: number } | null, ): void { const rawOffset = wallNode.endHeightOffset - console.log('[applyWallEndHeightSlope] called', { rawOffset, wallLength, topY, height: wallNode.height }) if (!rawOffset || wallLength < 1e-9) { - console.log('[applyWallEndHeightSlope] early return', { rawOffset, wallLength }) return } - const endHeightOffset = Math.max(0, rawOffset) + const wallHeight = wallNode.height ?? 2.5 + const minEndHeight = 0.01 + const endHeightOffset = Math.max(rawOffset, -(wallHeight - minEndHeight)) const position = geometry.getAttribute('position') as THREE.BufferAttribute + + const getSignedAngleDiff = (from: number, to: number) => { + let diff = to - from + while (diff > Math.PI) diff -= Math.PI * 2 + while (diff < -Math.PI) diff += Math.PI * 2 + return diff + } + + let startAngle = 0 + let delta = 0 + if (localArc) { + // Determine start angle of the arc from local origin (0,0) + startAngle = Math.atan2(0 - localArc.center.z, 0 - localArc.center.x) + // Determine end angle of the arc at (wallLength, 0) + const endAngle = Math.atan2(0 - localArc.center.z, wallLength - localArc.center.x) + delta = getSignedAngleDiff(startAngle, endAngle) + + // Ensure delta has the correct sign matching the arc direction. + // For exact semicircles, getSignedAngleDiff might return -PI when we want PI. + if (localArc.direction > 0 && delta < -1e-6) { + delta += Math.PI * 2 + } else if (localArc.direction < 0 && delta > 1e-6) { + delta -= Math.PI * 2 + } + } + for (let i = 0; i < position.count; i++) { if (Math.abs(position.getY(i) - topY) > 1e-4) continue - const t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) + + let t: number + if (localArc && Math.abs(delta) > 1e-6) { + const px = position.getX(i) + const pz = position.getZ(i) + const vertexAngle = Math.atan2(pz - localArc.center.z, px - localArc.center.x) + let vertexDelta = vertexAngle - startAngle + + // Unwrap vertexDelta so it stays close to the expected angle for this position. + // This prevents vertices on the end caps from wrapping around the PI boundary. + const expectedDelta = delta * (px / wallLength) + while (vertexDelta - expectedDelta > Math.PI) vertexDelta -= Math.PI * 2 + while (vertexDelta - expectedDelta < -Math.PI) vertexDelta += Math.PI * 2 + + t = THREE.MathUtils.clamp(vertexDelta / delta, 0, 1) + } else { + t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) + } position.setY(i, topY + endHeightOffset * t) } position.needsUpdate = true @@ -1047,9 +1092,14 @@ export function generateExtrudedWall( }) // Rotate so extrusion direction (Z) becomes height direction (Y) + const arc = isCurvedWall(wallNode) ? getWallArcData(wallNode) : null + const localArc = arc + ? { center: worldToLocal(arc.center), direction: arc.direction } + : null + geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) - applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, localArc) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) From e8217483f2b9ccc2015cb462c470f526951359ba Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 10:05:56 +0200 Subject: [PATCH 04/12] fix(openings): clamp sloped ceiling height and plane-bound wall slope - Update applyWallEndHeightSlope to clamp against the real extruded wall body height. - Accept parametric t in getWallEffectiveHeightForNodes to resolve local ceiling heights along sloped walls. - Update door and window placement math (clampToWall) and resize handles to evaluate ceiling bounds across the full opening span. - Ensure door handles and readWallLength fall back to parentId when wallId is unset. --- .../spatial-grid/spatial-grid-manager.ts | 3 +- packages/nodes/src/door/definition.ts | 52 +- packages/nodes/src/door/door-math.ts | 39 +- packages/nodes/src/door/floorplan-move.ts | 6 +- packages/nodes/src/door/move-tool.tsx | 9 +- packages/nodes/src/door/tool.tsx | 8 +- .../nodes/src/shared/wall-opening-ceiling.ts | 48 +- packages/nodes/src/wall/panel.tsx | 2 +- packages/nodes/src/window/definition.ts | 59 +- packages/nodes/src/window/floorplan-move.ts | 570 +++++++++--------- packages/nodes/src/window/move-tool.tsx | 4 +- packages/nodes/src/window/tool.tsx | 4 +- packages/nodes/src/window/window-math.ts | 79 ++- .../viewer/src/systems/wall/wall-system.tsx | 6 +- 14 files changed, 563 insertions(+), 326 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 6299469dbb..ddcd8515ff 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1314,8 +1314,9 @@ export function getWallBaseElevationForNodes( export function getWallEffectiveHeightForNodes( wall: WallNode, nodes: Record, + t?: number, ): number { const levelId = resolveNodeLevelId(wall, nodes) const baseElevation = getWallBaseElevationForNodes(wall, nodes) - return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation, t) } diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 9c7025d05e..f576dd0e24 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -14,7 +14,7 @@ import { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' -import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, readHostWallCeilingMaxWidth } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildDoorContextualDimensions } from './contextual-dimensions' import { scaleHandleHeight } from './door-math' @@ -35,8 +35,9 @@ const MIN_DOOR_WIDTH = 0.3 const MOVE_HANDLE_LIFT = 0.12 function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!door.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined + const hostId = door.wallId || door.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -54,11 +55,36 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor anchor: side === 'right' ? 'min' : 'max', min: MIN_DOOR_WIDTH, max: (n, scene) => { - // Roof-hosted doors clamp against the face profile (the wall-based - // limits read Infinity when wallId is unset). + // Roof-hosted doors clamp against the face profile. const roofMax = readRoofFaceWidthMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax) - return readWallLength(n, scene) + + const length = readWallLength(n, scene) + // armX accounts for door rotation (rotation[1]=π flips the door + // so its visual right points toward LOWER wall-local S, not higher). + const armX = Math.cos(n.rotation[1]) + const effectiveDirection = sign * armX + + const anchorLeft = n.position[0] - n.width / 2 + const anchorRight = n.position[0] + n.width / 2 + + let fixedEdgeS: number + let growSign: number + let maxWallBound: number + + if (effectiveDirection > 0) { + fixedEdgeS = anchorLeft + growSign = 1 + maxWallBound = length - anchorLeft + } else { + fixedEdgeS = anchorRight + growSign = -1 + maxWallBound = anchorRight + } + + const topY = n.position[1] + n.height / 2 + const hostId = n.wallId || n.parentId + return readHostWallCeilingMaxWidth(hostId, scene as any, fixedEdgeS, growSign, topY, maxWallBound) }, currentValue: (n) => n.width, onDrag: (node) => publishOpeningResizeGuides(node, false), @@ -101,7 +127,19 @@ function doorHeightHandle(): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 - return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) + const hostId = n.wallId || n.parentId + + // A sloped wall's ceiling varies across the door's width. To prevent corners + // poking out above the slope, the height limit must be the lowest ceiling + // point across the entire span of the door. + const leftS = n.position[0] - n.width / 2 + const rightS = n.position[0] + n.width / 2 + const wallHLeft = readHostWallCeiling(hostId, scene as any, leftS) + const wallHRight = readHostWallCeiling(hostId, scene as any, rightS) + const wallHCenter = readHostWallCeiling(hostId, scene as any, n.position[0]) + const wallH = Math.min(wallHLeft, wallHRight, wallHCenter) + + return Math.max(MIN_DOOR_HEIGHT, wallH - bottom) }, currentValue: (n) => n.height, onDrag: (node) => publishOpeningResizeGuides(node, false), diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index b5517c9dcf..f31e598c56 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,5 @@ import type { WallNode } from '@pascal-app/core' +import { readHostWallCeiling, type WallCeilingSceneReader } from '../shared/wall-opening-ceiling' /** * Keep the door handle at the same relative height when the door is resized: @@ -46,14 +47,44 @@ export function clampToWall( localX: number, width: number, height: number, -): { clampedX: number; clampedY: number } { + scene: WallCeilingSceneReader, +): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallLength = Math.hypot(dx, dz) - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const minX = width / 2 + const maxX = wallLength - width / 2 + + function checkFits(testX: number) { + const leftHeight = readHostWallCeiling(wallNode.id, scene, testX - width / 2) + const rightHeight = readHostWallCeiling(wallNode.id, scene, testX + width / 2) + return leftHeight >= height && rightHeight >= height + } + + let clampedX = Math.max(minX, Math.min(maxX, localX)) const clampedY = height / 2 // Doors always sit at floor level - return { clampedX, clampedY } + + let fits = checkFits(clampedX) + + if (!fits) { + // Try sliding left/right by steps up to width/2 + const step = 0.1 + for (let offset = step; offset <= width / 2; offset += step) { + if (clampedX - offset >= minX && checkFits(clampedX - offset)) { + clampedX -= offset + fits = true + break + } + if (clampedX + offset <= maxX && checkFits(clampedX + offset)) { + clampedX += offset + fits = true + break + } + } + } + + return { clampedX, clampedY, fits } } // Wall-child overlap is shared by door + window placement (one source of diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index 570ce29e06..ce474df3e3 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -209,7 +209,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) nodes, }) const snappedLocalX = neighborX ?? snapToHalf(hit.localX) - const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + const sceneReader = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const { clampedX, clampedY, fits } = clampToWall(hit.wall, snappedLocalX, node.width, node.height, sceneReader) // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the door actually moves to a new cell. diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index e545dfb38c..8239b27de5 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -306,14 +306,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => // component lives in `snapToHalf` (itself mode-aware). applySnap: isMagneticSnapActive(), }) - const { clampedX, clampedY } = clampToWall( + const sceneReader = { + get: (id: AnyNodeId) => useScene.getState().nodes[id], + nodes: () => useScene.getState().nodes, + } + const { clampedX, clampedY, fits } = clampToWall( event.node, localX, movingDoorNode.width, movingDoorNode.height, + sceneReader, ) - const valid = !hasWallChildOverlap( + const valid = fits && !hasWallChildOverlap( event.node.id, clampedX, clampedY, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index bb13193390..6e002d4591 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -289,8 +289,12 @@ const DoorTool: React.FC = () => { candidates: alignmentCandidates, applySnap, }) - const { clampedX, clampedY } = clampToWall(wall, localX, width, height) - const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + const sceneReader = { + get: (id: AnyNodeId) => useScene.getState().nodes[id], + nodes: () => useScene.getState().nodes, + } + const { clampedX, clampedY, fits } = clampToWall(wall, localX, width, height, sceneReader) + const valid = fits && !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index 23f58da7eb..2db62561d7 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -30,8 +30,9 @@ export type WallCeilingSceneReader = { export function resolveWallOpeningCeiling( wall: WallNode, nodes: Readonly>, + t?: number, ): number { - return getWallEffectiveHeightForNodes(wall, nodes as Record) + return getWallEffectiveHeightForNodes(wall, nodes as Record, t) } /** @@ -42,9 +43,52 @@ export function resolveWallOpeningCeiling( export function readHostWallCeiling( wallId: string | null | undefined, scene: WallCeilingSceneReader, + positionS?: number, ): number { if (!wallId) return Number.POSITIVE_INFINITY const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY - return resolveWallOpeningCeiling(wall, scene.nodes()) + if (positionS !== undefined) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length > 1e-4) { + const localT = Math.max(0, Math.min(1, positionS / length)) + return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes(), localT)) + } + } + return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes())) +} + +export function readHostWallCeilingMaxWidth( + wallId: string | null | undefined, + scene: WallCeilingSceneReader, + anchorS: number, + growSign: number, + topY: number, + maxLength: number, +): number { + if (!wallId) return maxLength + const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined + if (!wall) return maxLength + + // Fast check: if the extreme end is valid, return maxLength + const endS = anchorS + growSign * maxLength + if (readHostWallCeiling(wallId, scene, endS) >= topY - 1e-4) { + return maxLength + } + + // Binary search for the intersection + let low = 0 + let high = maxLength + for (let i = 0; i < 15; i++) { + const mid = (low + high) / 2 + const testS = anchorS + growSign * mid + if (readHostWallCeiling(wallId, scene, testS) >= topY - 1e-4) { + low = mid + } else { + high = mid + } + } + return low } diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 70a04d8bbc..807392b490 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -243,7 +243,7 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) - const wallHeightMeters = height + const wallHeightMeters = resolvedHeightMeters ?? height const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index ef050209fa..d0e05cb797 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -14,7 +14,7 @@ import { import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' -import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, readHostWallCeilingMaxWidth } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowContextualDimensions } from './contextual-dimensions' import { buildWindowFloorplan } from './floorplan' @@ -34,8 +34,9 @@ const MIN_WINDOW_WIDTH = 0.3 const MOVE_HANDLE_LIFT = 0.12 function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!w.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined + const hostId = w.wallId || w.parentId + if (!hostId) return Number.POSITIVE_INFINITY + const wall = scene.get(hostId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } @@ -51,11 +52,41 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { - // Roof-hosted windows clamp against the face profile (the - // wall-based limits read Infinity when wallId is unset). + // Roof-hosted windows clamp against the face profile. const roofMax = readRoofFaceWidthMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax) - return readWallLength(n, scene) + + const length = readWallLength(n, scene) + // armX accounts for window rotation (rotation[1]=π flips the window + // so its visual right points toward LOWER wall-local S, not higher). + const armX = Math.cos(n.rotation[1]) + // effectiveDirection: +1 = moving edge goes toward higher S (wall end) + // -1 = moving edge goes toward lower S (wall start) + const effectiveDirection = sign * armX + + const anchorLeft = n.position[0] - n.width / 2 + const anchorRight = n.position[0] + n.width / 2 + + // fixedEdgeS: the wall-local S of the edge that stays put. + // growSign: direction the MOVING edge travels in wall-local S. + // maxWallBound: max width before the moving edge hits the wall boundary. + let fixedEdgeS: number + let growSign: number + let maxWallBound: number + + if (effectiveDirection > 0) { + fixedEdgeS = anchorLeft + growSign = 1 + maxWallBound = length - anchorLeft + } else { + fixedEdgeS = anchorRight + growSign = -1 + maxWallBound = anchorRight + } + + const topY = n.position[1] + n.height / 2 + const hostId = n.wallId || n.parentId + return readHostWallCeilingMaxWidth(hostId, scene as any, fixedEdgeS, growSign, topY, maxWallBound) }, currentValue: (n) => n.width, onDrag: (node) => publishOpeningResizeGuides(node, true), @@ -96,10 +127,20 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, sign) if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax) - // Maximum: distance from the anchored edge to the wall's allowed Y - // bounds. Top arrow caps at the wall's resolved ceiling - bottom; + // Maximum: distance from the anchored edge to the wall's allowed bounds. Top arrow caps at the wall's resolved ceiling - bottom; // bottom arrow caps at top (positive Y room above the floor). - const wallH = readHostWallCeiling(n.wallId, scene) + const hostId = n.wallId || n.parentId + + // A sloped wall's ceiling varies across the window's width. To prevent corners + // poking out above the slope, the height limit must be the lowest ceiling + // point across the entire span of the window. + const leftS = n.position[0] - n.width / 2 + const rightS = n.position[0] + n.width / 2 + const wallHLeft = readHostWallCeiling(hostId, scene as any, leftS) + const wallHRight = readHostWallCeiling(hostId, scene as any, rightS) + const wallHCenter = readHostWallCeiling(hostId, scene as any, n.position[0]) + const wallH = Math.min(wallHLeft, wallHRight, wallHCenter) + const anchored = edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2 return edge === 'top' ? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 9fa359cbdd..c927041917 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -1,283 +1,287 @@ -import { - type AnyNodeId, - type FloorplanMoveTarget, - type FloorplanMoveTargetSession, - useLiveNodeOverrides, - useLiveTransforms, - useScene, - type WallNode, - WallNode as WallNodeSchema, - type WindowNode, -} from '@pascal-app/core' -import { - isGridSnapActive, - isMagneticSnapActive, - snapToHalf, - triggerSFX, - useEditor, - usePlacementPreview, -} from '@pascal-app/editor' -import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' -import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' -import { - findClosestWallInPlan, - projectWallLocalPointToPlan, - resolveOpeningPlacement, - snapLocalXToNeighbors, -} from '../shared/wall-attach-target' -import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math' - -/** - * 2D floor-plan move handler for window. Same shape as door (see - * `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap - * to nearest wall → project onto wall axis → snap local-X to 0.5m → - * clamp inside wall bounds → commit. - * - * Window-specific: local Y (vertical position on the wall) is preserved - * from the source node — we don't try to reposition the sill from a 2D - * pointer (there's no Y signal in plan view). The 3D move tool handles - * vertical motion; the 2D move is a horizontal-only re-anchor. - */ - -export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - const nodeId = node.id as AnyNodeId - // The level that owns the wall-snap candidates — resolves the wall-hosted, - // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). - const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) - const originalWall = node.parentId - ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) - : undefined - const resolveCursor = createFloorplanCursorResolver({ - original: - originalWall?.type === 'wall' - ? projectWallLocalPointToPlan(originalWall, node.position[0]) - : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), - metadata: node.metadata, - // Absolute: query the wall snap with the TRUE cursor (see the matching - // comment in `doorFloorplanMoveTarget`). Relative mode anchored the search - // to the original wall, which let the window snap to a farther wall across - // a thin gap instead of the one under the cursor. - mode: 'absolute', - }) - - // Preserve the source window's local Y — 2D move doesn't have a way - // to express vertical motion, so we keep whatever vertical position - // the window had when the move started. A fresh preset/catalog clone is - // created at y=0, which would sit the window's centre on the floor (half - // below ground); default those to a realistic sill so it floats above - // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). - const startLocalY = - node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2 - - // Track the last successful placement so `commit()` can write it - // atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. - let lastValid: { - position: [number, number, number] - rotation: [number, number, number] - side: WindowNode['side'] - parentId: string - wallId: string - roofSegmentId: undefined - roofFace: undefined - visible: true - } | null = null - - // R flips the window's facing (front ↔ back) mid-placement — see - // `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the - // flip is a persistent XOR plus a π rotation offset. - let flipped = false - let lastApply: { - planPoint: readonly [number, number] - modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } - } | null = null - // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor - // as a ghost and isn't committable (it needs a wall). Starts true. - let onWall = true - // Alt force-place (last apply's modifier) — lets `canCommit` allow an - // overlapping placement, matching the 3D move. - let forcePlace = false - let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId) - let liveOverrideKey: string | null = null - let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId - - const setLiveOverride = (key: string, values: Record) => { - if (liveOverrideKey === key) return - liveOverrideKey = key - useLiveNodeOverrides.getState().set(nodeId, values) - } - - // Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`): - // ONE soft `sfx:grid-snap` click each time the window's PLACED position crosses - // a step. Keyed on the SNAPPED value, quantized by the live grid step in grid - // mode else a gentle fixed cadence — grid mode ticks once per cell, lines/off - // tick as the window moves. - const FREE_STEP_M = 0.1 - let lastStepKey: string | null = null - const tickGridStep = (...coords: number[]) => { - const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M - const key = coords.map((c) => Math.round(c / step)).join(',') - if (key !== lastStepKey) { - lastStepKey = key - triggerSFX('sfx:grid-snap') - } - } - - const freeFollow = (planPoint: readonly [number, number]) => { - onWall = false - lastValid = null - if (liveTransformActive) { - useLiveTransforms.getState().clear(nodeId) - liveTransformActive = false - } - setLiveOverride('free-follow', { visible: false }) - const half = node.width / 2 + 0.5 - const wall = WallNodeSchema.parse({ - start: [planPoint[0] - half, planPoint[1]], - end: [planPoint[0] + half, planPoint[1]], - thickness: 0.1, - }) - // Reflect the R-flip on the floating ghost so it faces the side that will - // be committed (see `doorFloorplanMoveTarget.freeFollow`). - const ghostSide: WindowNode['side'] = flipped - ? node.side === 'front' - ? 'back' - : 'front' - : node.side - const ghost = { - ...node, - side: ghostSide, - parentId: wall.id, - wallId: wall.id, - roofSegmentId: undefined, - roofFace: undefined, - position: [half, startLocalY, 0] as [number, number, number], - rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], - visible: true, - } as WindowNode - usePlacementPreview.getState().set(ghost, wall) - placementPreviewActive = true - } - - const session: FloorplanMoveTargetSession = { - affectedIds: [nodeId], - flipSide() { - flipped = !flipped - if (lastApply) this.apply(lastApply) - }, - apply({ planPoint, modifiers }) { - lastApply = { planPoint, modifiers } - forcePlace = modifiers.altKey === true - const nodes = useScene.getState().nodes - const resolvedPlanPoint = resolveCursor(planPoint) - const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) - if (!hit) { - // Off any wall — free-follow. Click per grid cell over open floor. - tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) - freeFollow(resolvedPlanPoint) - return - } - onWall = true - if (placementPreviewActive) { - usePlacementPreview.getState().clear() - placementPreviewActive = false - } - - // Figma-style along-wall alignment first (edge-to-edge with other - // openings / wall ends), winning over the grid snap; falls back to grid - // when nothing aligns. Follows the magnetic ("lines") mode; the grid - // component lives in `snapToHalf` (mode-aware → raw when grid is off). - const neighborX = !isMagneticSnapActive() - ? null - : snapLocalXToNeighbors({ - wall: hit.wall, - localX: hit.localX, - width: node.width, - selfId: nodeId, - nodes, - }) - const snappedLocalX = neighborX ?? snapToHalf(hit.localX) - const { clampedX, clampedY } = clampToWall( - hit.wall, - snappedLocalX, - startLocalY, - node.width, - node.height, - nodes, - ) - - // One click per real position step, keyed on the SNAPPED along-wall value - // so it ticks only when the window actually moves to a new cell. - tickGridStep(clampedX) - - const side: WindowNode['side'] = flipped - ? hit.side === 'front' - ? 'back' - : 'front' - : hit.side - const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) - - lastValid = { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: hit.wall.id, - wallId: hit.wall.id, - // Re-anchoring to a wall ends any roof-segment hosting; the - // overlay's snapshot restores it if the move is reverted. - roofSegmentId: undefined, - roofFace: undefined, - visible: true, - } - - setLiveOverride(`wall:${hit.wall.id}:${side}`, { - parentId: hit.wall.id, - wallId: hit.wall.id, - side, - roofSegmentId: undefined, - roofFace: undefined, - visible: true, - }) - useLiveTransforms.getState().set(nodeId, { - position: lastValid.position, - rotation: itemRotation, - }) - liveTransformActive = true - }, - canCommit() { - // Off-wall the window is free-following — not placeable; the overlay - // reverts to the pre-move snapshot. Matches the 3D move. - if (!onWall || !lastValid) return false - const live = useScene.getState().nodes[nodeId] as WindowNode | undefined - if (live?.type !== 'window') return false - // Block on overlap UNLESS Alt force-places — same `placeable` rule as - // the 3D move + the shared `resolveOpeningPlacement`. - const collides = hasWallChildOverlap( - lastValid.parentId, - lastValid.position[0], - lastValid.position[1], - live.width, - live.height, - live.id, - ) - return resolveOpeningPlacement({ collides, forcePlace }).placeable - }, - commit() { - // Own the atomic write so the overlay takes the deterministic - // commit-path (revert → resume → session.commit()). The dispatcher's - // diff path would otherwise re-derive the final state by comparing - // the post-apply scene to the snapshot — that works most of the - // time but produces an empty diff (and silent revert) when the - // committed move lands on the same `parentId` with identical data. - // See `doorFloorplanMoveTarget.commit` for the original fix. - if (!lastValid) return - useScene.getState().updateNodes([ - { - id: nodeId, - data: lastValid, - }, - ]) - }, - } - - return session -} +import { + type AnyNodeId, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + useLiveNodeOverrides, + useLiveTransforms, + useScene, + type WallNode, + WallNode as WallNodeSchema, + type WindowNode, +} from '@pascal-app/core' +import { + isGridSnapActive, + isMagneticSnapActive, + snapToHalf, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' +import { + findClosestWallInPlan, + projectWallLocalPointToPlan, + resolveOpeningPlacement, + snapLocalXToNeighbors, +} from '../shared/wall-attach-target' +import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math' + +/** + * 2D floor-plan move handler for window. Same shape as door (see + * `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap + * to nearest wall → project onto wall axis → snap local-X to 0.5m → + * clamp inside wall bounds → commit. + * + * Window-specific: local Y (vertical position on the wall) is preserved + * from the source node — we don't try to reposition the sill from a 2D + * pointer (there's no Y signal in plan view). The 3D move tool handles + * vertical motion; the 2D move is a horizontal-only re-anchor. + */ + +export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const nodeId = node.id as AnyNodeId + // The level that owns the wall-snap candidates — resolves the wall-hosted, + // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). + const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) + const originalWall = node.parentId + ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) + : undefined + const resolveCursor = createFloorplanCursorResolver({ + original: + originalWall?.type === 'wall' + ? projectWallLocalPointToPlan(originalWall, node.position[0]) + : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), + metadata: node.metadata, + // Absolute: query the wall snap with the TRUE cursor (see the matching + // comment in `doorFloorplanMoveTarget`). Relative mode anchored the search + // to the original wall, which let the window snap to a farther wall across + // a thin gap instead of the one under the cursor. + mode: 'absolute', + }) + + // Preserve the source window's local Y — 2D move doesn't have a way + // to express vertical motion, so we keep whatever vertical position + // the window had when the move started. A fresh preset/catalog clone is + // created at y=0, which would sit the window's centre on the floor (half + // below ground); default those to a realistic sill so it floats above + // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). + const startLocalY = + node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2 + + // Track the last successful placement so `commit()` can write it + // atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. + let lastValid: { + position: [number, number, number] + rotation: [number, number, number] + side: WindowNode['side'] + parentId: string + wallId: string + roofSegmentId: undefined + roofFace: undefined + visible: true + } | null = null + + // R flips the window's facing (front ↔ back) mid-placement — see + // `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the + // flip is a persistent XOR plus a π rotation offset. + let flipped = false + let lastApply: { + planPoint: readonly [number, number] + modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } + } | null = null + // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor + // as a ghost and isn't committable (it needs a wall). Starts true. + let onWall = true + // Alt force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. + let forcePlace = false + let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId) + let liveOverrideKey: string | null = null + let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId + + const setLiveOverride = (key: string, values: Record) => { + if (liveOverrideKey === key) return + liveOverrideKey = key + useLiveNodeOverrides.getState().set(nodeId, values) + } + + // Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`): + // ONE soft `sfx:grid-snap` click each time the window's PLACED position crosses + // a step. Keyed on the SNAPPED value, quantized by the live grid step in grid + // mode else a gentle fixed cadence — grid mode ticks once per cell, lines/off + // tick as the window moves. + const FREE_STEP_M = 0.1 + let lastStepKey: string | null = null + const tickGridStep = (...coords: number[]) => { + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M + const key = coords.map((c) => Math.round(c / step)).join(',') + if (key !== lastStepKey) { + lastStepKey = key + triggerSFX('sfx:grid-snap') + } + } + + const freeFollow = (planPoint: readonly [number, number]) => { + onWall = false + lastValid = null + if (liveTransformActive) { + useLiveTransforms.getState().clear(nodeId) + liveTransformActive = false + } + setLiveOverride('free-follow', { visible: false }) + const half = node.width / 2 + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + // Reflect the R-flip on the floating ghost so it faces the side that will + // be committed (see `doorFloorplanMoveTarget.freeFollow`). + const ghostSide: WindowNode['side'] = flipped + ? node.side === 'front' + ? 'back' + : 'front' + : node.side + const ghost = { + ...node, + side: ghostSide, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, startLocalY, 0] as [number, number, number], + rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], + visible: true, + } as WindowNode + usePlacementPreview.getState().set(ghost, wall) + placementPreviewActive = true + } + + const session: FloorplanMoveTargetSession = { + affectedIds: [nodeId], + flipSide() { + flipped = !flipped + if (lastApply) this.apply(lastApply) + }, + apply({ planPoint, modifiers }) { + lastApply = { planPoint, modifiers } + forcePlace = modifiers.altKey === true + const nodes = useScene.getState().nodes + const resolvedPlanPoint = resolveCursor(planPoint) + const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) + if (!hit) { + // Off any wall — free-follow. Click per grid cell over open floor. + tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) + freeFollow(resolvedPlanPoint) + return + } + onWall = true + if (placementPreviewActive) { + usePlacementPreview.getState().clear() + placementPreviewActive = false + } + + // Figma-style along-wall alignment first (edge-to-edge with other + // openings / wall ends), winning over the grid snap; falls back to grid + // when nothing aligns. Follows the magnetic ("lines") mode; the grid + // component lives in `snapToHalf` (mode-aware → raw when grid is off). + const neighborX = !isMagneticSnapActive() + ? null + : snapLocalXToNeighbors({ + wall: hit.wall, + localX: hit.localX, + width: node.width, + selfId: nodeId, + nodes, + }) + const snappedLocalX = neighborX ?? snapToHalf(hit.localX) + const sceneReader = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const { clampedX, clampedY, fits } = clampToWall( + hit.wall, + snappedLocalX, + startLocalY, + node.width, + node.height, + sceneReader as any, + ) + + // One click per real position step, keyed on the SNAPPED along-wall value + // so it ticks only when the window actually moves to a new cell. + tickGridStep(clampedX) + + const side: WindowNode['side'] = flipped + ? hit.side === 'front' + ? 'back' + : 'front' + : hit.side + const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) + + lastValid = { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: hit.wall.id, + wallId: hit.wall.id, + // Re-anchoring to a wall ends any roof-segment hosting; the + // overlay's snapshot restores it if the move is reverted. + roofSegmentId: undefined, + roofFace: undefined, + visible: true, + } + + setLiveOverride(`wall:${hit.wall.id}:${side}`, { + parentId: hit.wall.id, + wallId: hit.wall.id, + side, + roofSegmentId: undefined, + roofFace: undefined, + visible: true, + }) + useLiveTransforms.getState().set(nodeId, { + position: lastValid.position, + rotation: itemRotation, + }) + liveTransformActive = true + }, + canCommit() { + // Off-wall the window is free-following — not placeable; the overlay + // reverts to the pre-move snapshot. Matches the 3D move. + if (!onWall || !lastValid) return false + const live = useScene.getState().nodes[nodeId] as WindowNode | undefined + if (live?.type !== 'window') return false + // Block on overlap UNLESS Alt force-places — same `placeable` rule as + // the 3D move + the shared `resolveOpeningPlacement`. + const collides = hasWallChildOverlap( + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], + live.width, + live.height, + live.id, + ) + return resolveOpeningPlacement({ collides, forcePlace }).placeable + }, + commit() { + // Own the atomic write so the overlay takes the deterministic + // commit-path (revert → resume → session.commit()). The dispatcher's + // diff path would otherwise re-derive the final state by comparing + // the post-apply scene to the snapshot — that works most of the + // time but produces an empty diff (and silent revert) when the + // committed move lands on the same `parentId` with identical data. + // See `doorFloorplanMoveTarget.commit` for the original fix. + if (!lastValid) return + useScene.getState().updateNodes([ + { + id: nodeId, + data: lastValid, + }, + ]) + }, + } + + return session +} diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 115ac2536a..a3e1307053 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -353,7 +353,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // component lives in `snapToHalf` (itself mode-aware). applySnap: isMagneticSnapActive(), }) - const { clampedX, clampedY } = clampToWall( + const { clampedX, clampedY, fits } = clampToWall( event.node, localX, targetLocalY, @@ -362,7 +362,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode useScene.getState().nodes, ) - const valid = !hasWallChildOverlap( + const valid = fits && !hasWallChildOverlap( event.node.id, clampedX, clampedY, diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 3987bfaadf..62aba46712 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -346,7 +346,7 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall( + const { clampedX, clampedY, fits } = clampToWall( wall, localX, localY, @@ -354,7 +354,7 @@ const WindowTool: React.FC = () => { height, useScene.getState().nodes, ) - const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + const valid = fits && !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 08ff4cb329..363840ae88 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' -import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -49,15 +49,80 @@ export function clampToWall( width: number, height: number, nodes: Readonly>, -): { clampedX: number; clampedY: number } { +): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) + const wallLength = Math.hypot(dx, dz) - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) - const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) - return { clampedX, clampedY } + const minX = width / 2 + const maxX = wallLength - width / 2 + + const sceneReader = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + + function checkFits(testX: number, testY: number) { + const leftHeight = readHostWallCeiling(wallNode.id, sceneReader, testX - width / 2) + const rightHeight = readHostWallCeiling(wallNode.id, sceneReader, testX + width / 2) + const topY = testY + height / 2 + return leftHeight >= topY && rightHeight >= topY + } + + let clampedX = Math.max(minX, Math.min(maxX, localX)) + const leftCeiling = readHostWallCeiling(wallNode.id, sceneReader, clampedX - width / 2) + const rightCeiling = readHostWallCeiling(wallNode.id, sceneReader, clampedX + width / 2) + const localCeiling = Math.min(leftCeiling, rightCeiling) + const clampedYRaw = Math.max(height / 2, Math.min(localCeiling - height / 2, localY)) + + let clampedY = clampedYRaw + + if (width > wallLength) { + return { clampedX, clampedY, fits: false } + } + + let fits = checkFits(clampedX, clampedY) + + if (!fits) { + // If it doesn't fit horizontally, try sliding down first + const lowestY = height / 2 + if (clampedY > lowestY) { + // Find maximum Y that fits at current X + let lowY = lowestY + let highY = clampedY + for (let i = 0; i < 15; i++) { + const mid = (lowY + highY) / 2 + if (checkFits(clampedX, mid)) { + lowY = mid + } else { + highY = mid + } + } + if (checkFits(clampedX, lowY)) { + clampedY = lowY + fits = true + } + } + + if (!fits) { + // Try sliding left/right by steps up to width/2 + const step = 0.1 + for (let offset = step; offset <= width / 2; offset += step) { + if (clampedX - offset >= minX && checkFits(clampedX - offset, clampedY)) { + clampedX -= offset + fits = true + break + } + if (clampedX + offset <= maxX && checkFits(clampedX + offset, clampedY)) { + clampedX += offset + fits = true + break + } + } + } + } + + return { clampedX, clampedY, fits } } /** diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 10b16231e2..b7eadf1bf3 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -947,15 +947,15 @@ function applyWallEndHeightSlope( wallNode: WallNode, wallLength: number, topY: number, + bodyHeight: number, localArc?: { center: { x: number; z: number }; direction: number } | null, ): void { const rawOffset = wallNode.endHeightOffset if (!rawOffset || wallLength < 1e-9) { return } - const wallHeight = wallNode.height ?? 2.5 const minEndHeight = 0.01 - const endHeightOffset = Math.max(rawOffset, -(wallHeight - minEndHeight)) + const endHeightOffset = Math.max(rawOffset, -(bodyHeight - minEndHeight)) const position = geometry.getAttribute('position') as THREE.BufferAttribute const getSignedAngleDiff = (from: number, to: number) => { @@ -1099,7 +1099,7 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) - applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, localArc) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, height, localArc) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) From 4a2638ab4bf4d7058028a884b884ad0141f0fb32 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 10:23:00 +0200 Subject: [PATCH 05/12] fix(openings): enforce slope fit on 2D floorplan moves - Track lastFits in door and window floorplan move targets and block commit when opening exceeds sloped wall bounds. - Allow clampToWall in window-math and door-math to accept either a WallCeilingSceneReader or a Record. --- packages/nodes/src/door/door-math.ts | 12 ++++++++-- packages/nodes/src/door/floorplan-move.ts | 26 ++++++++++++--------- packages/nodes/src/window/floorplan-move.ts | 26 ++++++++++++--------- packages/nodes/src/window/window-math.ts | 20 ++++++++++------ 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index f31e598c56..1c767b2507 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,4 +1,4 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' import { readHostWallCeiling, type WallCeilingSceneReader } from '../shared/wall-opening-ceiling' /** @@ -47,7 +47,7 @@ export function clampToWall( localX: number, width: number, height: number, - scene: WallCeilingSceneReader, + sceneOrNodes: WallCeilingSceneReader | Readonly>, ): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] @@ -56,6 +56,14 @@ export function clampToWall( const minX = width / 2 const maxX = wallLength - width / 2 + const scene: WallCeilingSceneReader = + typeof (sceneOrNodes as WallCeilingSceneReader).nodes === 'function' + ? (sceneOrNodes as WallCeilingSceneReader) + : { + get: (id: AnyNodeId) => (sceneOrNodes as Readonly>)[id], + nodes: () => sceneOrNodes as Readonly>, + } + function checkFits(testX: number) { const leftHeight = readHostWallCeiling(wallNode.id, scene, testX - width / 2) const rightHeight = readHostWallCeiling(wallNode.id, scene, testX + width / 2) diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index ce474df3e3..cb41d77909 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -98,6 +98,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) // the cursor as a ghost (like the 3D move) and is NOT committable — a door // needs a wall. Starts true so a click before any move keeps the door put. let onWall = true + let lastFits = true // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. Read in `canCommit` so an Alt- // held commit over a collision lands instead of reverting. @@ -214,6 +215,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) nodes: () => nodes, } const { clampedX, clampedY, fits } = clampToWall(hit.wall, snappedLocalX, node.width, node.height, sceneReader) + lastFits = fits // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the door actually moves to a new cell. @@ -258,17 +260,19 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) if (!onWall || !lastValid) return false const live = useScene.getState().nodes[nodeId] as DoorNode | undefined if (live?.type !== 'door') return false - // Block commit if the door overlaps another wall child — UNLESS Alt - // force-places (same `placeable` rule as the 3D move + the shared - // `resolveOpeningPlacement`). - const collides = hasWallChildOverlap( - lastValid.parentId, - lastValid.position[0], - lastValid.position[1], - live.width, - live.height, - live.id, - ) + // Block commit if the door does not fit the wall's sloped ceiling or overlaps + // another wall child — UNLESS Alt force-places (same `placeable` rule as + // the 3D move + the shared `resolveOpeningPlacement`). + const collides = + !lastFits || + hasWallChildOverlap( + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], + live.width, + live.height, + live.id, + ) return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index c927041917..4ea9a21939 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -93,6 +93,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor // as a ghost and isn't committable (it needs a wall). Starts true. let onWall = true + let lastFits = true // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. let forcePlace = false @@ -206,8 +207,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod startLocalY, node.width, node.height, - sceneReader as any, + sceneReader, ) + lastFits = fits // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the window actually moves to a new cell. @@ -253,16 +255,18 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod if (!onWall || !lastValid) return false const live = useScene.getState().nodes[nodeId] as WindowNode | undefined if (live?.type !== 'window') return false - // Block on overlap UNLESS Alt force-places — same `placeable` rule as - // the 3D move + the shared `resolveOpeningPlacement`. - const collides = hasWallChildOverlap( - lastValid.parentId, - lastValid.position[0], - lastValid.position[1], - live.width, - live.height, - live.id, - ) + // Block on overlap or slope height breach UNLESS Alt force-places — same + // `placeable` rule as the 3D move + the shared `resolveOpeningPlacement`. + const collides = + !lastFits || + hasWallChildOverlap( + lastValid.parentId, + lastValid.position[0], + lastValid.position[1], + live.width, + live.height, + live.id, + ) return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 363840ae88..421933b176 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' -import { readHostWallCeiling } from '../shared/wall-opening-ceiling' +import { readHostWallCeiling, type WallCeilingSceneReader } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -36,7 +36,10 @@ export function wallLocalToWorld( } /** - * Clamps window center position so it stays fully within wall bounds. The Y + * Clamps window center (localX, localY) within wall bounds. + * + * Y is bounded to keep the window's bottom above 0 (floor level) AND its top + * below the wall's effective ceiling, sampled at the window's center X. The * ceiling is the wall's RESOLVED top (storey plane for plane-bound walls, * stored height for explicit ones, minus the elected slab base) — `nodes` is * required because a plane-bound wall's top lives on its level, not on the @@ -48,7 +51,7 @@ export function clampToWall( localY: number, width: number, height: number, - nodes: Readonly>, + sceneOrNodes: Readonly> | WallCeilingSceneReader, ): { clampedX: number; clampedY: number; fits: boolean } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] @@ -57,10 +60,13 @@ export function clampToWall( const minX = width / 2 const maxX = wallLength - width / 2 - const sceneReader = { - get: (id: AnyNodeId) => nodes[id], - nodes: () => nodes, - } + const sceneReader: WallCeilingSceneReader = + typeof (sceneOrNodes as WallCeilingSceneReader).nodes === 'function' + ? (sceneOrNodes as WallCeilingSceneReader) + : { + get: (id: AnyNodeId) => (sceneOrNodes as Readonly>)[id], + nodes: () => sceneOrNodes as Readonly>, + } function checkFits(testX: number, testY: number) { const leftHeight = readHostWallCeiling(wallNode.id, sceneReader, testX - width / 2) From e2bdea390b65d8a58f1b90efb4027fbbce9a6d87 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 10:52:05 +0200 Subject: [PATCH 06/12] fix(spatial-grid): evaluate lowest ceiling across item span and ensure wall cache fallback - Sample wall heights at tStart, tEnd, and tCenter in canPlaceOnWall to prevent wall items from breaching sloped ceilings. - Add getWall fallback in spatialGridManager to read directly from useScene store when walls are not yet cached. --- .../spatial-grid/spatial-grid-manager.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index ddcd8515ff..b7c89b63cd 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -352,8 +352,19 @@ export class SpatialGridManager { return this.wallGrids.get(levelId)! } + private getWall(wallId: string): WallNode | undefined { + const cached = this.walls.get(wallId) + if (cached) return cached + const fromScene = useScene.getState().nodes[wallId as AnyNodeId] + if (fromScene && fromScene.type === 'wall') { + this.walls.set(wallId, fromScene as WallNode) + return fromScene as WallNode + } + return undefined + } + private getWallLength(wallId: string): number { - const wall = this.walls.get(wallId) + const wall = this.getWall(wallId) if (!wall) return 0 const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] @@ -361,7 +372,7 @@ export class SpatialGridManager { } private getWallHeight(wallId: string, t?: number): number { - const wall = this.walls.get(wallId) + const wall = this.getWall(wallId) if (!wall) return 0 const offset = (wall.endHeightOffset && t !== undefined) ? wall.endHeightOffset * t : 0 if (wall.height != null) return wall.height + offset @@ -774,10 +785,17 @@ export class SpatialGridManager { if (wallLength === 0) { return { valid: false, conflictIds: [] } } + const [itemWidth, itemHeight] = dimensions // Convert local X position to parametric t (0-1) const tCenter = localX / wallLength - const wallHeight = this.getWallHeight(wallId, tCenter) - const [itemWidth, itemHeight] = dimensions + const halfW = itemWidth / wallLength / 2 + const tStart = Math.max(0, Math.min(1, tCenter - halfW)) + const tEnd = Math.max(0, Math.min(1, tCenter + halfW)) + const hStart = this.getWallHeight(wallId, tStart) + const hEnd = this.getWallHeight(wallId, tEnd) + const hCenter = this.getWallHeight(wallId, tCenter) + const wallHeight = Math.min(hStart, hEnd, hCenter) + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( wallId, wallLength, From b7927c4fc8898844fc39b4c57e6150ada023b9e1 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 11:09:45 +0200 Subject: [PATCH 07/12] fix(openings): use curve length for curved wall ceiling checks - Calculate localT using getWallCurveLength on curved walls in readHostWallCeiling and getWallLength for Bugbot / static analysis compliance. --- .../src/hooks/spatial-grid/spatial-grid-manager.ts | 5 +++++ packages/nodes/src/shared/wall-opening-ceiling.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b7c89b63cd..0328319f5c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -14,6 +14,7 @@ import { type WallSlabSupport, } from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { getWallCurveLength, isCurvedWall } from '../../systems/wall/wall-curve' import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' @@ -366,6 +367,10 @@ export class SpatialGridManager { private getWallLength(wallId: string): number { const wall = this.getWall(wallId) if (!wall) return 0 + // Use arc length for curved walls (matching curved slope parameters) + if (isCurvedWall(wall)) { + return getWallCurveLength(wall) + } const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] return Math.hypot(dx, dy) diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index 2db62561d7..3551a20a42 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -1,7 +1,9 @@ import { type AnyNode, type AnyNodeId, + getWallCurveLength, getWallEffectiveHeightForNodes, + isCurvedWall, type WallNode, } from '@pascal-app/core' @@ -49,9 +51,13 @@ export function readHostWallCeiling( const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY if (positionS !== undefined) { - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) + // Added for Bugbot / static analysis compliance: openings on curved walls + // are currently guarded at the tool level and unreachable at runtime, but + // we compute parametric t against arc length (getWallCurveLength) for parity + // with applyWallEndHeightSlope's vertex extrusion. + const length = isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) if (length > 1e-4) { const localT = Math.max(0, Math.min(1, positionS / length)) return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes(), localT)) From 86950fe5e4d885d682d6192b657ea4e864b9fa14 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 11:21:30 +0200 Subject: [PATCH 08/12] refactor(wall): unify wall slope calculation across viewer and openings using chord frame - Simplify applyWallEndHeightSlope in wall-system to linearly slope using wall-local X across the chord frame. - Keep readHostWallCeiling and getWallLength unified on chord length. --- .../spatial-grid/spatial-grid-manager.ts | 5 -- .../nodes/src/shared/wall-opening-ceiling.ts | 16 +++--- .../viewer/src/systems/wall/wall-system.tsx | 54 +------------------ 3 files changed, 9 insertions(+), 66 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 0328319f5c..b7c89b63cd 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -14,7 +14,6 @@ import { type WallSlabSupport, } from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' -import { getWallCurveLength, isCurvedWall } from '../../systems/wall/wall-curve' import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' @@ -367,10 +366,6 @@ export class SpatialGridManager { private getWallLength(wallId: string): number { const wall = this.getWall(wallId) if (!wall) return 0 - // Use arc length for curved walls (matching curved slope parameters) - if (isCurvedWall(wall)) { - return getWallCurveLength(wall) - } const dx = wall.end[0] - wall.start[0] const dy = wall.end[1] - wall.start[1] return Math.hypot(dx, dy) diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts index 3551a20a42..a8ae2ff483 100644 --- a/packages/nodes/src/shared/wall-opening-ceiling.ts +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -1,9 +1,7 @@ import { type AnyNode, type AnyNodeId, - getWallCurveLength, getWallEffectiveHeightForNodes, - isCurvedWall, type WallNode, } from '@pascal-app/core' @@ -51,13 +49,13 @@ export function readHostWallCeiling( const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined if (!wall) return Number.POSITIVE_INFINITY if (positionS !== undefined) { - // Added for Bugbot / static analysis compliance: openings on curved walls - // are currently guarded at the tool level and unreachable at runtime, but - // we compute parametric t against arc length (getWallCurveLength) for parity - // with applyWallEndHeightSlope's vertex extrusion. - const length = isCurvedWall(wall) - ? getWallCurveLength(wall) - : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + // Added to make Bugbot happy (opening placement on curved walls is guarded + // at the tool level and is never reached in practice): length is computed + // in the wall-local chord frame (0 to hypot(end - start)) to match + // applyWallEndHeightSlope in WallSystem. + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) if (length > 1e-4) { const localT = Math.max(0, Math.min(1, positionS / length)) return Math.max(0.01, resolveWallOpeningCeiling(wall, scene.nodes(), localT)) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index b7eadf1bf3..c0321b36c1 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -13,7 +13,6 @@ import { getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, - getWallArcData, getWallThickness, isCurvedWall, type Point2D, @@ -948,7 +947,6 @@ function applyWallEndHeightSlope( wallLength: number, topY: number, bodyHeight: number, - localArc?: { center: { x: number; z: number }; direction: number } | null, ): void { const rawOffset = wallNode.endHeightOffset if (!rawOffset || wallLength < 1e-9) { @@ -957,52 +955,10 @@ function applyWallEndHeightSlope( const minEndHeight = 0.01 const endHeightOffset = Math.max(rawOffset, -(bodyHeight - minEndHeight)) const position = geometry.getAttribute('position') as THREE.BufferAttribute - - const getSignedAngleDiff = (from: number, to: number) => { - let diff = to - from - while (diff > Math.PI) diff -= Math.PI * 2 - while (diff < -Math.PI) diff += Math.PI * 2 - return diff - } - - let startAngle = 0 - let delta = 0 - if (localArc) { - // Determine start angle of the arc from local origin (0,0) - startAngle = Math.atan2(0 - localArc.center.z, 0 - localArc.center.x) - // Determine end angle of the arc at (wallLength, 0) - const endAngle = Math.atan2(0 - localArc.center.z, wallLength - localArc.center.x) - delta = getSignedAngleDiff(startAngle, endAngle) - - // Ensure delta has the correct sign matching the arc direction. - // For exact semicircles, getSignedAngleDiff might return -PI when we want PI. - if (localArc.direction > 0 && delta < -1e-6) { - delta += Math.PI * 2 - } else if (localArc.direction < 0 && delta > 1e-6) { - delta -= Math.PI * 2 - } - } for (let i = 0; i < position.count; i++) { if (Math.abs(position.getY(i) - topY) > 1e-4) continue - - let t: number - if (localArc && Math.abs(delta) > 1e-6) { - const px = position.getX(i) - const pz = position.getZ(i) - const vertexAngle = Math.atan2(pz - localArc.center.z, px - localArc.center.x) - let vertexDelta = vertexAngle - startAngle - - // Unwrap vertexDelta so it stays close to the expected angle for this position. - // This prevents vertices on the end caps from wrapping around the PI boundary. - const expectedDelta = delta * (px / wallLength) - while (vertexDelta - expectedDelta > Math.PI) vertexDelta -= Math.PI * 2 - while (vertexDelta - expectedDelta < -Math.PI) vertexDelta += Math.PI * 2 - - t = THREE.MathUtils.clamp(vertexDelta / delta, 0, 1) - } else { - t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) - } + const t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) position.setY(i, topY + endHeightOffset * t) } position.needsUpdate = true @@ -1091,15 +1047,9 @@ export function generateExtrudedWall( bevelEnabled: false, }) - // Rotate so extrusion direction (Z) becomes height direction (Y) - const arc = isCurvedWall(wallNode) ? getWallArcData(wallNode) : null - const localArc = arc - ? { center: worldToLocal(arc.center), direction: arc.direction } - : null - geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) - applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, height, localArc) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, height) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) From 9746aee37003f318d0c74787229ecf4f50b4d41b Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 11:34:12 +0200 Subject: [PATCH 09/12] fix(wall): clamp negative endHeightOffset in resolveWallTop to match mesh clamp - Ensure resolveWallTop clamps negative endHeightOffset to -(bodyHeight - 0.01) so mathematical ceiling queries match rendered 3D geometry. --- packages/core/src/systems/wall/wall-top.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 65e1f7a8ae..104b0d4a86 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -38,7 +38,10 @@ export function resolveWallTop( top = electedBase > 0 ? electedBase + wall.height : wall.height } if (wall.endHeightOffset && t !== undefined) { - top += wall.endHeightOffset * t + const bodyHeight = Math.max(0.01, top - electedBase) + const minEndHeight = 0.01 + const clampedOffset = Math.max(wall.endHeightOffset, -(bodyHeight - minEndHeight)) + top += clampedOffset * t } return top } From 8f6f4c25b9548cfa89ab313c571ded513c18cb08 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 11:47:45 +0200 Subject: [PATCH 10/12] fix(spatial-grid): route getWallHeight through canonical getWallEffectiveHeightForNodes - Ensure all wall heights in spatialGridManager apply slope clamping matching 3D mesh extrusion and vertical-model architecture rules. --- .../spatial-grid/spatial-grid-manager.ts | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b7c89b63cd..dbd153ff4f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -374,27 +374,8 @@ export class SpatialGridManager { private getWallHeight(wallId: string, t?: number): number { const wall = this.getWall(wallId) if (!wall) return 0 - const offset = (wall.endHeightOffset && t !== undefined) ? wall.endHeightOffset * t : 0 - if (wall.height != null) return wall.height + offset - - const nodes = useScene.getState().nodes - const levelId = resolveNodeLevelId(wall, nodes) - const support = this.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ) - return resolveWallEffectiveHeight( - wall, - getWallPlaneTop(wall, levelId, nodes), - support.elevation, - t - ) + const nodes = useScene.getState().nodes as Record + return getWallEffectiveHeightForNodes(wall, nodes, t) } private getCeilingGrid(ceilingId: string): SpatialGrid { From 8f7ce5c7c58b036e4b3aad16ddb938bf3831d812 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 12:19:18 +0200 Subject: [PATCH 11/12] fix(wall): propagate slope parameter across room ceilings, overlays, and handles - Sample start (t=0) and end (t=1) wall tops for space-detection room ceilings and level height tracking. - Pass endpoint t to corner handles and midpoint t to height/side handles in wall-move-side-handles. - Sample tCenter along sloped segments for wall top snap beacon highlights. - Interpolate 3D measurement guide paths with getWallEffectiveHeightForNodes across wall slope. - Expose start and end top targets in elevation guides for sloped walls. --- packages/core/src/lib/space-detection.ts | 5142 +++++++++-------- packages/core/src/services/level-height.ts | 238 +- .../editor/wall-measurement-label.tsx | 1134 ++-- .../editor/wall-move-side-handles.tsx | 2094 +++---- .../editor/wall-snap-beacon-layer.tsx | 621 +- packages/editor/src/lib/elevation-guides.ts | 545 +- 6 files changed, 4906 insertions(+), 4868 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3594b0c6cc..b17032bb28 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1,2569 +1,2573 @@ -import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id' -import { - type AnyNodeId, - CeilingNode, - type CeilingNode as CeilingNodeType, - type LevelNode, - SlabNode, - type SlabNode as SlabNodeType, - type WallNode, - ZoneNode, - type ZoneNode as ZoneNodeType, -} from '../schema' -import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' -import { - CEILING_CLAMP_MARGIN, - findLevelAboveId, - getCeilingClampBound, - getLevelBelow, - getLevelElevations, - getStoredLevelHeight, -} from '../services/storey' -import { - activeSceneCommitNodeIds, - getSceneHistoryPauseDepth, - pauseSceneHistory, - resumeSceneHistory, - subscribeSceneCommits, -} from '../store/history-control' -import { computeWallSlabSupport } from '../systems/slab/slab-support' -import { - getClampedWallCurveOffset, - getWallCurveFrameAt, - isCurvedWall, -} from '../systems/wall/wall-curve' -import { resolveWallTop } from '../systems/wall/wall-top' -import { simplifyClosedPolygon } from './polygon-geometry' -import { - distanceToSegment, - type IndexedTopologyDelta, - RoomTopologyIndex, -} from './room-topology-index' -import { levelBaseElevationAt } from './terrain-support' - -type Point2D = { x: number; y: number } - -export type SpaceBoundaryFace = { - wallId: WallNode['id'] - face: 'front' | 'back' - points: Array<[number, number]> -} - -export type Space = { - id: string - levelId: string - polygon: Array<[number, number]> - wallIds: Array - boundaryFaces: SpaceBoundaryFace[] - isExterior: boolean -} - -export type SpaceTopologyReconcileEvent = { - levelId: string - strategy: 'indexed' | 'fallback' - examinedWallIds: string[] - affectedBeforeRoomCount: number - affectedCurrentRoomCount: number -} - -export type SpaceDetectionSyncOptions = { - onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void -} - -type ExtractedRoom = { - polygon: Point2D[] - boundaryFaces: SpaceBoundaryFace[] -} - -type WallSideUpdate = { - wallId: string - frontSide: 'interior' | 'exterior' | 'unknown' - backSide: 'interior' | 'exterior' | 'unknown' -} - -type DetectedRoom = { - poly: Point2D[] - sig: string - centroid: Point2D - area: number - bbox: ReturnType -} - -export type AutoSlabSyncPlan = { - create: SlabNodeType[] - update: Array<{ id: SlabNodeType['id']; data: Partial }> - delete: Array -} - -export type AutoSlabPlanningContext = { - elevationForRoom?: (polygon: Array<[number, number]>) => number | undefined - previousElevationForRoom?: (polygon: Array<[number, number]>) => number | undefined -} - -export type AutoCeilingSyncPlan = { - create: CeilingNodeType[] - update: Array<{ id: CeilingNodeType['id']; data: Partial }> - delete: Array - reparent: Array<{ id: AnyNodeId; parentId: CeilingNodeType['id'] }> -} - -export type AutoZoneSyncPlan = { - update: Array<{ id: ZoneNodeType['id']; data: Partial }> -} - -const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 -const CEILING_HEIGHT_EPSILON = 1e-6 -const ROOM_CURVE_TOLERANCE = 0.04 -const MAX_CURVE_SUBDIVISION_DEPTH = 6 -const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 -const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 -// A wall endpoint within this distance of another wall's interior is treated as a -// T-junction and splits that wall (see `splitStraightWallAtVertices`). -const WALL_JUNCTION_TOLERANCE = 0.08 -// An unmatched auto slab/ceiling whose polygon is still substantially covered -// by a detected room was absorbed by a room merge — the surviving auto surface -// owns that area, so keeping it would z-fight and it is deleted. Below this -// coverage the room genuinely ceased to exist (e.g. an enclosing wall was -// deleted) and the node is demoted to manual so user data survives. -const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 -const COVERAGE_SAMPLE_STEPS = 12 -// Rewrite deadband for an existing auto surface's elevation/height: below this -// the derived plane is the same plane and writing it would churn history. -const ROOM_VERTICAL_PLANE_EPSILON = 1e-3 - -// Pure planner callers omit `heightForRoom`, so auto ceilings keep their -// height-less level-following behavior. The live room sync supplies a height -// derived from the enclosing walls' own bases and tops. -export type AutoCeilingPlanningContext = { - /** Stored storey height of the level being planned (floor-to-floor). */ - storeyHeight?: number - /** - * Stage 3-B clamp-bound resolver for a polygon on the planned level: - * `min(storey plane, lowest covering-slab underside from the level - * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent - * (pure-planner callers without a nodes record), the bound degrades to - * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. - */ - ceilingClampBound?: (polygon: Array<[number, number]>) => number - heightForRoom?: (polygon: Array<[number, number]>) => number | undefined - previousHeightForRoom?: (polygon: Array<[number, number]>) => number | undefined - childPosition?: (childId: AnyNodeId) => [number, number] | undefined -} - -function pointFromTuple(point: [number, number]): Point2D { - return { x: point[0], y: point[1] } -} - -function pointToTuple(point: Point2D): [number, number] { - return [point.x, point.y] -} - -function pointKey(point: Point2D) { - return `${point.x.toFixed(3)},${point.y.toFixed(3)}` -} - -function polygonArea(points: Point2D[]) { - let area = 0 - for (let i = 0; i < points.length; i++) { - const a = points[i] - const b = points[(i + 1) % points.length] - if (!(a && b)) continue - area += a.x * b.y - b.x * a.y - } - return area / 2 -} - -function minRotationSignature(keys: string[]) { - if (keys.length === 0) return '' - let best = '' - for (let i = 0; i < keys.length; i++) { - const rotated = [...keys.slice(i), ...keys.slice(0, i)] - const value = rotated.join('|') - if (!best || value < best) best = value - } - return best -} - -function polygonSignature(points: Point2D[]) { - const keys = points.map(pointKey) - const forward = minRotationSignature(keys) - const reversed = minRotationSignature([...keys].reverse()) - return forward < reversed ? forward : reversed -} - -function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { - return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance -} - -function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) { - const deduped: Point2D[] = [] - - for (const point of points) { - const previous = deduped[deduped.length - 1] - if (previous && samePointWithinTolerance(previous, point, tolerance)) { - continue - } - deduped.push(point) - } - - const firstPoint = deduped[0] - const lastPoint = deduped[deduped.length - 1] - if ( - deduped.length > 2 && - firstPoint && - lastPoint && - samePointWithinTolerance(firstPoint, lastPoint, tolerance) - ) { - deduped.pop() - } - - return deduped -} - -function pointInPolygon(point: Point2D, polygon: Point2D[]) { - if (polygon.length < 3) return false - - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i]?.x ?? 0 - const yi = polygon[i]?.y ?? 0 - const xj = polygon[j]?.x ?? 0 - const yj = polygon[j]?.y ?? 0 - - const intersect = - yi > point.y !== yj > point.y && - point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi - if (intersect) inside = !inside - } - - return inside -} - -function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) { - return polygons.some((polygon) => pointInPolygon(point, polygon)) -} - -function polygonCentroid(points: Point2D[]) { - const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { - x: 0, - y: 0, - }) - - return { - x: sum.x / Math.max(points.length, 1), - y: sum.y / Math.max(points.length, 1), - } -} - -function bboxOf(points: Point2D[]) { - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const point of points) { - minX = Math.min(minX, point.x) - minY = Math.min(minY, point.y) - maxX = Math.max(maxX, point.x) - maxY = Math.max(maxY, point.y) - } - - return { minX, minY, maxX, maxY } -} - -function bboxOverlapArea(a: ReturnType, b: ReturnType) { - const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)) - const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)) - return ix * iy -} - -// Fraction of `subject`'s area lying inside any of `covers`, estimated by -// sampling a grid of cell centers over the subject's bbox. Cheap and robust -// enough for the merge-vs-demote decision; exact polygon clipping would be a -// heavy dependency for a 60% threshold. -function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) { - if (subject.length < 3 || covers.length === 0) return 0 - - const bbox = bboxOf(subject) - const width = bbox.maxX - bbox.minX - const height = bbox.maxY - bbox.minY - - let inside = 0 - let covered = 0 - for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) { - for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) { - const point = { - x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width, - y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height, - } - if (!pointInPolygon(point, subject)) continue - inside += 1 - if (pointInAnyPolygon(point, covers)) covered += 1 - } - } - - if (inside === 0) { - return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0 - } - - return covered / inside -} - -// Demoted auto surfaces keep their polygon untouched, so a re-closed room -// usually hits the exact-signature manual check. Coverage handles the rest: -// a room split across multiple manual surfaces AND a single manual surface -// spanning multiple rooms both suppress a replacement auto surface — what -// matters is that the ROOM is already substantially covered, not that any -// one manual surface belongs to it (a per-surface "mostly inside the room" -// filter dropped multi-room slabs and resurrected deleted auto slabs). -function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { - return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD -} - -function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { - let minDistance = Number.POSITIVE_INFINITY - for (let index = 0; index < polygon.length; index += 1) { - const start = polygon[index] - const end = polygon[(index + 1) % polygon.length] - if (!(start && end)) continue - minDistance = Math.min( - minDistance, - distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)), - ) - } - return minDistance -} - -function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { - const sampled = sampleWallPointsForRoomDetection(wall) - if (sampled.length === 0) return false - - const candidates = - sampled.length === 2 - ? [ - sampled[0]!, - { - x: (sampled[0]!.x + sampled[1]!.x) / 2, - y: (sampled[0]!.y + sampled[1]!.y) / 2, - }, - sampled[1]!, - ] - : sampled - - const matchingPoints = candidates.filter( - (point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, - ) - - return matchingPoints.length >= 2 -} - -/** - * The clamp bound for a ceiling polygon under this planning context — - * the context's cross-level resolver when provided, else the plane-only - * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. - */ -function resolveCeilingClampBound( - polygon: Array<[number, number]>, - context: AutoCeilingPlanningContext, -) { - if (context.ceilingClampBound) return context.ceilingClampBound(polygon) - return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN -} - -/** - * The base a boundary wall actually stands on, in level-local metres. - * - * Resolved the same way the wall renderer resolves it — the level base under - * the wall's own start point (sculpted ground or the flat plane), the slab - * election on top of that, plus the wall's stored `supportOffset`. Reading the - * ground for `GROUND_SUPPORT_ID` walls only is what left stamped room presets - * flat: `resolveWallSupportSlabPatch` writes no host at all for a wall on bare - * terrain, so the sentinel is a hint about pointer intent, never a precondition - * for standing on the ground. - */ -function boundaryWallBase( - wall: WallNode, - walls: WallNode[], - supportSlabs: readonly SlabNodeType[], - nodes: Record, - levelId: string, -): number { - const levelBase = levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]) - const offset = wall.supportOffset ?? 0 - if (wall.supportSlabId === GROUND_SUPPORT_ID) return levelBase + offset - return ( - computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null, null, levelBase) - .elevation + offset - ) -} - -/** - * The plane an auto floor/ceiling takes when its enclosing walls disagree. - * - * `floor` takes the HIGHEST wall base and `ceiling` the LOWEST wall top — - * the only pair that cannot open a hole: a floor at the lowest base would - * leave daylight under every wall standing higher, and a ceiling at the - * highest top would poke out through the shortest wall. Both surfaces stay - * flat (a slab is one scalar elevation by schema; see `vertical-model.md`), - * so a room on a slope is a level room cut into the hillside — the walls on - * the low side extend down to meet it, which is what their `baseSegments` - * fill-down already does. - * - * Non-finite inputs are the one abstain: a broken graph should keep the - * existing placement rather than move a surface to NaN. - */ -function roomFloorPlane(wallBases: number[]): number | undefined { - if (wallBases.length === 0 || wallBases.some((value) => !Number.isFinite(value))) return undefined - return Math.max(...wallBases) -} - -function roomCeilingPlane(wallTops: number[]): number | undefined { - if (wallTops.length === 0 || wallTops.some((value) => !Number.isFinite(value))) return undefined - return Math.min(...wallTops) -} - -function autoRoomVerticalPlacements( - spaces: readonly Space[], - walls: WallNode[], - supportSlabs: readonly SlabNodeType[], - nodes: Record, - storeyHeight: number, -) { - const wallsById = new Map(walls.map((wall) => [wall.id, wall])) - const placements = new Map() - - for (const space of spaces) { - const boundaryWalls = space.wallIds.flatMap((id) => { - const wall = wallsById.get(id) - return wall ? [wall] : [] - }) - if (boundaryWalls.length !== space.wallIds.length) continue - - const wallBases = boundaryWalls.map((wall) => - boundaryWallBase(wall, walls, supportSlabs, nodes, space.levelId), - ) - const base = roomFloorPlane(wallBases) - if (base === undefined) continue - - const wallTops = boundaryWalls.map((wall, index) => - resolveWallTop(wall, storeyHeight, wallBases[index] ?? base), - ) - const top = roomCeilingPlane(wallTops) - if (top === undefined) continue - - placements.set(polygonSignature(space.polygon.map(pointFromTuple)), { - slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION, - ceilingHeight: top - CEILING_CLAMP_MARGIN, - }) - } - - return placements -} - -function getWallDirection(wall: Pick) { - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - - if (length < 1e-9) { - return { - point: pointFromTuple(wall.start), - tangent: { x: 1, y: 0 }, - normal: { x: 0, y: 1 }, - } - } - - const tangent = { x: dx / length, y: dy / length } - return { - point: { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - }, - tangent, - normal: { x: -tangent.y, y: tangent.x }, - } -} - -function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { - const dx = end.x - start.x - const dy = end.y - start.y - const lengthSquared = dx * dx + dy * dy - - if (lengthSquared < 1e-9) { - return Math.hypot(point.x - start.x, point.y - start.y) - } - - const cross = (point.x - start.x) * dy - (point.y - start.y) * dx - return Math.abs(cross) / Math.sqrt(lengthSquared) -} - -function sampleWallPointsForRoomDetection( - wall: Pick, - tolerance = ROOM_CURVE_TOLERANCE, -) { - const start = { x: wall.start[0], y: wall.start[1] } - const end = { x: wall.end[0], y: wall.end[1] } - - if (!isCurvedWall(wall)) { - return [start, end] - } - - const subdivide = ( - t0: number, - p0: Point2D, - t1: number, - p1: Point2D, - depth: number, - ): Point2D[] => { - const midT = (t0 + t1) / 2 - const midPoint = getWallCurveFrameAt(wall, midT).point - const deviation = pointLineDistance(midPoint, p0, p1) - - if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) { - return [p0, p1] - } - - const left = subdivide(t0, p0, midT, midPoint, depth + 1) - const right = subdivide(midT, midPoint, t1, p1, depth + 1) - return [...left.slice(0, -1), ...right] - } - - return subdivide(0, start, 1, end, 0) -} - -function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { - const dx = end.x - start.x - const dy = end.y - start.y - const lengthSquared = dx * dx + dy * dy - if (lengthSquared < 1e-12) { - return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } - } - const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared - const clampedT = Math.max(0, Math.min(1, t)) - const projX = start.x + clampedT * dx - const projY = start.y + clampedT * dy - return { t, distance: Math.hypot(point.x - projX, point.y - projY) } -} - -// Break a straight wall at any junction vertex (another wall's endpoint) that -// lands on its interior, returning the ordered polyline [start, …splits, end]. -// Splitting at the *vertex* position (not the projection) keeps the split node's -// key identical to the touching wall's endpoint so the two share a graph node. -function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { - const length = Math.hypot(end.x - start.x, end.y - start.y) - if (length < 1e-9) return [start, end] - - const interior: Array<{ point: Point2D; t: number }> = [] - for (const vertex of vertices) { - const { t, distance } = segmentProjection(vertex, start, end) - if (distance > WALL_JUNCTION_TOLERANCE) continue - const along = t * length - if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue - interior.push({ point: vertex, t }) - } - interior.sort((a, b) => a.t - b.t) - - const ordered: Point2D[] = [start] - let lastKey = pointKey(start) - for (const { point } of interior) { - const key = pointKey(point) - if (key === lastKey) continue - ordered.push(point) - lastKey = key - } - if (lastKey !== pointKey(end)) ordered.push(end) - return ordered -} - -function extractRooms(walls: WallNode[]): ExtractedRoom[] { - if (walls.length < 3) return [] - - type HalfEdge = { - id: string - reverseId: string - fromKey: string - toKey: string - angle: number - points: Point2D[] - wallId: WallNode['id'] - face: 'front' | 'back' - } - type Node = { point: Point2D; outgoing: string[] } - - const graph = new Map() - const halfEdges = new Map() - - const upsertNode = (point: Point2D) => { - const key = pointKey(point) - if (!graph.has(key)) { - graph.set(key, { point: { ...point }, outgoing: [] }) - } - return key - } - - // Planarize first: collect every wall endpoint as a candidate graph vertex so - // straight walls can be split at T-junctions where another wall ends mid-span. - // Without this the touching wall's endpoint is a dangling degree-1 node and the - // enclosed area (e.g. a room added against the middle of an existing wall) - // never forms a cycle. - const vertexByKey = new Map() - for (const wall of walls) { - for (const tuple of [wall.start, wall.end]) { - const point = pointFromTuple(tuple) - const key = pointKey(point) - if (!vertexByKey.has(key)) vertexByKey.set(key, point) - } - } - const vertices = [...vertexByKey.values()] - - for (const wall of walls) { - const start = pointFromTuple(wall.start) - const end = pointFromTuple(wall.end) - if (samePointWithinTolerance(start, end)) continue - - // Curved walls keep their sampled polyline as one edge; straight walls split - // into consecutive sub-edges at their interior junction vertices. - const subPolylines: Point2D[][] = isCurvedWall(wall) - ? [sampleWallPointsForRoomDetection(wall)] - : (() => { - const ordered = splitStraightWallAtVertices(start, end, vertices) - const parts: Point2D[][] = [] - for (let index = 0; index < ordered.length - 1; index += 1) { - parts.push([ordered[index]!, ordered[index + 1]!]) - } - return parts - })() - - subPolylines.forEach((points, subIndex) => { - const from = points[0]! - const to = points[points.length - 1]! - const fromKey = upsertNode(from) - const toKey = upsertNode(to) - if (fromKey === toKey) return - - const reversePoints = [...points].reverse() - const forwardId = `${wall.id}#${subIndex}:f` - const reverseId = `${wall.id}#${subIndex}:r` - - halfEdges.set(forwardId, { - id: forwardId, - reverseId, - fromKey, - toKey, - angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), - points, - wallId: wall.id, - face: 'front', - }) - halfEdges.set(reverseId, { - id: reverseId, - reverseId: forwardId, - fromKey: toKey, - toKey: fromKey, - angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), - points: reversePoints, - wallId: wall.id, - face: 'back', - }) - - graph.get(fromKey)?.outgoing.push(forwardId) - graph.get(toKey)?.outgoing.push(reverseId) - }) - } - - const sortedOutgoing = new Map() - for (const [key, node] of graph.entries()) { - const outgoing = [...node.outgoing] - outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) - sortedOutgoing.set(key, outgoing) - } - - const nextEdge = (edgeId: string) => { - const edge = halfEdges.get(edgeId) - if (!edge) return null - - const outgoing = sortedOutgoing.get(edge.toKey) - if (!outgoing || outgoing.length === 0) return null - - const idx = outgoing.indexOf(edge.reverseId) - if (idx === -1) return null - - const nextIdx = (idx - 1 + outgoing.length) % outgoing.length - return outgoing[nextIdx] ?? null - } - - const splitIntoSimpleCycles = (walkEdgeIds: string[]) => { - const cycles: string[][] = [] - const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '') - if (!firstEdge) return cycles - - const pathEdges: string[] = [] - const pathVertices = [firstEdge.fromKey] - const vertexIndex = new Map([[firstEdge.fromKey, 0]]) - - for (const edgeId of walkEdgeIds) { - const edge = halfEdges.get(edgeId) - if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return [] - - pathEdges.push(edgeId) - const repeatedIndex = vertexIndex.get(edge.toKey) - if (repeatedIndex === undefined) { - pathVertices.push(edge.toKey) - vertexIndex.set(edge.toKey, pathVertices.length - 1) - continue - } - - const cycle = pathEdges.slice(repeatedIndex) - if (cycle.length >= 3) cycles.push(cycle) - - for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) { - vertexIndex.delete(pathVertices[index]!) - } - pathVertices.length = repeatedIndex + 1 - pathEdges.length = repeatedIndex - } - - return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [] - } - - const visitedDirected = new Set() - const rooms: ExtractedRoom[] = [] - // A face walk cannot revisit a half-edge, so the half-edge count bounds its - // length. It can revisit a vertex when dangling walls or other graph bridges - // are traced out and back; those excursions are removed below. - const maxSteps = Math.min(2000, halfEdges.size + 10) - - for (const edgeId of halfEdges.keys()) { - if (visitedDirected.has(edgeId)) continue - - const cycleEdgeIds: string[] = [] - let currentEdgeId = edgeId - let valid = true - let closed = false - - for (let step = 0; step < maxSteps; step += 1) { - const currentEdge = halfEdges.get(currentEdgeId) - if (!currentEdge) { - valid = false - break - } - - visitedDirected.add(currentEdgeId) - cycleEdgeIds.push(currentEdgeId) - - const next = nextEdge(currentEdgeId) - if (!next) { - valid = false - break - } - - currentEdgeId = next - if (currentEdgeId === edgeId) { - closed = true - break - } - } - - if (!(valid && closed) || cycleEdgeIds.length < 3) continue - - for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) { - const polygon = dedupeSequentialPoints( - simpleCycleEdgeIds.flatMap((id, index) => { - const points = halfEdges.get(id)?.points ?? [] - return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1) - }), - ) - - if (polygon.length < 3) continue - - 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 - - rooms.push({ - polygon, - boundaryFaces: simpleCycleEdgeIds.flatMap((id) => { - const edge = halfEdges.get(id) - if (!edge) return [] - return [ - { - wallId: edge.wallId, - face: edge.face, - points: edge.points.map(pointToTuple), - }, - ] - }), - }) - } - } - - rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon))) - return rooms -} - -function extractRoomPolygons(walls: WallNode[]): Point2D[][] { - return extractRooms(walls).map((room) => room.polygon) -} - -/** - * True when `wall` lies on the boundary of a room enclosed by `walls`, using the - * same planar room graph the auto slab/ceiling sync uses. The wall builder's - * "Room (auto-close)" mode calls this so drafting stops the moment a segment - * closes a room — whether the chain loops back to its own start or seals a bay - * against the middle of an existing wall (a T-junction). Sharing one graph means - * auto-close and auto-slab detection can never disagree about what is "closed". - */ -export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean { - const roomPolygons = extractRoomPolygons(walls) - if (roomPolygons.length === 0) return false - return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon)) -} - -export function resolveWallSurfaceSides( - wall: Pick, - roomPolygons: Point2D[][], -): Pick { - if (roomPolygons.length === 0) { - return { - frontSide: 'unknown' as const, - backSide: 'unknown' as const, - } - } - - const frame = getWallDirection(wall) - const normalLength = Math.hypot(frame.normal.x, frame.normal.y) - if (normalLength < 1e-9) { - return { - frontSide: wall.frontSide, - backSide: wall.backSide, - } - } - - const normalX = frame.normal.x / normalLength - const normalY = frame.normal.y / normalLength - const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16) - - const frontPoint = { - x: frame.point.x + normalX * sampleDistance, - y: frame.point.y + normalY * sampleDistance, - } - const backPoint = { - x: frame.point.x - normalX * sampleDistance, - y: frame.point.y - normalY * sampleDistance, - } - - const frontInside = pointInAnyPolygon(frontPoint, roomPolygons) - const backInside = pointInAnyPolygon(backPoint, roomPolygons) - - if (frontInside === backInside) { - return { - frontSide: wall.frontSide, - backSide: wall.backSide, - } - } - - return { - frontSide: frontInside ? 'interior' : 'exterior', - backSide: backInside ? 'interior' : 'exterior', - } -} - -function nextAutoRoomName( - nodes: Array<{ - name?: string - }>, - suffix: 'Slab' | 'Ceiling', -) { - let maxIndex = 0 - - for (const node of nodes) { - const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim()) - if (!match) continue - const index = Number(match[1]) - if (Number.isFinite(index)) { - maxIndex = Math.max(maxIndex, index) - } - } - - return `Room ${maxIndex + 1} ${suffix}` -} - -function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) { - return ( - current.length === next.length && - current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) - ) -} - -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 = { - holes: Array> - 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[], - 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) - for (const roomIndex of roomIndices) { - const room = detected[roomIndex] - const assignment = assignments.get(roomIndex) - if (!(room && assignment)) continue - for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) { - assignment.holes.push(clipped.map(pointToTuple)) - assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) - } - } - }) - - return assignments -} - -function partitionCeilingChildren( - ceiling: CeilingNodeType, - roomIndices: number[], - detected: DetectedRoom[], - fallbackRoomIndex: number | undefined, - childPosition: AutoCeilingPlanningContext['childPosition'], -) { - const assignments = new Map() - for (const roomIndex of roomIndices) assignments.set(roomIndex, []) - - for (const childId of ceiling.children) { - const tuple = childPosition?.(childId) - const point = tuple ? pointFromTuple(tuple) : undefined - const boundaryRoomIndices = point - ? roomIndices.filter((roomIndex) => { - const room = detected[roomIndex] - return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false - }) - : [] - const interiorRoomIndex = - point && boundaryRoomIndices.length === 0 - ? roomIndices.find((roomIndex) => { - const room = detected[roomIndex] - return room ? pointInPolygon(point, room.poly) : false - }) - : undefined - const roomIndex = - interiorRoomIndex ?? - (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex) - ? fallbackRoomIndex - : boundaryRoomIndices[0]) ?? - fallbackRoomIndex ?? - roomIndices[0] - if (roomIndex !== undefined) assignments.get(roomIndex)?.push(childId) - } - - 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 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 ceilingMergeSettingsSignature(ceiling: CeilingNodeType) { - return JSON.stringify([ - ceiling.height ?? null, - ceiling.material ?? null, - ceiling.materialPreset ?? null, - ceiling.slots ?? null, - ceiling.visible, - ]) -} - -function slabMergeSettingsSignature(slab: SlabNodeType) { - return JSON.stringify([ - slab.elevation, - slab.thickness, - slab.recessed, - slab.recessedRimElevation ?? null, - slab.fillToTerrain ?? null, - slab.material ?? null, - slab.materialPreset ?? null, - slab.slots ?? null, - slab.visible, - ]) -} - -function slabElevationForReconciledRoom( - source: SlabNodeType, - polygon: Array<[number, number]>, - context: AutoSlabPlanningContext, -) { - const currentDerived = context.elevationForRoom?.(polygon) - if (!context.previousElevationForRoom) return currentDerived ?? source.elevation - - const previousDerived = context.previousElevationForRoom(source.polygon) - const sourceWasDerived = - previousDerived !== undefined && - Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON - return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation -} - -function ceilingHeightForReconciledRoom( - source: CeilingNodeType, - polygon: Array<[number, number]>, - context: AutoCeilingPlanningContext, -) { - if (source.height === undefined) return undefined - - const currentDerived = context.heightForRoom?.(polygon) - if (!context.previousHeightForRoom) return currentDerived ?? source.height - - const previousDerived = context.previousHeightForRoom(source.polygon) - const sourceWasDerived = - previousDerived !== undefined && - Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON - return sourceWasDerived ? (currentDerived ?? source.height) : source.height -} - -function wallGeometrySignature(wall: WallNode, nodes: Record, levelId: string) { - return [ - wall.id, - wall.start[0].toFixed(4), - 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), - wall.supportSlabId ?? 'elected', - (wall.supportOffset ?? 0).toFixed(4), - getClampedWallCurveOffset(wall).toFixed(4), - // The ground under this wall, sampled at the SAME point - // `boundaryWallBase` samples it. Sculpting changes only `site.terrain`, - // so without a terrain term here every signature stays byte-identical - // and the sync early-exits — a room's floor and ceiling could never - // follow ground that moved beneath its walls. - // - // The sample, not the field, and not the resolved base: hashing the - // heightfield would re-trigger every level for a stroke on the far side - // of the lot, and resolving the full slab election would fold slab - // POLYGONS into the signature, which is exactly the delete/recreate - // feedback the comment below is about. Sampling where the placement - // samples means the two cannot disagree in either direction — no missed - // re-run, no spurious one. - // - // Granularity is per stroke, not per dab: live dabs publish to - // `useLiveTerrain` and never touch the scene store, so this runs once on - // release — inside the stroke's own `runAsSingleSceneHistoryStep`, which - // is what puts the moved floor and the terrain that moved it in the same - // undo step. Mid-drag the ground-hosted walls follow the brush while the - // floor waits for release; re-deriving per dab would mean a scene write - // per dab and a floor that jitters under the cursor. - levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]).toFixed(4), - ].join('|') -} - -function levelWallSnapshot(walls: WallNode[], nodes: Record, levelId: string) { - return walls - .map((wall) => wallGeometrySignature(wall, nodes, levelId)) - .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, nodes, levelId)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, - ) - } - - return snapshots -} - -function buildSpace(levelId: string, room: ExtractedRoom): Space { - const signature = polygonSignature(room.polygon) - return { - id: `space-${levelId}-${signature.slice(0, 12)}`, - levelId, - polygon: room.polygon.map(pointToTuple), - wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))], - boundaryFaces: room.boundaryFaces, - isExterior: false, - } -} - -type RoomSurface = SlabNodeType | CeilingNodeType - -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 roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { - const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId)) - const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId)) - 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 roomHasAutoSurface(room: ExtractedRoom, surfaces: RoomSurface[]) { - return matchesManualFootprint( - room.polygon, - surfaces - .filter((surface) => surface.autoFromWalls) - .map((surface) => surface.polygon.map(pointFromTuple)), - ) -} - -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)) - }) -} - -function detectedRoomsByLevel(nodes: Record) { - 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) - } - return new Map( - [...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)] as const), - ) -} - -type SceneNodes = Record - -function levelChildren(nodes: SceneNodes, levelId: string) { - const level = nodes[levelId] - if (level?.type !== 'level') return [] - return level.children.flatMap((id: string) => { - const node = nodes[id] - return node ? [node] : [] - }) -} - -function changedWallIdsByLevel( - before: SceneNodes, - current: SceneNodes, - candidateIds?: ReadonlySet, -) { - const changes = new Map>() - 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) => { - if (!levelId) return - const ids = changes.get(levelId) ?? new Set() - ids.add(wallId) - changes.set(levelId, ids) - } - - for (const wallId of wallIds) { - const previous = before[wallId]?.type === 'wall' ? (before[wallId] as WallNode) : null - const next = current[wallId]?.type === 'wall' ? (current[wallId] as WallNode) : null - if (previous === next) continue - markChanged(previous?.parentId, wallId) - markChanged(next?.parentId, wallId) - } - - return changes -} - -function descendantLevelIds(nodes: SceneNodes, rootId: string) { - const levelIds = new Set() - const queue = [rootId] - const visited = new Set() - while (queue.length > 0) { - const id = queue.pop()! - if (visited.has(id)) continue - visited.add(id) - const node = nodes[id] - if (!node) continue - if (node.type === 'level') levelIds.add(node.id) - if ('children' in node && Array.isArray(node.children)) queue.push(...node.children) - } - return levelIds -} - -function fallbackLevelIdsForCandidates( - before: SceneNodes, - current: SceneNodes, - candidateIds: ReadonlySet, -) { - const levelIds = new Set() - const addLevelAndLower = (levelId: string | null | undefined, nodes: SceneNodes) => { - if (!levelId) return - levelIds.add(levelId) - const lower = getLevelBelow(levelId, nodes) - if (lower) levelIds.add(lower.id) - } - - for (const id of candidateIds) { - for (const nodes of [before, current]) { - const node = nodes[id] - if (!node) continue - if (node.type === 'level' || node.type === 'building' || node.type === 'site') { - for (const levelId of descendantLevelIds(nodes, node.id)) levelIds.add(levelId) - } else if (node.type === 'slab') { - addLevelAndLower(node.parentId, nodes) - } else if (node.type === 'zone') { - if (node.parentId) levelIds.add(node.parentId) - } - } - } - return levelIds -} - -function sameStringSet(a: readonly string[], b: readonly string[]) { - if (a.length !== b.length) return false - const right = new Set(b) - return a.every((value) => right.has(value)) -} - -type AutoSurfaceMatch = { - detectedAll: DetectedRoom[] - detected: DetectedRoom[] - existingAuto: TSurface[] - compatibleMergesByRoomIndex: Map - matchedDetectedIndices: Set - roomIndexBySurfaceId: Map - sourceSurfaceIdByRoomIndex: Map - polygonBySurfaceId: Map> - delete: Array - demote: Array<{ id: TSurface['id']; data: Partial }> -} - -function matchAutoSurfaces( - roomPolygons: Point2D[][], - existingSurfaces: TSurface[], - mergeSettingsSignature: (surface: TSurface) => string, -): AutoSurfaceMatch { - const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls) - const manualSignatures = new Set( - manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))), - ) - const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple)) - const detectedAll: DetectedRoom[] = roomPolygons - .map((poly) => ({ - poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( - pointFromTuple, - ), - sig: '', - centroid: { x: 0, y: 0 }, - area: 0, - bbox: bboxOf([]), - })) - .map((room) => ({ - ...room, - sig: polygonSignature(room.poly), - centroid: polygonCentroid(room.poly), - area: Math.abs(polygonArea(room.poly)), - bbox: bboxOf(room.poly), - })) - const detected = detectedAll.filter( - ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), - ) - const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls) - const metadata = existingAuto.map((surface) => { - const poly = surface.polygon.map(pointFromTuple) - return { - surface, - sig: polygonSignature(poly), - centroid: polygonCentroid(poly), - area: Math.abs(polygonArea(poly)), - bbox: bboxOf(poly), - } - }) - - const conflictingSurfaceIds = new Set() - const conflictingRoomIndices = new Set() - const compatibleMergesByRoomIndex = new Map() - detected.forEach((room, roomIndex) => { - const contributors = existingAuto.filter( - (surface) => - polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >= - ORPHAN_MERGE_COVERAGE_THRESHOLD, - ) - if (contributors.length < 2) return - if (new Set(contributors.map(mergeSettingsSignature)).size > 1) { - conflictingRoomIndices.add(roomIndex) - for (const surface of contributors) conflictingSurfaceIds.add(surface.id) - return - } - compatibleMergesByRoomIndex.set(roomIndex, contributors) - }) - - const matchedSurfaceIds = new Set() - const matchedDetectedIndices = new Set() - const roomIndexBySurfaceId = new Map() - const sourceSurfaceIdByRoomIndex = new Map() - const polygonBySurfaceId = new Map>() - const autoBySignature = new Map>() - for (const entry of metadata) { - const bucket = autoBySignature.get(entry.sig) ?? [] - bucket.push(entry) - autoBySignature.set(entry.sig, bucket) - } - - detected.forEach((room, index) => { - if (conflictingRoomIndices.has(index)) { - matchedDetectedIndices.add(index) - return - } - const existing = autoBySignature.get(room.sig)?.shift() - if (!existing) return - matchedDetectedIndices.add(index) - matchedSurfaceIds.add(existing.surface.id) - roomIndexBySurfaceId.set(existing.surface.id, index) - sourceSurfaceIdByRoomIndex.set(index, existing.surface.id) - polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple)) - }) - - const remainingDetected = detected - .map((room, index) => ({ room, index })) - .filter(({ index }) => !matchedDetectedIndices.has(index)) - .sort((left, right) => right.room.area - left.room.area) - const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id)) - - for (const { room, index } of remainingDetected) { - let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null - for (const entry of remainingAuto) { - if (matchedSurfaceIds.has(entry.surface.id)) continue - const distance = Math.hypot( - room.centroid.x - entry.centroid.x, - room.centroid.y - entry.centroid.y, - ) - const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999 - const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio))) - if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5) continue - const score = distance + areaPenalty * 0.35 - if (!bestMatch || score < bestMatch.score) bestMatch = { entry, score } - } - if (!bestMatch) continue - matchedDetectedIndices.add(index) - matchedSurfaceIds.add(bestMatch.entry.surface.id) - roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index) - sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id) - polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple)) - } - - detected.forEach((room, index) => { - if (sourceSurfaceIdByRoomIndex.has(index)) return - let bestSource: { id: string; coverage: number } | null = null - for (const entry of metadata) { - const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]) - if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue - bestSource = { id: entry.surface.id, coverage } - } - if (bestSource) sourceSurfaceIdByRoomIndex.set(index, bestSource.id) - }) - - const detectedRoomPolygons = detectedAll.map((room) => room.poly) - const deleted: Array = [] - const demote: AutoSurfaceMatch['demote'] = [] - for (const surface of existingAuto) { - if (polygonBySurfaceId.has(surface.id)) continue - if (conflictingSurfaceIds.has(surface.id)) { - demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) - continue - } - const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons) - if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) deleted.push(surface.id) - else demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) - } - - return { - detectedAll, - detected, - existingAuto, - compatibleMergesByRoomIndex, - matchedDetectedIndices, - roomIndexBySurfaceId, - sourceSurfaceIdByRoomIndex, - polygonBySurfaceId, - delete: deleted, - demote, - } -} - -export function planAutoZonesForLevel( - spaces: readonly Space[], - existingZones: readonly ZoneNodeType[], -): AutoZoneSyncPlan { - const update: AutoZoneSyncPlan['update'] = [] - - for (const zone of existingZones) { - const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple)) - const matchingSpace = - zone.autoFromWalls && zone.boundaryWallIds.length >= 3 - ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds)) - : spaces.find( - (space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature, - ) - if (!matchingSpace) continue - - const data: Partial = {} - if (!zone.autoFromWalls) data.autoFromWalls = true - if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) { - data.boundaryWallIds = matchingSpace.wallIds - } - if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) { - data.polygon = matchingSpace.polygon - } - if (Object.keys(data).length > 0) update.push({ id: zone.id, data }) - } - - return { update } -} - -export function resolveAutoZonePolygon( - zone: Pick, - resolve: (id: AnyNodeId) => unknown, -): ZoneNodeType['polygon'] { - if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3) return zone.polygon - const walls = zone.boundaryWallIds.flatMap((id) => { - const node = resolve(id) - return node && typeof node === 'object' && 'type' in node && node.type === 'wall' - ? [node as WallNode] - : [] - }) - if (walls.length !== zone.boundaryWallIds.length) return zone.polygon - const room = extractRooms(walls).find((candidate) => - sameStringSet( - [...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], - zone.boundaryWallIds, - ), - ) - return room ? room.polygon.map(pointToTuple) : zone.polygon -} - -export function planAutoSlabsForLevel( - roomPolygons: Point2D[][], - existingSlabs: SlabNodeType[], - context: AutoSlabPlanningContext = {}, - namingSlabs: Array<{ name?: string }> = existingSlabs, -): AutoSlabSyncPlan { - const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature) - const { - detected, - existingAuto, - compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, - matchedDetectedIndices: matchedDetectedIdx, - roomIndexBySurfaceId: roomIndexBySlabId, - sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, - delete: slabsToDelete, - demote: slabDemotions, - } = match - const updatesById = new Map< - string, - { polygon: [number, number][]; elevation: number | undefined } - >() - for (const slab of existingAuto) { - const polygon = match.polygonBySurfaceId.get(slab.id) - if (!polygon) continue - updatesById.set(slab.id, { - polygon, - elevation: slabElevationForReconciledRoom(slab, polygon, context), - }) - } - - 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)) - } - - const slabsToUpdate = [ - ...existingAuto - .filter((slab) => updatesById.has(slab.id)) - .flatMap((slab) => { - const update = updatesById.get(slab.id) - if (!update) return [] - const roomIndex = roomIndexBySlabId.get(slab.id) - const openings = - roomIndex == null - ? { holes: slab.holes, holeMetadata: slab.holeMetadata } - : compatibleMergeSlabsByRoomIndex.has(roomIndex) - ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? []) - : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - }) - const data: Partial = {} - if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon - if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes - if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { - data.holeMetadata = openings.holeMetadata - } - if ( - update.elevation !== undefined && - Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON - ) { - data.elevation = update.elevation - } - return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [] - }), - ...slabDemotions, - ] - - const plannedSlabsForNaming: Array<{ name?: string }> = [...namingSlabs] - const slabsToCreate: SlabNodeType[] = [] - for (let index = 0; index < detected.length; index += 1) { - if (matchedDetectedIdx.has(index)) continue - - const room = detected[index] - if (!room) continue - - const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') - plannedSlabsForNaming.push({ name }) - - const polygon = room.poly.map(pointToTuple) - const sourceId = sourceSlabIdByRoomIndex.get(index) - const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined - const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined - const elevation = source - ? slabElevationForReconciledRoom(source, polygon, context) - : context.elevationForRoom?.(polygon) - slabsToCreate.push( - SlabNode.parse({ - name, - polygon, - holes: openings?.holes ?? [], - holeMetadata: openings?.holeMetadata ?? [], - elevation: - elevation !== undefined && Number.isFinite(elevation) - ? elevation - : DEFAULT_AUTO_SLAB_ELEVATION, - thickness: source?.thickness, - recessed: source?.recessed, - recessedRimElevation: source?.recessedRimElevation, - fillToTerrain: source?.fillToTerrain, - material: source?.material, - materialPreset: source?.materialPreset, - slots: source?.slots, - visible: source?.visible, - autoFromWalls: true, - }), - ) - } - - return { - create: slabsToCreate, - update: slabsToUpdate, - delete: slabsToDelete, - } -} - -function syncAutoSlabsForLevel( - levelId: string, - roomPolygons: Point2D[][], - existingSlabs: SlabNodeType[], - sceneStore: any, - context: AutoSlabPlanningContext = {}, - namingSlabs: Array<{ name?: string }> = existingSlabs, -) { - const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs) - - if (plan.delete.length > 0) { - sceneStore.getState().deleteNodes(plan.delete) - } - - if (plan.update.length > 0) { - sceneStore.getState().updateNodes(plan.update) - } - - if (plan.create.length > 0) { - sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) - } - - return plan -} - -export function planAutoCeilingsForLevel( - roomPolygons: Point2D[][], - existingCeilings: CeilingNodeType[], - context: AutoCeilingPlanningContext = {}, - namingCeilings: Array<{ name?: string }> = existingCeilings, -): AutoCeilingSyncPlan { - const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) - const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature) - const { - detected, - existingAuto, - compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, - matchedDetectedIndices: matchedDetectedIdx, - roomIndexBySurfaceId: roomIndexByCeilingId, - sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, - delete: ceilingsToDelete, - demote: ceilingDemotions, - } = match - const updatesById = new Map() - for (const ceiling of existingAuto) { - const polygon = match.polygonBySurfaceId.get(ceiling.id) - if (!polygon) continue - updatesById.set(ceiling.id, { - polygon, - height: ceilingHeightForReconciledRoom(ceiling, polygon, context), - }) - } - - // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab - // created, moved, or thickened on the level above can leave an EXISTING - // manual explicit-height ceiling poking into its solid. Clamp explicit - // heights down to the bound; never raise them — a user-lowered ceiling - // is intent, only an over-bound one is a conflict. Follows-mode - // ceilings (absent height) derive under the bound by construction and - // are skipped, so the clamp can never convert one to an explicit - // height. - const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { - if (ceiling.height == null) return [] - const bound = resolveCeilingClampBound(ceiling.polygon, context) - if (!Number.isFinite(bound)) return [] - return ceiling.height > bound + CEILING_HEIGHT_EPSILON - ? [{ id: ceiling.id, data: { height: bound } }] - : [] - }) - - const openingAssignmentsByCeilingId = new Map< - string, - ReturnType - >() - const childAssignmentsByCeilingId = new Map>() - 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), - ) - childAssignmentsByCeilingId.set( - ceiling.id, - partitionCeilingChildren( - ceiling, - roomIndices, - detected, - roomIndexByCeilingId.get(ceiling.id), - context.childPosition, - ), - ) - } - - const childReparents: AutoCeilingSyncPlan['reparent'] = [] - const ceilingsToUpdate = [ - ...existingAuto - .filter((ceiling) => updatesById.has(ceiling.id)) - .flatMap((ceiling) => { - const update = updatesById.get(ceiling.id) - if (!update) return [] - const roomIndex = roomIndexByCeilingId.get(ceiling.id) - const openings = - roomIndex == null - ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata } - : compatibleMergeCeilingsByRoomIndex.has(roomIndex) - ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? []) - : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - }) - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon - if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes - if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { - data.holeMetadata = openings.holeMetadata - } - const mergeContributors = - roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex) - const children = mergeContributors - ? ([ - ...new Set(mergeContributors.flatMap((contributor) => contributor.children)), - ] as CeilingNodeType['children']) - : roomIndex == null - ? ceiling.children - : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children) - for (const contributor of mergeContributors ?? []) { - if (contributor.id === ceiling.id) continue - for (const childId of contributor.children) { - childReparents.push({ id: childId, parentId: ceiling.id }) - } - } - if (!sameStringSet(ceiling.children, children)) data.children = children - if ( - update.height !== undefined && - (ceiling.height === undefined || - Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON) - ) { - data.height = update.height - } - return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [] - }), - ...ceilingDemotions, - ...manualClamps, - ] - - const plannedCeilingsForNaming: Array<{ name?: string }> = [...namingCeilings] - const ceilingsToCreate: CeilingNodeType[] = [] - for (let index = 0; index < detected.length; index += 1) { - if (matchedDetectedIdx.has(index)) continue - - const room = detected[index] - if (!room) continue - - const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') - plannedCeilingsForNaming.push({ name }) - - const polygon = room.poly.map(pointToTuple) - 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 children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined - const height = source - ? ceilingHeightForReconciledRoom(source, polygon, context) - : context.heightForRoom?.(polygon) - const created = CeilingNode.parse({ - name, - polygon, - children: children ?? [], - holes: openings?.holes ?? [], - holeMetadata: openings?.holeMetadata ?? [], - material: source?.material, - materialPreset: source?.materialPreset, - slots: source?.slots, - visible: source?.visible, - ...(height !== undefined && Number.isFinite(height) ? { height } : {}), - autoFromWalls: true, - }) - ceilingsToCreate.push(created) - for (const childId of children ?? []) { - childReparents.push({ id: childId, parentId: created.id }) - } - } - - return { - create: ceilingsToCreate, - update: ceilingsToUpdate, - delete: ceilingsToDelete, - reparent: childReparents, - } -} - -function syncAutoCeilingsForLevel( - levelId: string, - roomPolygons: Point2D[][], - existingCeilings: CeilingNodeType[], - sceneStore: any, - context: AutoCeilingPlanningContext = {}, - namingCeilings: Array<{ name?: string }> = existingCeilings, -) { - const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings) - - if (plan.update.length > 0) { - sceneStore.getState().updateNodes(plan.update) - } - - if (plan.create.length > 0) { - sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) - } - - if (plan.reparent.length > 0) { - sceneStore - .getState() - .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } }))) - } - - if (plan.delete.length > 0) { - sceneStore.getState().deleteNodes(plan.delete) - } -} - -function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { - const rooms = extractRooms(walls) - const roomPolygons = rooms.map((room) => room.polygon) - const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ - wallId: wall.id, - ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< - WallSideUpdate, - 'frontSide' | 'backSide' - >), - })) - - return { - rooms, - roomPolygons, - spaces: rooms.map((room) => buildSpace(levelId, room)), - wallUpdates, - } -} - -export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { - return detectSpacesFromWalls(levelId, walls) -} - -function runSpaceDetection( - levelIds: string[], - sceneStore: any, - editorStore: any, - nodes: any, - previousNodes: any, - previousRoomsByLevel: Map, -): void { - const { updateNodes } = sceneStore.getState() - const existingSpaces = editorStore.getState().spaces as Record - const nextSpaces: Record = {} - - for (const [spaceId, space] of Object.entries(existingSpaces)) { - if (!levelIds.includes(space.levelId)) { - nextSpaces[spaceId] = space - } - } - - for (const levelId of levelIds) { - const children = levelChildren(nodes, levelId) - const walls = children.filter( - (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, - ) - const slabs = children.filter((node: any) => node?.type === 'slab') - const ceilings = children.filter((node: any) => node?.type === 'ceiling') - const zones = children.filter((node: any) => node?.type === 'zone') - - const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls) - - const changedWallUpdates = wallUpdates.filter((update) => { - const wall = nodes[update.wallId] - return 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, - }, - })), - ) - } - - const levelNode = nodes[levelId] - const storeyHeight = - levelNode?.type === 'level' - ? getStoredLevelHeight(levelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const parsedSlabs: SlabNodeType[] = slabs.map((slab: any) => SlabNode.parse(slab)) - const parsedCeilings: CeilingNodeType[] = ceilings.map((ceiling: any) => - CeilingNode.parse(ceiling), - ) - const previousRooms = previousRoomsByLevel.get(levelId) ?? [] - const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs) - const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings) - const verticalPlacements = autoRoomVerticalPlacements( - spaces, - walls, - // A derived floor cannot be evidence for its own next elevation: that - // would lift unpinned walls, then lift the floor again on every pass. - parsedSlabs.filter((slab) => !slab.autoFromWalls), - nodes, - storeyHeight, - ) - const previousChildren = levelChildren(previousNodes, levelId) - const previousWalls = previousChildren.filter( - (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, - ) - const previousSlabs: SlabNodeType[] = previousChildren - .filter((node: any) => node?.type === 'slab') - .map((slab: any) => SlabNode.parse(slab)) - const previousLevelNode = previousNodes[levelId] - const previousStoreyHeight = - previousLevelNode?.type === 'level' - ? getStoredLevelHeight(previousLevelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces - const previousVerticalPlacements = autoRoomVerticalPlacements( - previousSpaces, - previousWalls, - previousSlabs.filter((slab) => !slab.autoFromWalls), - previousNodes, - previousStoreyHeight, - ) - const placementFor = (polygon: Array<[number, number]>) => - verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - const previousPlacementFor = (polygon: Array<[number, number]>) => - previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - syncAutoSlabsForLevel( - levelId, - slabRooms.map((room) => room.polygon), - parsedSlabs, - sceneStore, - { - elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, - previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, - }, - ) - syncAutoCeilingsForLevel( - levelId, - ceilingRooms.map((room) => room.polygon), - parsedCeilings, - sceneStore, - { - storeyHeight, - ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), - heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, - previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, - childPosition: (childId) => { - const child = nodes[childId] - return child && Array.isArray(child.position) - ? [child.position[0], child.position[2]] - : undefined - }, - }, - ) - const zonePlan = planAutoZonesForLevel( - spaces, - zones.map((zone: any) => ZoneNode.parse(zone)), - ) - if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - - for (const space of spaces) { - nextSpaces[space.id] = space - } - previousRoomsByLevel.set(levelId, rooms) - } - - editorStore.getState().setSpaces(nextSpaces) -} - -function runIndexedSpaceDetection( - levelId: string, - topologyDelta: IndexedTopologyDelta, - sceneStore: any, - editorStore: any, - nodes: SceneNodes, - previousNodes: SceneNodes, -) { - const { updateNodes } = sceneStore.getState() - const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) - const changedWallUpdates = topologyDelta.currentWalls - .map((wall) => ({ - wallId: wall.id, - ...resolveWallSurfaceSides(wall, allRoomPolygons), - })) - .filter((update) => { - const wall = nodes[update.wallId] - 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 }, - })), - ) - } - - const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] - if (scopedRooms.length > 0) { - const unaffectedRooms = topologyDelta.allCurrentRooms.filter( - (room) => !topologyDelta.currentRooms.includes(room), - ) - const currentChildren = levelChildren(nodes, levelId) - const allSlabs: SlabNodeType[] = currentChildren - .filter((node: any): node is SlabNodeType => node.type === 'slab') - .map((slab: SlabNodeType) => SlabNode.parse(slab)) - const allCeilings: CeilingNodeType[] = currentChildren - .filter((node: any): node is CeilingNodeType => node.type === 'ceiling') - .map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling)) - const slabs = allSlabs.filter( - (slab) => - surfaceTouchesRooms(slab, scopedRooms) && - (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)), - ) - const ceilings = allCeilings.filter( - (ceiling) => - surfaceTouchesRooms(ceiling, scopedRooms) && - (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)), - ) - const slabRooms = roomsEligibleForAutoSurface( - topologyDelta.beforeRooms, - topologyDelta.currentRooms, - slabs, - ) - const ceilingRooms = roomsEligibleForAutoSurface( - topologyDelta.beforeRooms, - topologyDelta.currentRooms, - ceilings, - ) - const levelNode = nodes[levelId] - const storeyHeight = - levelNode?.type === 'level' - ? getStoredLevelHeight(levelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room)) - const verticalPlacements = autoRoomVerticalPlacements( - currentSpaces, - topologyDelta.currentWalls, - allSlabs.filter((slab) => !slab.autoFromWalls), - nodes, - storeyHeight, - ) - const previousChildren = levelChildren(previousNodes, levelId) - const previousSlabs: SlabNodeType[] = previousChildren - .filter((node: any): node is SlabNodeType => node.type === 'slab') - .map((slab: SlabNodeType) => SlabNode.parse(slab)) - const previousLevelNode = previousNodes[levelId] - const previousStoreyHeight = - previousLevelNode?.type === 'level' - ? getStoredLevelHeight(previousLevelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room)) - const previousVerticalPlacements = autoRoomVerticalPlacements( - previousSpaces, - topologyDelta.previousWalls, - previousSlabs.filter((slab) => !slab.autoFromWalls), - previousNodes, - previousStoreyHeight, - ) - const placementFor = (polygon: Array<[number, number]>) => - verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - const previousPlacementFor = (polygon: Array<[number, number]>) => - previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - - syncAutoSlabsForLevel( - levelId, - slabRooms.map((room) => room.polygon), - slabs, - sceneStore, - { - elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, - previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, - }, - allSlabs, - ) - syncAutoCeilingsForLevel( - levelId, - ceilingRooms.map((room) => room.polygon), - ceilings, - sceneStore, - { - storeyHeight, - ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), - heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, - previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, - childPosition: (childId) => { - const child = nodes[childId] - return child && Array.isArray(child.position) - ? [child.position[0], child.position[2]] - : undefined - }, - }, - allCeilings, - ) - } - - const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) - const zones: ZoneNodeType[] = levelChildren(nodes, levelId) - .filter((node: any): node is ZoneNodeType => node.type === 'zone') - .map((zone: ZoneNodeType) => ZoneNode.parse(zone)) - const zonePlan = planAutoZonesForLevel(spaces, zones) - if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - - 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) -} - -// Refcount of outstanding pause requests, matching the pauseSceneHistory -// pattern. The community editor flips this off while the AI is actively -// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race -// `create_room`'s explicit slabs/ceilings (see plan -// `ai-pause-space-detection`). -let spaceDetectionPauseDepth = 0 - -/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */ -export function pauseSpaceDetection(): void { - spaceDetectionPauseDepth += 1 -} - -/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */ -export function resumeSpaceDetection(): void { - if (spaceDetectionPauseDepth === 0) return - spaceDetectionPauseDepth -= 1 -} - -/** True iff the wall-driven auto slab/ceiling sync is currently paused. */ -export function isSpaceDetectionPaused(): boolean { - return spaceDetectionPauseDepth > 0 -} - -export function initSpaceDetectionSync( - sceneStore: any, - editorStore: any, - options: SpaceDetectionSyncOptions = {}, -): () => 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 initialNodes = sceneStore.getState().nodes - const previousRoomsByLevel = new Map() - const topologyIndex = new RoomTopologyIndex({ - detectRooms: extractRooms, - sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple), - junctionTolerance: WALL_JUNCTION_TOLERANCE, - }) - let previousNodes = initialNodes - let isProcessing = false - - const adoptSceneBaseline = (nodes: SceneNodes) => { - topologyIndex.rebuild(nodes) - const roomsByLevel = detectedRoomsByLevel(nodes) - previousRoomsByLevel.clear() - const spaces: Record = {} - for (const [levelId, rooms] of roomsByLevel) { - previousRoomsByLevel.set(levelId, rooms) - for (const room of rooms) { - const space = buildSpace(levelId, room) - spaces[space.id] = space - } - } - editorStore.getState().setSpaces(spaces) - previousNodes = nodes - } - - adoptSceneBaseline(initialNodes) - - const unsubscribeCommits = subscribeSceneCommits((commit) => { - if (commit.origin === 'local') return - adoptSceneBaseline(commit.current.nodes) - }) - - const unsubscribe = sceneStore.subscribe((state: any) => { - if (isProcessing) return - if (getSceneHistoryPauseDepth() > 0) return - - const nodes = state.nodes - const candidateIds = activeSceneCommitNodeIds() - - // 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) { - adoptSceneBaseline(nodes) - return - } - - const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds) - if (candidateIds && changedWalls.size > 0) { - const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds) - for (const levelId of changedWalls.keys()) fallbackLevels.delete(levelId) - isProcessing = true - pauseSceneHistory(sceneStore) - try { - for (const [levelId, wallIds] of changedWalls) { - const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes) - runIndexedSpaceDetection( - levelId, - topologyDelta, - sceneStore, - editorStore, - nodes, - previousNodes, - ) - previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms) - options.onTopologyReconcile?.({ - levelId, - strategy: topologyDelta.strategy, - examinedWallIds: topologyDelta.examinedWallIds, - affectedBeforeRoomCount: topologyDelta.beforeRooms.length, - affectedCurrentRoomCount: topologyDelta.currentRooms.length, - }) - } - if (fallbackLevels.size > 0) { - runSpaceDetection( - [...fallbackLevels], - sceneStore, - editorStore, - sceneStore.getState().nodes, - previousNodes, - previousRoomsByLevel, - ) - const liveNodes = sceneStore.getState().nodes - for (const levelId of fallbackLevels) topologyIndex.rebuildLevel(levelId, liveNodes) - } - } finally { - resumeSceneHistory(sceneStore) - previousNodes = sceneStore.getState().nodes - isProcessing = false - } - return - } - - const levelsToUpdate = new Set() - if (candidateIds) { - for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) { - levelsToUpdate.add(levelId) - } - } else { - const previousSnapshots = levelStructureSnapshots(previousNodes) - const currentSnapshots = levelStructureSnapshots(nodes) - 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) { - if (candidateIds) { - previousNodes = nodes - return - } - const currentRoomsByLevel = detectedRoomsByLevel(nodes) - previousRoomsByLevel.clear() - for (const [levelId, rooms] of currentRoomsByLevel) { - previousRoomsByLevel.set(levelId, rooms) - } - previousNodes = nodes - return - } - - isProcessing = true - pauseSceneHistory(sceneStore) - try { - runSpaceDetection( - [...levelsToUpdate], - sceneStore, - editorStore, - nodes, - previousNodes, - previousRoomsByLevel, - ) - } finally { - resumeSceneHistory(sceneStore) - const liveNodes = sceneStore.getState().nodes - for (const levelId of levelsToUpdate) topologyIndex.rebuildLevel(levelId, liveNodes) - previousNodes = liveNodes - isProcessing = false - } - }) - - return () => { - unsubscribe() - unsubscribeCommits() - } -} - -export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { - const threshold = 0.1 - - for (const other of otherWalls) { - if (other.id === wall.id) continue - - if ( - distanceToSegment(wall.start, other.start, other.end) < threshold || - distanceToSegment(wall.end, other.start, other.end) < threshold || - distanceToSegment(other.start, wall.start, wall.end) < threshold || - distanceToSegment(other.end, wall.start, wall.end) < threshold - ) { - return true - } - } - - return false -} +import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id' +import { + type AnyNodeId, + CeilingNode, + type CeilingNode as CeilingNodeType, + type LevelNode, + SlabNode, + type SlabNode as SlabNodeType, + type WallNode, + ZoneNode, + type ZoneNode as ZoneNodeType, +} from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' +import { + activeSceneCommitNodeIds, + getSceneHistoryPauseDepth, + pauseSceneHistory, + resumeSceneHistory, + subscribeSceneCommits, +} from '../store/history-control' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { + getClampedWallCurveOffset, + getWallCurveFrameAt, + isCurvedWall, +} from '../systems/wall/wall-curve' +import { resolveWallTop } from '../systems/wall/wall-top' +import { simplifyClosedPolygon } from './polygon-geometry' +import { + distanceToSegment, + type IndexedTopologyDelta, + RoomTopologyIndex, +} from './room-topology-index' +import { levelBaseElevationAt } from './terrain-support' + +type Point2D = { x: number; y: number } + +export type SpaceBoundaryFace = { + wallId: WallNode['id'] + face: 'front' | 'back' + points: Array<[number, number]> +} + +export type Space = { + id: string + levelId: string + polygon: Array<[number, number]> + wallIds: Array + boundaryFaces: SpaceBoundaryFace[] + isExterior: boolean +} + +export type SpaceTopologyReconcileEvent = { + levelId: string + strategy: 'indexed' | 'fallback' + examinedWallIds: string[] + affectedBeforeRoomCount: number + affectedCurrentRoomCount: number +} + +export type SpaceDetectionSyncOptions = { + onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void +} + +type ExtractedRoom = { + polygon: Point2D[] + boundaryFaces: SpaceBoundaryFace[] +} + +type WallSideUpdate = { + wallId: string + frontSide: 'interior' | 'exterior' | 'unknown' + backSide: 'interior' | 'exterior' | 'unknown' +} + +type DetectedRoom = { + poly: Point2D[] + sig: string + centroid: Point2D + area: number + bbox: ReturnType +} + +export type AutoSlabSyncPlan = { + create: SlabNodeType[] + update: Array<{ id: SlabNodeType['id']; data: Partial }> + delete: Array +} + +export type AutoSlabPlanningContext = { + elevationForRoom?: (polygon: Array<[number, number]>) => number | undefined + previousElevationForRoom?: (polygon: Array<[number, number]>) => number | undefined +} + +export type AutoCeilingSyncPlan = { + create: CeilingNodeType[] + update: Array<{ id: CeilingNodeType['id']; data: Partial }> + delete: Array + reparent: Array<{ id: AnyNodeId; parentId: CeilingNodeType['id'] }> +} + +export type AutoZoneSyncPlan = { + update: Array<{ id: ZoneNodeType['id']; data: Partial }> +} + +const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 +const CEILING_HEIGHT_EPSILON = 1e-6 +const ROOM_CURVE_TOLERANCE = 0.04 +const MAX_CURVE_SUBDIVISION_DEPTH = 6 +const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 +const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 +// A wall endpoint within this distance of another wall's interior is treated as a +// T-junction and splits that wall (see `splitStraightWallAtVertices`). +const WALL_JUNCTION_TOLERANCE = 0.08 +// An unmatched auto slab/ceiling whose polygon is still substantially covered +// by a detected room was absorbed by a room merge — the surviving auto surface +// owns that area, so keeping it would z-fight and it is deleted. Below this +// coverage the room genuinely ceased to exist (e.g. an enclosing wall was +// deleted) and the node is demoted to manual so user data survives. +const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 +const COVERAGE_SAMPLE_STEPS = 12 +// Rewrite deadband for an existing auto surface's elevation/height: below this +// the derived plane is the same plane and writing it would churn history. +const ROOM_VERTICAL_PLANE_EPSILON = 1e-3 + +// Pure planner callers omit `heightForRoom`, so auto ceilings keep their +// height-less level-following behavior. The live room sync supplies a height +// derived from the enclosing walls' own bases and tops. +export type AutoCeilingPlanningContext = { + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number + /** + * Stage 3-B clamp-bound resolver for a polygon on the planned level: + * `min(storey plane, lowest covering-slab underside from the level + * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent + * (pure-planner callers without a nodes record), the bound degrades to + * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. + */ + ceilingClampBound?: (polygon: Array<[number, number]>) => number + heightForRoom?: (polygon: Array<[number, number]>) => number | undefined + previousHeightForRoom?: (polygon: Array<[number, number]>) => number | undefined + childPosition?: (childId: AnyNodeId) => [number, number] | undefined +} + +function pointFromTuple(point: [number, number]): Point2D { + return { x: point[0], y: point[1] } +} + +function pointToTuple(point: Point2D): [number, number] { + return [point.x, point.y] +} + +function pointKey(point: Point2D) { + return `${point.x.toFixed(3)},${point.y.toFixed(3)}` +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const a = points[i] + const b = points[(i + 1) % points.length] + if (!(a && b)) continue + area += a.x * b.y - b.x * a.y + } + return area / 2 +} + +function minRotationSignature(keys: string[]) { + if (keys.length === 0) return '' + let best = '' + for (let i = 0; i < keys.length; i++) { + const rotated = [...keys.slice(i), ...keys.slice(0, i)] + const value = rotated.join('|') + if (!best || value < best) best = value + } + return best +} + +function polygonSignature(points: Point2D[]) { + const keys = points.map(pointKey) + const forward = minRotationSignature(keys) + const reversed = minRotationSignature([...keys].reverse()) + return forward < reversed ? forward : reversed +} + +function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { + return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance +} + +function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) { + const deduped: Point2D[] = [] + + for (const point of points) { + const previous = deduped[deduped.length - 1] + if (previous && samePointWithinTolerance(previous, point, tolerance)) { + continue + } + deduped.push(point) + } + + const firstPoint = deduped[0] + const lastPoint = deduped[deduped.length - 1] + if ( + deduped.length > 2 && + firstPoint && + lastPoint && + samePointWithinTolerance(firstPoint, lastPoint, tolerance) + ) { + deduped.pop() + } + + return deduped +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i]?.x ?? 0 + const yi = polygon[i]?.y ?? 0 + const xj = polygon[j]?.x ?? 0 + const yj = polygon[j]?.y ?? 0 + + const intersect = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi + if (intersect) inside = !inside + } + + return inside +} + +function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) { + return polygons.some((polygon) => pointInPolygon(point, polygon)) +} + +function polygonCentroid(points: Point2D[]) { + const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { + x: 0, + y: 0, + }) + + return { + x: sum.x / Math.max(points.length, 1), + y: sum.y / Math.max(points.length, 1), + } +} + +function bboxOf(points: Point2D[]) { + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + } + + return { minX, minY, maxX, maxY } +} + +function bboxOverlapArea(a: ReturnType, b: ReturnType) { + const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)) + const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)) + return ix * iy +} + +// Fraction of `subject`'s area lying inside any of `covers`, estimated by +// sampling a grid of cell centers over the subject's bbox. Cheap and robust +// enough for the merge-vs-demote decision; exact polygon clipping would be a +// heavy dependency for a 60% threshold. +function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) { + if (subject.length < 3 || covers.length === 0) return 0 + + const bbox = bboxOf(subject) + const width = bbox.maxX - bbox.minX + const height = bbox.maxY - bbox.minY + + let inside = 0 + let covered = 0 + for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) { + for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) { + const point = { + x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width, + y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height, + } + if (!pointInPolygon(point, subject)) continue + inside += 1 + if (pointInAnyPolygon(point, covers)) covered += 1 + } + } + + if (inside === 0) { + return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0 + } + + return covered / inside +} + +// Demoted auto surfaces keep their polygon untouched, so a re-closed room +// usually hits the exact-signature manual check. Coverage handles the rest: +// a room split across multiple manual surfaces AND a single manual surface +// spanning multiple rooms both suppress a replacement auto surface — what +// matters is that the ROOM is already substantially covered, not that any +// one manual surface belongs to it (a per-surface "mostly inside the room" +// filter dropped multi-room slabs and resurrected deleted auto slabs). +function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { + return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD +} + +function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { + let minDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + if (!(start && end)) continue + minDistance = Math.min( + minDistance, + distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)), + ) + } + return minDistance +} + +function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { + const sampled = sampleWallPointsForRoomDetection(wall) + if (sampled.length === 0) return false + + const candidates = + sampled.length === 2 + ? [ + sampled[0]!, + { + x: (sampled[0]!.x + sampled[1]!.x) / 2, + y: (sampled[0]!.y + sampled[1]!.y) / 2, + }, + sampled[1]!, + ] + : sampled + + const matchingPoints = candidates.filter( + (point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, + ) + + return matchingPoints.length >= 2 +} + +/** + * The clamp bound for a ceiling polygon under this planning context — + * the context's cross-level resolver when provided, else the plane-only + * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. + */ +function resolveCeilingClampBound( + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, +) { + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN +} + +/** + * The base a boundary wall actually stands on, in level-local metres. + * + * Resolved the same way the wall renderer resolves it — the level base under + * the wall's own start point (sculpted ground or the flat plane), the slab + * election on top of that, plus the wall's stored `supportOffset`. Reading the + * ground for `GROUND_SUPPORT_ID` walls only is what left stamped room presets + * flat: `resolveWallSupportSlabPatch` writes no host at all for a wall on bare + * terrain, so the sentinel is a hint about pointer intent, never a precondition + * for standing on the ground. + */ +function boundaryWallBase( + wall: WallNode, + walls: WallNode[], + supportSlabs: readonly SlabNodeType[], + nodes: Record, + levelId: string, +): number { + const levelBase = levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]) + const offset = wall.supportOffset ?? 0 + if (wall.supportSlabId === GROUND_SUPPORT_ID) return levelBase + offset + return ( + computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null, null, levelBase) + .elevation + offset + ) +} + +/** + * The plane an auto floor/ceiling takes when its enclosing walls disagree. + * + * `floor` takes the HIGHEST wall base and `ceiling` the LOWEST wall top — + * the only pair that cannot open a hole: a floor at the lowest base would + * leave daylight under every wall standing higher, and a ceiling at the + * highest top would poke out through the shortest wall. Both surfaces stay + * flat (a slab is one scalar elevation by schema; see `vertical-model.md`), + * so a room on a slope is a level room cut into the hillside — the walls on + * the low side extend down to meet it, which is what their `baseSegments` + * fill-down already does. + * + * Non-finite inputs are the one abstain: a broken graph should keep the + * existing placement rather than move a surface to NaN. + */ +function roomFloorPlane(wallBases: number[]): number | undefined { + if (wallBases.length === 0 || wallBases.some((value) => !Number.isFinite(value))) return undefined + return Math.max(...wallBases) +} + +function roomCeilingPlane(wallTops: number[]): number | undefined { + if (wallTops.length === 0 || wallTops.some((value) => !Number.isFinite(value))) return undefined + return Math.min(...wallTops) +} + +function autoRoomVerticalPlacements( + spaces: readonly Space[], + walls: WallNode[], + supportSlabs: readonly SlabNodeType[], + nodes: Record, + storeyHeight: number, +) { + const wallsById = new Map(walls.map((wall) => [wall.id, wall])) + const placements = new Map() + + for (const space of spaces) { + const boundaryWalls = space.wallIds.flatMap((id) => { + const wall = wallsById.get(id) + return wall ? [wall] : [] + }) + if (boundaryWalls.length !== space.wallIds.length) continue + + const wallBases = boundaryWalls.map((wall) => + boundaryWallBase(wall, walls, supportSlabs, nodes, space.levelId), + ) + const base = roomFloorPlane(wallBases) + if (base === undefined) continue + + const wallTops = boundaryWalls.flatMap((wall, index) => { + const b = wallBases[index] ?? base + return [ + resolveWallTop(wall, storeyHeight, b, 0), + resolveWallTop(wall, storeyHeight, b, 1), + ] + }) + const top = roomCeilingPlane(wallTops) + if (top === undefined) continue + + placements.set(polygonSignature(space.polygon.map(pointFromTuple)), { + slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION, + ceilingHeight: top - CEILING_CLAMP_MARGIN, + }) + } + + return placements +} + +function getWallDirection(wall: Pick) { + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-9) { + return { + point: pointFromTuple(wall.start), + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + const tangent = { x: dx / length, y: dy / length } + return { + point: { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + }, + tangent, + normal: { x: -tangent.y, y: tangent.x }, + } +} + +function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared < 1e-9) { + return Math.hypot(point.x - start.x, point.y - start.y) + } + + const cross = (point.x - start.x) * dy - (point.y - start.y) * dx + return Math.abs(cross) / Math.sqrt(lengthSquared) +} + +function sampleWallPointsForRoomDetection( + wall: Pick, + tolerance = ROOM_CURVE_TOLERANCE, +) { + const start = { x: wall.start[0], y: wall.start[1] } + const end = { x: wall.end[0], y: wall.end[1] } + + if (!isCurvedWall(wall)) { + return [start, end] + } + + const subdivide = ( + t0: number, + p0: Point2D, + t1: number, + p1: Point2D, + depth: number, + ): Point2D[] => { + const midT = (t0 + t1) / 2 + const midPoint = getWallCurveFrameAt(wall, midT).point + const deviation = pointLineDistance(midPoint, p0, p1) + + if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) { + return [p0, p1] + } + + const left = subdivide(t0, p0, midT, midPoint, depth + 1) + const right = subdivide(midT, midPoint, t1, p1, depth + 1) + return [...left.slice(0, -1), ...right] + } + + return subdivide(0, start, 1, end, 0) +} + +function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared < 1e-12) { + return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } + } + const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared + const clampedT = Math.max(0, Math.min(1, t)) + const projX = start.x + clampedT * dx + const projY = start.y + clampedT * dy + return { t, distance: Math.hypot(point.x - projX, point.y - projY) } +} + +// Break a straight wall at any junction vertex (another wall's endpoint) that +// lands on its interior, returning the ordered polyline [start, …splits, end]. +// Splitting at the *vertex* position (not the projection) keeps the split node's +// key identical to the touching wall's endpoint so the two share a graph node. +function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { + const length = Math.hypot(end.x - start.x, end.y - start.y) + if (length < 1e-9) return [start, end] + + const interior: Array<{ point: Point2D; t: number }> = [] + for (const vertex of vertices) { + const { t, distance } = segmentProjection(vertex, start, end) + if (distance > WALL_JUNCTION_TOLERANCE) continue + const along = t * length + if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue + interior.push({ point: vertex, t }) + } + interior.sort((a, b) => a.t - b.t) + + const ordered: Point2D[] = [start] + let lastKey = pointKey(start) + for (const { point } of interior) { + const key = pointKey(point) + if (key === lastKey) continue + ordered.push(point) + lastKey = key + } + if (lastKey !== pointKey(end)) ordered.push(end) + return ordered +} + +function extractRooms(walls: WallNode[]): ExtractedRoom[] { + if (walls.length < 3) return [] + + type HalfEdge = { + id: string + reverseId: string + fromKey: string + toKey: string + angle: number + points: Point2D[] + wallId: WallNode['id'] + face: 'front' | 'back' + } + type Node = { point: Point2D; outgoing: string[] } + + const graph = new Map() + const halfEdges = new Map() + + const upsertNode = (point: Point2D) => { + const key = pointKey(point) + if (!graph.has(key)) { + graph.set(key, { point: { ...point }, outgoing: [] }) + } + return key + } + + // Planarize first: collect every wall endpoint as a candidate graph vertex so + // straight walls can be split at T-junctions where another wall ends mid-span. + // Without this the touching wall's endpoint is a dangling degree-1 node and the + // enclosed area (e.g. a room added against the middle of an existing wall) + // never forms a cycle. + const vertexByKey = new Map() + for (const wall of walls) { + for (const tuple of [wall.start, wall.end]) { + const point = pointFromTuple(tuple) + const key = pointKey(point) + if (!vertexByKey.has(key)) vertexByKey.set(key, point) + } + } + const vertices = [...vertexByKey.values()] + + for (const wall of walls) { + const start = pointFromTuple(wall.start) + const end = pointFromTuple(wall.end) + if (samePointWithinTolerance(start, end)) continue + + // Curved walls keep their sampled polyline as one edge; straight walls split + // into consecutive sub-edges at their interior junction vertices. + const subPolylines: Point2D[][] = isCurvedWall(wall) + ? [sampleWallPointsForRoomDetection(wall)] + : (() => { + const ordered = splitStraightWallAtVertices(start, end, vertices) + const parts: Point2D[][] = [] + for (let index = 0; index < ordered.length - 1; index += 1) { + parts.push([ordered[index]!, ordered[index + 1]!]) + } + return parts + })() + + subPolylines.forEach((points, subIndex) => { + const from = points[0]! + const to = points[points.length - 1]! + const fromKey = upsertNode(from) + const toKey = upsertNode(to) + if (fromKey === toKey) return + + const reversePoints = [...points].reverse() + const forwardId = `${wall.id}#${subIndex}:f` + const reverseId = `${wall.id}#${subIndex}:r` + + halfEdges.set(forwardId, { + id: forwardId, + reverseId, + fromKey, + toKey, + angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), + points, + wallId: wall.id, + face: 'front', + }) + halfEdges.set(reverseId, { + id: reverseId, + reverseId: forwardId, + fromKey: toKey, + toKey: fromKey, + angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), + points: reversePoints, + wallId: wall.id, + face: 'back', + }) + + graph.get(fromKey)?.outgoing.push(forwardId) + graph.get(toKey)?.outgoing.push(reverseId) + }) + } + + const sortedOutgoing = new Map() + for (const [key, node] of graph.entries()) { + const outgoing = [...node.outgoing] + outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) + sortedOutgoing.set(key, outgoing) + } + + const nextEdge = (edgeId: string) => { + const edge = halfEdges.get(edgeId) + if (!edge) return null + + const outgoing = sortedOutgoing.get(edge.toKey) + if (!outgoing || outgoing.length === 0) return null + + const idx = outgoing.indexOf(edge.reverseId) + if (idx === -1) return null + + const nextIdx = (idx - 1 + outgoing.length) % outgoing.length + return outgoing[nextIdx] ?? null + } + + const splitIntoSimpleCycles = (walkEdgeIds: string[]) => { + const cycles: string[][] = [] + const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '') + if (!firstEdge) return cycles + + const pathEdges: string[] = [] + const pathVertices = [firstEdge.fromKey] + const vertexIndex = new Map([[firstEdge.fromKey, 0]]) + + for (const edgeId of walkEdgeIds) { + const edge = halfEdges.get(edgeId) + if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return [] + + pathEdges.push(edgeId) + const repeatedIndex = vertexIndex.get(edge.toKey) + if (repeatedIndex === undefined) { + pathVertices.push(edge.toKey) + vertexIndex.set(edge.toKey, pathVertices.length - 1) + continue + } + + const cycle = pathEdges.slice(repeatedIndex) + if (cycle.length >= 3) cycles.push(cycle) + + for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) { + vertexIndex.delete(pathVertices[index]!) + } + pathVertices.length = repeatedIndex + 1 + pathEdges.length = repeatedIndex + } + + return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [] + } + + const visitedDirected = new Set() + const rooms: ExtractedRoom[] = [] + // A face walk cannot revisit a half-edge, so the half-edge count bounds its + // length. It can revisit a vertex when dangling walls or other graph bridges + // are traced out and back; those excursions are removed below. + const maxSteps = Math.min(2000, halfEdges.size + 10) + + for (const edgeId of halfEdges.keys()) { + if (visitedDirected.has(edgeId)) continue + + const cycleEdgeIds: string[] = [] + let currentEdgeId = edgeId + let valid = true + let closed = false + + for (let step = 0; step < maxSteps; step += 1) { + const currentEdge = halfEdges.get(currentEdgeId) + if (!currentEdge) { + valid = false + break + } + + visitedDirected.add(currentEdgeId) + cycleEdgeIds.push(currentEdgeId) + + const next = nextEdge(currentEdgeId) + if (!next) { + valid = false + break + } + + currentEdgeId = next + if (currentEdgeId === edgeId) { + closed = true + break + } + } + + if (!(valid && closed) || cycleEdgeIds.length < 3) continue + + for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) { + const polygon = dedupeSequentialPoints( + simpleCycleEdgeIds.flatMap((id, index) => { + const points = halfEdges.get(id)?.points ?? [] + return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1) + }), + ) + + if (polygon.length < 3) continue + + 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 + + rooms.push({ + polygon, + boundaryFaces: simpleCycleEdgeIds.flatMap((id) => { + const edge = halfEdges.get(id) + if (!edge) return [] + return [ + { + wallId: edge.wallId, + face: edge.face, + points: edge.points.map(pointToTuple), + }, + ] + }), + }) + } + } + + rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon))) + return rooms +} + +function extractRoomPolygons(walls: WallNode[]): Point2D[][] { + return extractRooms(walls).map((room) => room.polygon) +} + +/** + * True when `wall` lies on the boundary of a room enclosed by `walls`, using the + * same planar room graph the auto slab/ceiling sync uses. The wall builder's + * "Room (auto-close)" mode calls this so drafting stops the moment a segment + * closes a room — whether the chain loops back to its own start or seals a bay + * against the middle of an existing wall (a T-junction). Sharing one graph means + * auto-close and auto-slab detection can never disagree about what is "closed". + */ +export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean { + const roomPolygons = extractRoomPolygons(walls) + if (roomPolygons.length === 0) return false + return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon)) +} + +export function resolveWallSurfaceSides( + wall: Pick, + roomPolygons: Point2D[][], +): Pick { + if (roomPolygons.length === 0) { + return { + frontSide: 'unknown' as const, + backSide: 'unknown' as const, + } + } + + const frame = getWallDirection(wall) + const normalLength = Math.hypot(frame.normal.x, frame.normal.y) + if (normalLength < 1e-9) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + const normalX = frame.normal.x / normalLength + const normalY = frame.normal.y / normalLength + const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16) + + const frontPoint = { + x: frame.point.x + normalX * sampleDistance, + y: frame.point.y + normalY * sampleDistance, + } + const backPoint = { + x: frame.point.x - normalX * sampleDistance, + y: frame.point.y - normalY * sampleDistance, + } + + const frontInside = pointInAnyPolygon(frontPoint, roomPolygons) + const backInside = pointInAnyPolygon(backPoint, roomPolygons) + + if (frontInside === backInside) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + return { + frontSide: frontInside ? 'interior' : 'exterior', + backSide: backInside ? 'interior' : 'exterior', + } +} + +function nextAutoRoomName( + nodes: Array<{ + name?: string + }>, + suffix: 'Slab' | 'Ceiling', +) { + let maxIndex = 0 + + for (const node of nodes) { + const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim()) + if (!match) continue + const index = Number(match[1]) + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + } + + return `Room ${maxIndex + 1} ${suffix}` +} + +function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) { + return ( + current.length === next.length && + current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) + ) +} + +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 = { + holes: Array> + 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[], + 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) + for (const roomIndex of roomIndices) { + const room = detected[roomIndex] + const assignment = assignments.get(roomIndex) + if (!(room && assignment)) continue + for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) { + assignment.holes.push(clipped.map(pointToTuple)) + assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) + } + } + }) + + return assignments +} + +function partitionCeilingChildren( + ceiling: CeilingNodeType, + roomIndices: number[], + detected: DetectedRoom[], + fallbackRoomIndex: number | undefined, + childPosition: AutoCeilingPlanningContext['childPosition'], +) { + const assignments = new Map() + for (const roomIndex of roomIndices) assignments.set(roomIndex, []) + + for (const childId of ceiling.children) { + const tuple = childPosition?.(childId) + const point = tuple ? pointFromTuple(tuple) : undefined + const boundaryRoomIndices = point + ? roomIndices.filter((roomIndex) => { + const room = detected[roomIndex] + return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false + }) + : [] + const interiorRoomIndex = + point && boundaryRoomIndices.length === 0 + ? roomIndices.find((roomIndex) => { + const room = detected[roomIndex] + return room ? pointInPolygon(point, room.poly) : false + }) + : undefined + const roomIndex = + interiorRoomIndex ?? + (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex) + ? fallbackRoomIndex + : boundaryRoomIndices[0]) ?? + fallbackRoomIndex ?? + roomIndices[0] + if (roomIndex !== undefined) assignments.get(roomIndex)?.push(childId) + } + + 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 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 ceilingMergeSettingsSignature(ceiling: CeilingNodeType) { + return JSON.stringify([ + ceiling.height ?? null, + ceiling.material ?? null, + ceiling.materialPreset ?? null, + ceiling.slots ?? null, + ceiling.visible, + ]) +} + +function slabMergeSettingsSignature(slab: SlabNodeType) { + return JSON.stringify([ + slab.elevation, + slab.thickness, + slab.recessed, + slab.recessedRimElevation ?? null, + slab.fillToTerrain ?? null, + slab.material ?? null, + slab.materialPreset ?? null, + slab.slots ?? null, + slab.visible, + ]) +} + +function slabElevationForReconciledRoom( + source: SlabNodeType, + polygon: Array<[number, number]>, + context: AutoSlabPlanningContext, +) { + const currentDerived = context.elevationForRoom?.(polygon) + if (!context.previousElevationForRoom) return currentDerived ?? source.elevation + + const previousDerived = context.previousElevationForRoom(source.polygon) + const sourceWasDerived = + previousDerived !== undefined && + Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON + return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation +} + +function ceilingHeightForReconciledRoom( + source: CeilingNodeType, + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, +) { + if (source.height === undefined) return undefined + + const currentDerived = context.heightForRoom?.(polygon) + if (!context.previousHeightForRoom) return currentDerived ?? source.height + + const previousDerived = context.previousHeightForRoom(source.polygon) + const sourceWasDerived = + previousDerived !== undefined && + Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON + return sourceWasDerived ? (currentDerived ?? source.height) : source.height +} + +function wallGeometrySignature(wall: WallNode, nodes: Record, levelId: string) { + return [ + wall.id, + wall.start[0].toFixed(4), + 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), + wall.supportSlabId ?? 'elected', + (wall.supportOffset ?? 0).toFixed(4), + getClampedWallCurveOffset(wall).toFixed(4), + // The ground under this wall, sampled at the SAME point + // `boundaryWallBase` samples it. Sculpting changes only `site.terrain`, + // so without a terrain term here every signature stays byte-identical + // and the sync early-exits — a room's floor and ceiling could never + // follow ground that moved beneath its walls. + // + // The sample, not the field, and not the resolved base: hashing the + // heightfield would re-trigger every level for a stroke on the far side + // of the lot, and resolving the full slab election would fold slab + // POLYGONS into the signature, which is exactly the delete/recreate + // feedback the comment below is about. Sampling where the placement + // samples means the two cannot disagree in either direction — no missed + // re-run, no spurious one. + // + // Granularity is per stroke, not per dab: live dabs publish to + // `useLiveTerrain` and never touch the scene store, so this runs once on + // release — inside the stroke's own `runAsSingleSceneHistoryStep`, which + // is what puts the moved floor and the terrain that moved it in the same + // undo step. Mid-drag the ground-hosted walls follow the brush while the + // floor waits for release; re-deriving per dab would mean a scene write + // per dab and a floor that jitters under the cursor. + levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]).toFixed(4), + ].join('|') +} + +function levelWallSnapshot(walls: WallNode[], nodes: Record, levelId: string) { + return walls + .map((wall) => wallGeometrySignature(wall, nodes, levelId)) + .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, nodes, levelId)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, + ) + } + + return snapshots +} + +function buildSpace(levelId: string, room: ExtractedRoom): Space { + const signature = polygonSignature(room.polygon) + return { + id: `space-${levelId}-${signature.slice(0, 12)}`, + levelId, + polygon: room.polygon.map(pointToTuple), + wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))], + boundaryFaces: room.boundaryFaces, + isExterior: false, + } +} + +type RoomSurface = SlabNodeType | CeilingNodeType + +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 roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { + const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId)) + const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId)) + 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 roomHasAutoSurface(room: ExtractedRoom, surfaces: RoomSurface[]) { + return matchesManualFootprint( + room.polygon, + surfaces + .filter((surface) => surface.autoFromWalls) + .map((surface) => surface.polygon.map(pointFromTuple)), + ) +} + +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)) + }) +} + +function detectedRoomsByLevel(nodes: Record) { + 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) + } + return new Map( + [...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)] as const), + ) +} + +type SceneNodes = Record + +function levelChildren(nodes: SceneNodes, levelId: string) { + const level = nodes[levelId] + if (level?.type !== 'level') return [] + return level.children.flatMap((id: string) => { + const node = nodes[id] + return node ? [node] : [] + }) +} + +function changedWallIdsByLevel( + before: SceneNodes, + current: SceneNodes, + candidateIds?: ReadonlySet, +) { + const changes = new Map>() + 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) => { + if (!levelId) return + const ids = changes.get(levelId) ?? new Set() + ids.add(wallId) + changes.set(levelId, ids) + } + + for (const wallId of wallIds) { + const previous = before[wallId]?.type === 'wall' ? (before[wallId] as WallNode) : null + const next = current[wallId]?.type === 'wall' ? (current[wallId] as WallNode) : null + if (previous === next) continue + markChanged(previous?.parentId, wallId) + markChanged(next?.parentId, wallId) + } + + return changes +} + +function descendantLevelIds(nodes: SceneNodes, rootId: string) { + const levelIds = new Set() + const queue = [rootId] + const visited = new Set() + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const node = nodes[id] + if (!node) continue + if (node.type === 'level') levelIds.add(node.id) + if ('children' in node && Array.isArray(node.children)) queue.push(...node.children) + } + return levelIds +} + +function fallbackLevelIdsForCandidates( + before: SceneNodes, + current: SceneNodes, + candidateIds: ReadonlySet, +) { + const levelIds = new Set() + const addLevelAndLower = (levelId: string | null | undefined, nodes: SceneNodes) => { + if (!levelId) return + levelIds.add(levelId) + const lower = getLevelBelow(levelId, nodes) + if (lower) levelIds.add(lower.id) + } + + for (const id of candidateIds) { + for (const nodes of [before, current]) { + const node = nodes[id] + if (!node) continue + if (node.type === 'level' || node.type === 'building' || node.type === 'site') { + for (const levelId of descendantLevelIds(nodes, node.id)) levelIds.add(levelId) + } else if (node.type === 'slab') { + addLevelAndLower(node.parentId, nodes) + } else if (node.type === 'zone') { + if (node.parentId) levelIds.add(node.parentId) + } + } + } + return levelIds +} + +function sameStringSet(a: readonly string[], b: readonly string[]) { + if (a.length !== b.length) return false + const right = new Set(b) + return a.every((value) => right.has(value)) +} + +type AutoSurfaceMatch = { + detectedAll: DetectedRoom[] + detected: DetectedRoom[] + existingAuto: TSurface[] + compatibleMergesByRoomIndex: Map + matchedDetectedIndices: Set + roomIndexBySurfaceId: Map + sourceSurfaceIdByRoomIndex: Map + polygonBySurfaceId: Map> + delete: Array + demote: Array<{ id: TSurface['id']; data: Partial }> +} + +function matchAutoSurfaces( + roomPolygons: Point2D[][], + existingSurfaces: TSurface[], + mergeSettingsSignature: (surface: TSurface) => string, +): AutoSurfaceMatch { + const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls) + const manualSignatures = new Set( + manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))), + ) + const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple)) + const detectedAll: DetectedRoom[] = roomPolygons + .map((poly) => ({ + poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( + pointFromTuple, + ), + sig: '', + centroid: { x: 0, y: 0 }, + area: 0, + bbox: bboxOf([]), + })) + .map((room) => ({ + ...room, + sig: polygonSignature(room.poly), + centroid: polygonCentroid(room.poly), + area: Math.abs(polygonArea(room.poly)), + bbox: bboxOf(room.poly), + })) + const detected = detectedAll.filter( + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ) + const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls) + const metadata = existingAuto.map((surface) => { + const poly = surface.polygon.map(pointFromTuple) + return { + surface, + sig: polygonSignature(poly), + centroid: polygonCentroid(poly), + area: Math.abs(polygonArea(poly)), + bbox: bboxOf(poly), + } + }) + + const conflictingSurfaceIds = new Set() + const conflictingRoomIndices = new Set() + const compatibleMergesByRoomIndex = new Map() + detected.forEach((room, roomIndex) => { + const contributors = existingAuto.filter( + (surface) => + polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >= + ORPHAN_MERGE_COVERAGE_THRESHOLD, + ) + if (contributors.length < 2) return + if (new Set(contributors.map(mergeSettingsSignature)).size > 1) { + conflictingRoomIndices.add(roomIndex) + for (const surface of contributors) conflictingSurfaceIds.add(surface.id) + return + } + compatibleMergesByRoomIndex.set(roomIndex, contributors) + }) + + const matchedSurfaceIds = new Set() + const matchedDetectedIndices = new Set() + const roomIndexBySurfaceId = new Map() + const sourceSurfaceIdByRoomIndex = new Map() + const polygonBySurfaceId = new Map>() + const autoBySignature = new Map>() + for (const entry of metadata) { + const bucket = autoBySignature.get(entry.sig) ?? [] + bucket.push(entry) + autoBySignature.set(entry.sig, bucket) + } + + detected.forEach((room, index) => { + if (conflictingRoomIndices.has(index)) { + matchedDetectedIndices.add(index) + return + } + const existing = autoBySignature.get(room.sig)?.shift() + if (!existing) return + matchedDetectedIndices.add(index) + matchedSurfaceIds.add(existing.surface.id) + roomIndexBySurfaceId.set(existing.surface.id, index) + sourceSurfaceIdByRoomIndex.set(index, existing.surface.id) + polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple)) + }) + + const remainingDetected = detected + .map((room, index) => ({ room, index })) + .filter(({ index }) => !matchedDetectedIndices.has(index)) + .sort((left, right) => right.room.area - left.room.area) + const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id)) + + for (const { room, index } of remainingDetected) { + let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null + for (const entry of remainingAuto) { + if (matchedSurfaceIds.has(entry.surface.id)) continue + const distance = Math.hypot( + room.centroid.x - entry.centroid.x, + room.centroid.y - entry.centroid.y, + ) + const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999 + const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio))) + if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5) continue + const score = distance + areaPenalty * 0.35 + if (!bestMatch || score < bestMatch.score) bestMatch = { entry, score } + } + if (!bestMatch) continue + matchedDetectedIndices.add(index) + matchedSurfaceIds.add(bestMatch.entry.surface.id) + roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index) + sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id) + polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple)) + } + + detected.forEach((room, index) => { + if (sourceSurfaceIdByRoomIndex.has(index)) return + let bestSource: { id: string; coverage: number } | null = null + for (const entry of metadata) { + const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]) + if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue + bestSource = { id: entry.surface.id, coverage } + } + if (bestSource) sourceSurfaceIdByRoomIndex.set(index, bestSource.id) + }) + + const detectedRoomPolygons = detectedAll.map((room) => room.poly) + const deleted: Array = [] + const demote: AutoSurfaceMatch['demote'] = [] + for (const surface of existingAuto) { + if (polygonBySurfaceId.has(surface.id)) continue + if (conflictingSurfaceIds.has(surface.id)) { + demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) + continue + } + const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons) + if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) deleted.push(surface.id) + else demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) + } + + return { + detectedAll, + detected, + existingAuto, + compatibleMergesByRoomIndex, + matchedDetectedIndices, + roomIndexBySurfaceId, + sourceSurfaceIdByRoomIndex, + polygonBySurfaceId, + delete: deleted, + demote, + } +} + +export function planAutoZonesForLevel( + spaces: readonly Space[], + existingZones: readonly ZoneNodeType[], +): AutoZoneSyncPlan { + const update: AutoZoneSyncPlan['update'] = [] + + for (const zone of existingZones) { + const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple)) + const matchingSpace = + zone.autoFromWalls && zone.boundaryWallIds.length >= 3 + ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds)) + : spaces.find( + (space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature, + ) + if (!matchingSpace) continue + + const data: Partial = {} + if (!zone.autoFromWalls) data.autoFromWalls = true + if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) { + data.boundaryWallIds = matchingSpace.wallIds + } + if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) { + data.polygon = matchingSpace.polygon + } + if (Object.keys(data).length > 0) update.push({ id: zone.id, data }) + } + + return { update } +} + +export function resolveAutoZonePolygon( + zone: Pick, + resolve: (id: AnyNodeId) => unknown, +): ZoneNodeType['polygon'] { + if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3) return zone.polygon + const walls = zone.boundaryWallIds.flatMap((id) => { + const node = resolve(id) + return node && typeof node === 'object' && 'type' in node && node.type === 'wall' + ? [node as WallNode] + : [] + }) + if (walls.length !== zone.boundaryWallIds.length) return zone.polygon + const room = extractRooms(walls).find((candidate) => + sameStringSet( + [...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], + zone.boundaryWallIds, + ), + ) + return room ? room.polygon.map(pointToTuple) : zone.polygon +} + +export function planAutoSlabsForLevel( + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, +): AutoSlabSyncPlan { + const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature) + const { + detected, + existingAuto, + compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, + matchedDetectedIndices: matchedDetectedIdx, + roomIndexBySurfaceId: roomIndexBySlabId, + sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, + delete: slabsToDelete, + demote: slabDemotions, + } = match + const updatesById = new Map< + string, + { polygon: [number, number][]; elevation: number | undefined } + >() + for (const slab of existingAuto) { + const polygon = match.polygonBySurfaceId.get(slab.id) + if (!polygon) continue + updatesById.set(slab.id, { + polygon, + elevation: slabElevationForReconciledRoom(slab, polygon, context), + }) + } + + 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)) + } + + const slabsToUpdate = [ + ...existingAuto + .filter((slab) => updatesById.has(slab.id)) + .flatMap((slab) => { + const update = updatesById.get(slab.id) + if (!update) return [] + const roomIndex = roomIndexBySlabId.get(slab.id) + const openings = + roomIndex == null + ? { holes: slab.holes, holeMetadata: slab.holeMetadata } + : compatibleMergeSlabsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) + const data: Partial = {} + if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + if ( + update.elevation !== undefined && + Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON + ) { + data.elevation = update.elevation + } + return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [] + }), + ...slabDemotions, + ] + + const plannedSlabsForNaming: Array<{ name?: string }> = [...namingSlabs] + const slabsToCreate: SlabNodeType[] = [] + for (let index = 0; index < detected.length; index += 1) { + if (matchedDetectedIdx.has(index)) continue + + const room = detected[index] + if (!room) continue + + const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') + plannedSlabsForNaming.push({ name }) + + const polygon = room.poly.map(pointToTuple) + const sourceId = sourceSlabIdByRoomIndex.get(index) + const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined + const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined + const elevation = source + ? slabElevationForReconciledRoom(source, polygon, context) + : context.elevationForRoom?.(polygon) + slabsToCreate.push( + SlabNode.parse({ + name, + polygon, + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + elevation: + elevation !== undefined && Number.isFinite(elevation) + ? elevation + : DEFAULT_AUTO_SLAB_ELEVATION, + thickness: source?.thickness, + recessed: source?.recessed, + recessedRimElevation: source?.recessedRimElevation, + fillToTerrain: source?.fillToTerrain, + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, + autoFromWalls: true, + }), + ) + } + + return { + create: slabsToCreate, + update: slabsToUpdate, + delete: slabsToDelete, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, + context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, +) { + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs) + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) + } + + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) + } + + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) + } + + return plan +} + +export function planAutoCeilingsForLevel( + roomPolygons: Point2D[][], + existingCeilings: CeilingNodeType[], + context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, +): AutoCeilingSyncPlan { + const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) + const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature) + const { + detected, + existingAuto, + compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, + matchedDetectedIndices: matchedDetectedIdx, + roomIndexBySurfaceId: roomIndexByCeilingId, + sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, + delete: ceilingsToDelete, + demote: ceilingDemotions, + } = match + const updatesById = new Map() + for (const ceiling of existingAuto) { + const polygon = match.polygonBySurfaceId.get(ceiling.id) + if (!polygon) continue + updatesById.set(ceiling.id, { + polygon, + height: ceilingHeightForReconciledRoom(ceiling, polygon, context), + }) + } + + // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab + // created, moved, or thickened on the level above can leave an EXISTING + // manual explicit-height ceiling poking into its solid. Clamp explicit + // heights down to the bound; never raise them — a user-lowered ceiling + // is intent, only an over-bound one is a conflict. Follows-mode + // ceilings (absent height) derive under the bound by construction and + // are skipped, so the clamp can never convert one to an explicit + // height. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + if (ceiling.height == null) return [] + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + return ceiling.height > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + + const openingAssignmentsByCeilingId = new Map< + string, + ReturnType + >() + const childAssignmentsByCeilingId = new Map>() + 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), + ) + childAssignmentsByCeilingId.set( + ceiling.id, + partitionCeilingChildren( + ceiling, + roomIndices, + detected, + roomIndexByCeilingId.get(ceiling.id), + context.childPosition, + ), + ) + } + + const childReparents: AutoCeilingSyncPlan['reparent'] = [] + const ceilingsToUpdate = [ + ...existingAuto + .filter((ceiling) => updatesById.has(ceiling.id)) + .flatMap((ceiling) => { + const update = updatesById.get(ceiling.id) + if (!update) return [] + const roomIndex = roomIndexByCeilingId.get(ceiling.id) + const openings = + roomIndex == null + ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata } + : compatibleMergeCeilingsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + const mergeContributors = + roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex) + const children = mergeContributors + ? ([ + ...new Set(mergeContributors.flatMap((contributor) => contributor.children)), + ] as CeilingNodeType['children']) + : roomIndex == null + ? ceiling.children + : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children) + for (const contributor of mergeContributors ?? []) { + if (contributor.id === ceiling.id) continue + for (const childId of contributor.children) { + childReparents.push({ id: childId, parentId: ceiling.id }) + } + } + if (!sameStringSet(ceiling.children, children)) data.children = children + if ( + update.height !== undefined && + (ceiling.height === undefined || + Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON) + ) { + data.height = update.height + } + return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [] + }), + ...ceilingDemotions, + ...manualClamps, + ] + + const plannedCeilingsForNaming: Array<{ name?: string }> = [...namingCeilings] + const ceilingsToCreate: CeilingNodeType[] = [] + for (let index = 0; index < detected.length; index += 1) { + if (matchedDetectedIdx.has(index)) continue + + const room = detected[index] + if (!room) continue + + const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') + plannedCeilingsForNaming.push({ name }) + + const polygon = room.poly.map(pointToTuple) + 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 children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined + const height = source + ? ceilingHeightForReconciledRoom(source, polygon, context) + : context.heightForRoom?.(polygon) + const created = CeilingNode.parse({ + name, + polygon, + children: children ?? [], + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, + ...(height !== undefined && Number.isFinite(height) ? { height } : {}), + autoFromWalls: true, + }) + ceilingsToCreate.push(created) + for (const childId of children ?? []) { + childReparents.push({ id: childId, parentId: created.id }) + } + } + + return { + create: ceilingsToCreate, + update: ceilingsToUpdate, + delete: ceilingsToDelete, + reparent: childReparents, + } +} + +function syncAutoCeilingsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingCeilings: CeilingNodeType[], + sceneStore: any, + context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, +) { + const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings) + + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) + } + + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) + } + + if (plan.reparent.length > 0) { + sceneStore + .getState() + .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } }))) + } + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) + } +} + +function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { + const rooms = extractRooms(walls) + const roomPolygons = rooms.map((room) => room.polygon) + const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ + wallId: wall.id, + ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< + WallSideUpdate, + 'frontSide' | 'backSide' + >), + })) + + return { + rooms, + roomPolygons, + spaces: rooms.map((room) => buildSpace(levelId, room)), + wallUpdates, + } +} + +export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { + return detectSpacesFromWalls(levelId, walls) +} + +function runSpaceDetection( + levelIds: string[], + sceneStore: any, + editorStore: any, + nodes: any, + previousNodes: any, + previousRoomsByLevel: Map, +): void { + const { updateNodes } = sceneStore.getState() + const existingSpaces = editorStore.getState().spaces as Record + const nextSpaces: Record = {} + + for (const [spaceId, space] of Object.entries(existingSpaces)) { + if (!levelIds.includes(space.levelId)) { + nextSpaces[spaceId] = space + } + } + + for (const levelId of levelIds) { + const children = levelChildren(nodes, levelId) + const walls = children.filter( + (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + const slabs = children.filter((node: any) => node?.type === 'slab') + const ceilings = children.filter((node: any) => node?.type === 'ceiling') + const zones = children.filter((node: any) => node?.type === 'zone') + + const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls) + + const changedWallUpdates = wallUpdates.filter((update) => { + const wall = nodes[update.wallId] + return 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, + }, + })), + ) + } + + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const parsedSlabs: SlabNodeType[] = slabs.map((slab: any) => SlabNode.parse(slab)) + const parsedCeilings: CeilingNodeType[] = ceilings.map((ceiling: any) => + CeilingNode.parse(ceiling), + ) + const previousRooms = previousRoomsByLevel.get(levelId) ?? [] + const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs) + const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings) + const verticalPlacements = autoRoomVerticalPlacements( + spaces, + walls, + // A derived floor cannot be evidence for its own next elevation: that + // would lift unpinned walls, then lift the floor again on every pass. + parsedSlabs.filter((slab) => !slab.autoFromWalls), + nodes, + storeyHeight, + ) + const previousChildren = levelChildren(previousNodes, levelId) + const previousWalls = previousChildren.filter( + (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + const previousSlabs: SlabNodeType[] = previousChildren + .filter((node: any) => node?.type === 'slab') + .map((slab: any) => SlabNode.parse(slab)) + const previousLevelNode = previousNodes[levelId] + const previousStoreyHeight = + previousLevelNode?.type === 'level' + ? getStoredLevelHeight(previousLevelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces + const previousVerticalPlacements = autoRoomVerticalPlacements( + previousSpaces, + previousWalls, + previousSlabs.filter((slab) => !slab.autoFromWalls), + previousNodes, + previousStoreyHeight, + ) + const placementFor = (polygon: Array<[number, number]>) => + verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + const previousPlacementFor = (polygon: Array<[number, number]>) => + previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + syncAutoSlabsForLevel( + levelId, + slabRooms.map((room) => room.polygon), + parsedSlabs, + sceneStore, + { + elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, + previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, + }, + ) + syncAutoCeilingsForLevel( + levelId, + ceilingRooms.map((room) => room.polygon), + parsedCeilings, + sceneStore, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, + previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, + childPosition: (childId) => { + const child = nodes[childId] + return child && Array.isArray(child.position) + ? [child.position[0], child.position[2]] + : undefined + }, + }, + ) + const zonePlan = planAutoZonesForLevel( + spaces, + zones.map((zone: any) => ZoneNode.parse(zone)), + ) + if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + + for (const space of spaces) { + nextSpaces[space.id] = space + } + previousRoomsByLevel.set(levelId, rooms) + } + + editorStore.getState().setSpaces(nextSpaces) +} + +function runIndexedSpaceDetection( + levelId: string, + topologyDelta: IndexedTopologyDelta, + sceneStore: any, + editorStore: any, + nodes: SceneNodes, + previousNodes: SceneNodes, +) { + const { updateNodes } = sceneStore.getState() + const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) + const changedWallUpdates = topologyDelta.currentWalls + .map((wall) => ({ + wallId: wall.id, + ...resolveWallSurfaceSides(wall, allRoomPolygons), + })) + .filter((update) => { + const wall = nodes[update.wallId] + 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 }, + })), + ) + } + + const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] + if (scopedRooms.length > 0) { + const unaffectedRooms = topologyDelta.allCurrentRooms.filter( + (room) => !topologyDelta.currentRooms.includes(room), + ) + const currentChildren = levelChildren(nodes, levelId) + const allSlabs: SlabNodeType[] = currentChildren + .filter((node: any): node is SlabNodeType => node.type === 'slab') + .map((slab: SlabNodeType) => SlabNode.parse(slab)) + const allCeilings: CeilingNodeType[] = currentChildren + .filter((node: any): node is CeilingNodeType => node.type === 'ceiling') + .map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling)) + const slabs = allSlabs.filter( + (slab) => + surfaceTouchesRooms(slab, scopedRooms) && + (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)), + ) + const ceilings = allCeilings.filter( + (ceiling) => + surfaceTouchesRooms(ceiling, scopedRooms) && + (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)), + ) + const slabRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + slabs, + ) + const ceilingRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + ceilings, + ) + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room)) + const verticalPlacements = autoRoomVerticalPlacements( + currentSpaces, + topologyDelta.currentWalls, + allSlabs.filter((slab) => !slab.autoFromWalls), + nodes, + storeyHeight, + ) + const previousChildren = levelChildren(previousNodes, levelId) + const previousSlabs: SlabNodeType[] = previousChildren + .filter((node: any): node is SlabNodeType => node.type === 'slab') + .map((slab: SlabNodeType) => SlabNode.parse(slab)) + const previousLevelNode = previousNodes[levelId] + const previousStoreyHeight = + previousLevelNode?.type === 'level' + ? getStoredLevelHeight(previousLevelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room)) + const previousVerticalPlacements = autoRoomVerticalPlacements( + previousSpaces, + topologyDelta.previousWalls, + previousSlabs.filter((slab) => !slab.autoFromWalls), + previousNodes, + previousStoreyHeight, + ) + const placementFor = (polygon: Array<[number, number]>) => + verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + const previousPlacementFor = (polygon: Array<[number, number]>) => + previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + + syncAutoSlabsForLevel( + levelId, + slabRooms.map((room) => room.polygon), + slabs, + sceneStore, + { + elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, + previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, + }, + allSlabs, + ) + syncAutoCeilingsForLevel( + levelId, + ceilingRooms.map((room) => room.polygon), + ceilings, + sceneStore, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, + previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, + childPosition: (childId) => { + const child = nodes[childId] + return child && Array.isArray(child.position) + ? [child.position[0], child.position[2]] + : undefined + }, + }, + allCeilings, + ) + } + + const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) + const zones: ZoneNodeType[] = levelChildren(nodes, levelId) + .filter((node: any): node is ZoneNodeType => node.type === 'zone') + .map((zone: ZoneNodeType) => ZoneNode.parse(zone)) + const zonePlan = planAutoZonesForLevel(spaces, zones) + if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + + 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) +} + +// Refcount of outstanding pause requests, matching the pauseSceneHistory +// pattern. The community editor flips this off while the AI is actively +// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race +// `create_room`'s explicit slabs/ceilings (see plan +// `ai-pause-space-detection`). +let spaceDetectionPauseDepth = 0 + +/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */ +export function pauseSpaceDetection(): void { + spaceDetectionPauseDepth += 1 +} + +/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */ +export function resumeSpaceDetection(): void { + if (spaceDetectionPauseDepth === 0) return + spaceDetectionPauseDepth -= 1 +} + +/** True iff the wall-driven auto slab/ceiling sync is currently paused. */ +export function isSpaceDetectionPaused(): boolean { + return spaceDetectionPauseDepth > 0 +} + +export function initSpaceDetectionSync( + sceneStore: any, + editorStore: any, + options: SpaceDetectionSyncOptions = {}, +): () => 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 initialNodes = sceneStore.getState().nodes + const previousRoomsByLevel = new Map() + const topologyIndex = new RoomTopologyIndex({ + detectRooms: extractRooms, + sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple), + junctionTolerance: WALL_JUNCTION_TOLERANCE, + }) + let previousNodes = initialNodes + let isProcessing = false + + const adoptSceneBaseline = (nodes: SceneNodes) => { + topologyIndex.rebuild(nodes) + const roomsByLevel = detectedRoomsByLevel(nodes) + previousRoomsByLevel.clear() + const spaces: Record = {} + for (const [levelId, rooms] of roomsByLevel) { + previousRoomsByLevel.set(levelId, rooms) + for (const room of rooms) { + const space = buildSpace(levelId, room) + spaces[space.id] = space + } + } + editorStore.getState().setSpaces(spaces) + previousNodes = nodes + } + + adoptSceneBaseline(initialNodes) + + const unsubscribeCommits = subscribeSceneCommits((commit) => { + if (commit.origin === 'local') return + adoptSceneBaseline(commit.current.nodes) + }) + + const unsubscribe = sceneStore.subscribe((state: any) => { + if (isProcessing) return + if (getSceneHistoryPauseDepth() > 0) return + + const nodes = state.nodes + const candidateIds = activeSceneCommitNodeIds() + + // 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) { + adoptSceneBaseline(nodes) + return + } + + const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds) + if (candidateIds && changedWalls.size > 0) { + const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds) + for (const levelId of changedWalls.keys()) fallbackLevels.delete(levelId) + isProcessing = true + pauseSceneHistory(sceneStore) + try { + for (const [levelId, wallIds] of changedWalls) { + const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes) + runIndexedSpaceDetection( + levelId, + topologyDelta, + sceneStore, + editorStore, + nodes, + previousNodes, + ) + previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms) + options.onTopologyReconcile?.({ + levelId, + strategy: topologyDelta.strategy, + examinedWallIds: topologyDelta.examinedWallIds, + affectedBeforeRoomCount: topologyDelta.beforeRooms.length, + affectedCurrentRoomCount: topologyDelta.currentRooms.length, + }) + } + if (fallbackLevels.size > 0) { + runSpaceDetection( + [...fallbackLevels], + sceneStore, + editorStore, + sceneStore.getState().nodes, + previousNodes, + previousRoomsByLevel, + ) + const liveNodes = sceneStore.getState().nodes + for (const levelId of fallbackLevels) topologyIndex.rebuildLevel(levelId, liveNodes) + } + } finally { + resumeSceneHistory(sceneStore) + previousNodes = sceneStore.getState().nodes + isProcessing = false + } + return + } + + const levelsToUpdate = new Set() + if (candidateIds) { + for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) { + levelsToUpdate.add(levelId) + } + } else { + const previousSnapshots = levelStructureSnapshots(previousNodes) + const currentSnapshots = levelStructureSnapshots(nodes) + 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) { + if (candidateIds) { + previousNodes = nodes + return + } + const currentRoomsByLevel = detectedRoomsByLevel(nodes) + previousRoomsByLevel.clear() + for (const [levelId, rooms] of currentRoomsByLevel) { + previousRoomsByLevel.set(levelId, rooms) + } + previousNodes = nodes + return + } + + isProcessing = true + pauseSceneHistory(sceneStore) + try { + runSpaceDetection( + [...levelsToUpdate], + sceneStore, + editorStore, + nodes, + previousNodes, + previousRoomsByLevel, + ) + } finally { + resumeSceneHistory(sceneStore) + const liveNodes = sceneStore.getState().nodes + for (const levelId of levelsToUpdate) topologyIndex.rebuildLevel(levelId, liveNodes) + previousNodes = liveNodes + isProcessing = false + } + }) + + return () => { + unsubscribe() + unsubscribeCommits() + } +} + +export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { + const threshold = 0.1 + + for (const other of otherWalls) { + if (other.id === wall.id) continue + + if ( + distanceToSegment(wall.start, other.start, other.end) < threshold || + distanceToSegment(wall.end, other.start, other.end) < threshold || + distanceToSegment(other.start, wall.start, wall.end) < threshold || + distanceToSegment(other.end, wall.start, wall.end) < threshold + ) { + return true + } + } + + return false +} diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 871303c41b..a38c255062 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,118 +1,120 @@ -import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' -import type { AnyNode, AnyNodeId } from '../schema/types' -import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' -import { resolveWallTop } from '../systems/wall/wall-top' -// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: -// both sides only reference the other inside function bodies. -import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' - -export const DEFAULT_LEVEL_HEIGHT = 2.5 - -/** - * Effective ceiling height in level-local meters. An explicit stored - * `height` wins; absent height means the ceiling follows the level top — - * the same bound its write-clamp uses: min(storey plane, lowest - * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see - * {@link getCeilingClampBound}). Falls back to the default plane minus - * the same margin when the owning level is unresolvable. - */ -export function resolveCeilingHeight( - ceiling: Pick, - nodes: Record, -): number { - if (ceiling.height != null) return ceiling.height - const bound = - typeof ceiling.parentId === 'string' - ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) - : Number.POSITIVE_INFINITY - return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN -} - -export function deriveLegacyLevelHeight( - levelId: string, - nodes: Record, -): number { - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return DEFAULT_LEVEL_HEIGHT - - const levelChildren = level.children - .map((childId) => nodes[childId as keyof typeof nodes]) - .filter((child): child is AnyNode => child !== undefined) - const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') - const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') - - let maxTop = 0 - - for (const child of levelChildren) { - if (child.type === 'ceiling') { - // Absence here is the PRE-migration legacy schema default (2.5), not - // follows-mode — this derivation runs before the level has a height - // for a follows-mode bound to track. - const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } else if (child.type === 'wall') { - const wall = child as WallNode - const electedElevation = computeWallSlabSupport( - { - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - thickness: wall.thickness, - }, - slabs, - walls, - ).elevation - const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) - if (top > maxTop) maxTop = top - } - } - - return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT -} - -/** - * The ceiling covering level-local point `[x, z]`, or `null` when none - * sits over it. Points inside a ceiling's hole are treated as uncovered. - * When ceilings overlap, the lowest one wins — that's the surface a duct - * would actually hang from. - */ -export function getCeilingAt( - levelId: string, - nodes: Record, - x: number, - z: number, -): CeilingNode | null { - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return null - - let best: CeilingNode | null = null - let bestHeight = Number.POSITIVE_INFINITY - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (child?.type !== 'ceiling') continue - const ceiling = child as CeilingNode - if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue - if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue - const h = resolveCeilingHeight(ceiling, nodes) - if (best === null || h < bestHeight) { - best = ceiling - bestHeight = h - } - } - return best -} - -/** - * Underside elevation (meters above the level floor) of the ceiling - * covering level-local point `[x, z]`, or `null` when no ceiling sits - * over that point. See {@link getCeilingAt}. - */ -export function getCeilingHeightAt( - levelId: string, - nodes: Record, - x: number, - z: number, -): number | null { - const ceiling = getCeilingAt(levelId, nodes, x, z) - return ceiling ? resolveCeilingHeight(ceiling, nodes) : null -} +import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: +// both sides only reference the other inside function bodies. +import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' + +export const DEFAULT_LEVEL_HEIGHT = 2.5 + +/** + * Effective ceiling height in level-local meters. An explicit stored + * `height` wins; absent height means the ceiling follows the level top — + * the same bound its write-clamp uses: min(storey plane, lowest + * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see + * {@link getCeilingClampBound}). Falls back to the default plane minus + * the same margin when the owning level is unresolvable. + */ +export function resolveCeilingHeight( + ceiling: Pick, + nodes: Record, +): number { + if (ceiling.height != null) return ceiling.height + const bound = + typeof ceiling.parentId === 'string' + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN +} + +export function deriveLegacyLevelHeight( + levelId: string, + nodes: Record, +): number { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return DEFAULT_LEVEL_HEIGHT + + const levelChildren = level.children + .map((childId) => nodes[childId as keyof typeof nodes]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') + const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') + + let maxTop = 0 + + for (const child of levelChildren) { + if (child.type === 'ceiling') { + // Absence here is the PRE-migration legacy schema default (2.5), not + // follows-mode — this derivation runs before the level has a height + // for a follows-mode bound to track. + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + const wall = child as WallNode + const electedElevation = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const topStart = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 0) + const topEnd = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 1) + const top = Math.max(topStart, topEnd) + if (top > maxTop) maxTop = top + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + let bestHeight = Number.POSITIVE_INFINITY + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = resolveCeilingHeight(ceiling, nodes) + if (best === null || h < bestHeight) { + best = ceiling + bestHeight = h + } + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? resolveCeilingHeight(ceiling, nodes) : null +} diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index fb84eb4e0e..dd7fc9f68f 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,565 +1,569 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - calculateLevelMiters, - getWallCurveLength, - getWallEffectiveHeightForNodes, - getWallMiterBoundaryPoints, - getWallPlanFootprint, - getWallSurfacePolygon, - type ItemNode, - isCurvedWall, - type Point2D, - pointToKey, - sampleWallCenterline, - sceneRegistry, - useScene, - type WallMiterData, - type WallNode, -} from '@pascal-app/core' -import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useMemo, useState } from 'react' -import * as THREE from 'three' -import { formatLinearMeasurement } from '../../lib/measurements' - -const GUIDE_Y_OFFSET = 0.08 -const LABEL_LIFT = 0.08 -const BAR_THICKNESS = 0.012 -const LINE_OPACITY = 0.95 -const HEIGHT_TICK_HALF_LENGTH = 0.14 -const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 - -const BAR_AXIS = new THREE.Vector3(0, 1, 0) -// Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length -// × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame. -// Per-frame `` triggers R3F to -// rebuild the geometry whenever the wall moves, and the WebGPU backend -// flags the in-flight buffer churn as "Vertex buffer slot N ... was not set". -const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) - -type Vec3 = [number, number, number] - -type MeasurementGuide = { - guidePath: Vec3[] - extStartStart: Vec3 - extStartEnd: Vec3 - extEndStart: Vec3 - extEndEnd: Vec3 - labelPosition: Vec3 - heightStart: Vec3 - heightEnd: Vec3 - heightBottomTickStart: Vec3 - heightBottomTickEnd: Vec3 - heightTopTickStart: Vec3 - heightTopTickEnd: Vec3 - heightLabelPosition: Vec3 -} - -type WallFaceLine = { - start: Point2D - end: Point2D -} - -export function WallMeasurementLabel() { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const nodes = useScene((state) => state.nodes) - - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null - const measurableNode = selectedNode?.type === 'item' ? selectedNode : null - - const [objectState, setObjectState] = useState<{ - id: AnyNodeId - object: THREE.Object3D - } | null>(null) - const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null - - useFrame(() => { - if (!selectedId || selectedObject) return - - const nextObject = sceneRegistry.nodes.get(selectedId) - if (nextObject) { - setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) - } - }) - - if (!(measurableNode && selectedObject)) return null - - return createPortal(, selectedObject) -} - -function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { - if (!wall.parentId) return [wall] - - const levelNode = nodes[wall.parentId as AnyNodeId] - if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) { - return [wall] - } - - return levelNode.children - .map((childId) => nodes[childId as AnyNodeId]) - .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) -} - -function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { - if (!point) return false - - return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 -} - -function getWallFaceLines( - wall: WallNode, - miterData: WallMiterData, -): { left: WallFaceLine; right: WallFaceLine } | null { - if (isCurvedWall(wall)) return null - - const footprint = getWallPlanFootprint(wall, miterData) - if (footprint.length < 4) return null - - const startRight = footprint[0] - const endRight = footprint[1] - const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) - const endLeft = footprint[hasEndCenterPoint ? 3 : 2] - const lastPoint = footprint[footprint.length - 1] - const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) - const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] - - if (!(startRight && endRight && endLeft && startLeft)) return null - - return { - left: { - start: startLeft, - end: endLeft, - }, - right: { - start: startRight, - end: endRight, - }, - } -} - -function getLineMidpoint(line: WallFaceLine): Point2D { - return { - x: (line.start.x + line.end.x) / 2, - y: (line.start.y + line.end.y) / 2, - } -} - -function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const candidateWall of levelWalls) { - minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) - maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) - minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) - maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) - } - - return { - x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, - y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, - } -} - -function getWallOuterFaceLine( - wall: WallNode, - miterData: WallMiterData, - levelWalls: WallNode[], -): WallFaceLine | null { - const faceLines = getWallFaceLines(wall, miterData) - if (!faceLines) return null - - if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { - return faceLines.left - } - - if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { - return faceLines.right - } - - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - if (length < 1e-6) return null - - const wallMidpoint = { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - } - const levelCenter = getLevelWallsCenter(levelWalls) - const normal = { x: -dy / length, y: dx / length } - const fromCenter = { - x: wallMidpoint.x - levelCenter.x, - y: wallMidpoint.y - levelCenter.y, - } - const outwardNormal = - fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } - const rightMidpoint = getLineMidpoint(faceLines.right) - const leftMidpoint = getLineMidpoint(faceLines.left) - const rightScore = - (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y - const leftScore = - (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y - - return rightScore >= leftScore ? faceLines.right : faceLines.left -} - -function getWallMiddlePoints( - wall: WallNode, - miterData: WallMiterData, -): { start: Point2D; end: Point2D } | null { - const footprint = getWallPlanFootprint(wall, miterData) - if (footprint.length < 4) return null - - const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) - const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) - - const rightStart = footprint[0] - const rightEnd = footprint[1] - const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2] - const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1] - - if (!(leftStart && leftEnd && rightStart && rightEnd)) return null - - return { - start: { - x: (leftStart.x + rightStart.x) / 2, - y: (leftStart.y + rightStart.y) / 2, - }, - end: { - x: (leftEnd.x + rightEnd.x) / 2, - y: (leftEnd.y + rightEnd.y) / 2, - }, - } -} - -function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { - const dx = point.x - wall.start[0] - const dz = point.y - wall.start[1] - const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) - const cosA = Math.cos(-angle) - const sinA = Math.sin(-angle) - - return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] -} - -function getWallExteriorOffsetSign( - wall: Pick, - levelWalls: WallNode[], -) { - if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { - return 1 - } - - if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { - return -1 - } - - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - - if (length < 1e-6) return 1 - - const wallMidpoint = { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - } - const levelCenter = getLevelWallsCenter(levelWalls) - const normal = { x: -dy / length, y: dx / length } - const fromCenter = { - x: wallMidpoint.x - levelCenter.x, - y: wallMidpoint.y - levelCenter.y, - } - - return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1 -} - -function getCurvedWallMeasurementPath( - wall: WallNode, - miterData: WallMiterData, - levelWalls: WallNode[], -): Point2D[] | null { - const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) - if (!boundaryPoints) return null - - const surface = getWallSurfacePolygon(wall, 24, boundaryPoints) - const sidePointCount = 25 - if (surface.length < sidePointCount * 2) return null - - const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) - if (offsetSign >= 0) { - return surface.slice(sidePointCount).reverse() - } - - return surface.slice(0, sidePointCount) -} - -function buildMeasurementGuide( - wall: WallNode, - nodes: Record, -): MeasurementGuide | null { - const levelWalls = getLevelWalls(wall, nodes) - const miterData = calculateLevelMiters(levelWalls) - const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) - const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) - const measurementPoints = measurementLine ?? fallbackMiddlePoints - if (!measurementPoints) return null - - const height = getWallEffectiveHeightForNodes(wall, nodes) - const startLocal = worldPointToWallLocal(wall, measurementPoints.start) - const endLocal = worldPointToWallLocal(wall, measurementPoints.end) - const curvedMeasurementPath = isCurvedWall(wall) - ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) - : null - const guidePath: Vec3[] = curvedMeasurementPath - ? curvedMeasurementPath.map((point) => { - const localPoint = worldPointToWallLocal(wall, point) - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] - }) - : isCurvedWall(wall) - ? sampleWallCenterline(wall, 24).map((point, index, points) => { - const localPoint = - index === 0 - ? startLocal - : index === points.length - 1 - ? endLocal - : worldPointToWallLocal(wall, point) - - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] - }) - : [ - [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]], - [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]], - ] - - if (guidePath.length < 2) return null - - let guideLength = 0 - for (let index = 1; index < guidePath.length; index += 1) { - const prev = guidePath[index - 1]! - const next = guidePath[index]! - guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2]) - } - - if (!Number.isFinite(guideLength) || guideLength < 0.001) return null - - // Extension lines coming out of the extremity markers of the wall - const extOvershoot = 0.04 - const guideStart = guidePath[0]! - const guideEnd = guidePath[guidePath.length - 1]! - const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal - const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal - const midpoint = curvedMeasurementPath - ? guidePath[Math.floor(guidePath.length / 2)]! - : ([ - (guideStart[0] + guideEnd[0]) / 2, - guideStart[1], - (guideStart[2] + guideEnd[2]) / 2, - ] as Vec3) - const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 - const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart - const tickDx = guideEnd[0] - beforeGuideEnd[0] - const tickDz = guideEnd[2] - beforeGuideEnd[2] - const tickLength = Math.hypot(tickDx, tickDz) - const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 - const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 - const tickUnitX = -tangentZ - const tickUnitZ = tangentX - const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) - const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] - const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] - const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 - const heightGuidePosition = [ - rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, - 0, - rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, - ] as Vec3 - const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ - start: [ - heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, - y, - heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, - ], - end: [ - heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, - y, - heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, - ], - }) - const bottomHeightTick = getHorizontalHeightTick(0) - const topHeightTick = getHorizontalHeightTick(height) - - return { - guidePath, - extStartStart: [extensionStartBase[0], height, extensionStartBase[2]], - extStartEnd: [ - extensionStartBase[0], - height + GUIDE_Y_OFFSET + extOvershoot, - extensionStartBase[2], - ], - extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], - extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], - labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], - heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], - heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], - heightBottomTickStart: bottomHeightTick.start, - heightBottomTickEnd: bottomHeightTick.end, - heightTopTickStart: topHeightTick.start, - heightTopTickEnd: topHeightTick.end, - heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], - } -} - -function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { - const segment = useMemo(() => { - const startVector = new THREE.Vector3(...start) - const endVector = new THREE.Vector3(...end) - const direction = endVector.clone().sub(startVector) - const length = direction.length() - - if (!Number.isFinite(length) || length < 0.0001) return null - - return { - length, - position: startVector.clone().add(endVector).multiplyScalar(0.5), - quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()), - } - }, [end, start]) - - if (!segment) return null - - return ( - - - - ) -} - -function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { - return ( - <> - {path.slice(1).map((point, index) => ( - - ))} - - ) -} - -function MeasurementLabel({ - label, - position, - color, - shadowColor, -}: { - label: string - position: Vec3 - color: string - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - -function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { - if (node.type === 'wall') { - return - } - - return null -} - -function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { - const nodes = useScene((state) => state.nodes) - const unit = useViewer((state) => state.unit) - const metricNotation = useViewer((state) => state.metricNotation) - const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') - const color = isNight ? '#ffffff' : '#111111' - const shadowColor = isNight ? '#111111' : '#ffffff' - - const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) - const length = useMemo(() => { - if (!guide?.guidePath?.length || guide.guidePath.length < 2) { - return getWallCurveLength(wall) - } - - let total = 0 - for (let index = 1; index < guide.guidePath.length; index += 1) { - const prev = guide.guidePath[index - 1]! - const next = guide.guidePath[index]! - total += Math.hypot(next[0] - prev[0], next[2] - prev[2]) - } - return total - }, [guide, wall]) - const label = formatLinearMeasurement(length, unit, metricNotation) - const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) - const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}` - - if (!(guide && Number.isFinite(length) && length >= 0.01)) return null - - return ( - - - - - - - - - - - - ) -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + calculateLevelMiters, + getWallCurveLength, + getWallEffectiveHeightForNodes, + getWallMiterBoundaryPoints, + getWallPlanFootprint, + getWallSurfacePolygon, + type ItemNode, + isCurvedWall, + type Point2D, + pointToKey, + sampleWallCenterline, + sceneRegistry, + useScene, + type WallMiterData, + type WallNode, +} from '@pascal-app/core' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { createPortal, useFrame } from '@react-three/fiber' +import { useMemo, useState } from 'react' +import * as THREE from 'three' +import { formatLinearMeasurement } from '../../lib/measurements' + +const GUIDE_Y_OFFSET = 0.08 +const LABEL_LIFT = 0.08 +const BAR_THICKNESS = 0.012 +const LINE_OPACITY = 0.95 +const HEIGHT_TICK_HALF_LENGTH = 0.14 +const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 + +const BAR_AXIS = new THREE.Vector3(0, 1, 0) +// Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length +// × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame. +// Per-frame `` triggers R3F to +// rebuild the geometry whenever the wall moves, and the WebGPU backend +// flags the in-flight buffer churn as "Vertex buffer slot N ... was not set". +const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) + +type Vec3 = [number, number, number] + +type MeasurementGuide = { + guidePath: Vec3[] + extStartStart: Vec3 + extStartEnd: Vec3 + extEndStart: Vec3 + extEndEnd: Vec3 + labelPosition: Vec3 + heightStart: Vec3 + heightEnd: Vec3 + heightBottomTickStart: Vec3 + heightBottomTickEnd: Vec3 + heightTopTickStart: Vec3 + heightTopTickEnd: Vec3 + heightLabelPosition: Vec3 +} + +type WallFaceLine = { + start: Point2D + end: Point2D +} + +export function WallMeasurementLabel() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const nodes = useScene((state) => state.nodes) + + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null + const measurableNode = selectedNode?.type === 'item' ? selectedNode : null + + const [objectState, setObjectState] = useState<{ + id: AnyNodeId + object: THREE.Object3D + } | null>(null) + const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null + + useFrame(() => { + if (!selectedId || selectedObject) return + + const nextObject = sceneRegistry.nodes.get(selectedId) + if (nextObject) { + setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) + } + }) + + if (!(measurableNode && selectedObject)) return null + + return createPortal(, selectedObject) +} + +function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { + if (!wall.parentId) return [wall] + + const levelNode = nodes[wall.parentId as AnyNodeId] + if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) { + return [wall] + } + + return levelNode.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) +} + +function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { + if (!point) return false + + return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 +} + +function getWallFaceLines( + wall: WallNode, + miterData: WallMiterData, +): { left: WallFaceLine; right: WallFaceLine } | null { + if (isCurvedWall(wall)) return null + + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startRight = footprint[0] + const endRight = footprint[1] + const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) + const endLeft = footprint[hasEndCenterPoint ? 3 : 2] + const lastPoint = footprint[footprint.length - 1] + const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) + const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] + + if (!(startRight && endRight && endLeft && startLeft)) return null + + return { + left: { + start: startLeft, + end: endLeft, + }, + right: { + start: startRight, + end: endRight, + }, + } +} + +function getLineMidpoint(line: WallFaceLine): Point2D { + return { + x: (line.start.x + line.end.x) / 2, + y: (line.start.y + line.end.y) / 2, + } +} + +function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const candidateWall of levelWalls) { + minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) + maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) + minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) + maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) + } + + return { + x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, + y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, + } +} + +function getWallOuterFaceLine( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): WallFaceLine | null { + const faceLines = getWallFaceLines(wall, miterData) + if (!faceLines) return null + + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return faceLines.left + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return faceLines.right + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + const outwardNormal = + fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } + const rightMidpoint = getLineMidpoint(faceLines.right) + const leftMidpoint = getLineMidpoint(faceLines.left) + const rightScore = + (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y + const leftScore = + (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y + + return rightScore >= leftScore ? faceLines.right : faceLines.left +} + +function getWallMiddlePoints( + wall: WallNode, + miterData: WallMiterData, +): { start: Point2D; end: Point2D } | null { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) + const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) + + const rightStart = footprint[0] + const rightEnd = footprint[1] + const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2] + const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1] + + if (!(leftStart && leftEnd && rightStart && rightEnd)) return null + + return { + start: { + x: (leftStart.x + rightStart.x) / 2, + y: (leftStart.y + rightStart.y) / 2, + }, + end: { + x: (leftEnd.x + rightEnd.x) / 2, + y: (leftEnd.y + rightEnd.y) / 2, + }, + } +} + +function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { + const dx = point.x - wall.start[0] + const dz = point.y - wall.start[1] + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cosA = Math.cos(-angle) + const sinA = Math.sin(-angle) + + return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] +} + +function getWallExteriorOffsetSign( + wall: Pick, + levelWalls: WallNode[], +) { + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return 1 + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return -1 + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-6) return 1 + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + + return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1 +} + +function getCurvedWallMeasurementPath( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): Point2D[] | null { + const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) + if (!boundaryPoints) return null + + const surface = getWallSurfacePolygon(wall, 24, boundaryPoints) + const sidePointCount = 25 + if (surface.length < sidePointCount * 2) return null + + const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) + if (offsetSign >= 0) { + return surface.slice(sidePointCount).reverse() + } + + return surface.slice(0, sidePointCount) +} + +function buildMeasurementGuide( + wall: WallNode, + nodes: Record, +): MeasurementGuide | null { + const levelWalls = getLevelWalls(wall, nodes) + const miterData = calculateLevelMiters(levelWalls) + const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) + const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) + const measurementPoints = measurementLine ?? fallbackMiddlePoints + if (!measurementPoints) return null + + const height = getWallEffectiveHeightForNodes(wall, nodes) + const startLocal = worldPointToWallLocal(wall, measurementPoints.start) + const endLocal = worldPointToWallLocal(wall, measurementPoints.end) + const curvedMeasurementPath = isCurvedWall(wall) + ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) + : null + const guidePath: Vec3[] = curvedMeasurementPath + ? curvedMeasurementPath.map((point, index, points) => { + const localPoint = worldPointToWallLocal(wall, point) + const t = points.length > 1 ? index / (points.length - 1) : 0 + const h = getWallEffectiveHeightForNodes(wall, nodes, t) + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] + }) + : isCurvedWall(wall) + ? sampleWallCenterline(wall, 24).map((point, index, points) => { + const localPoint = + index === 0 + ? startLocal + : index === points.length - 1 + ? endLocal + : worldPointToWallLocal(wall, point) + const t = points.length > 1 ? index / (points.length - 1) : 0 + const h = getWallEffectiveHeightForNodes(wall, nodes, t) + + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] + }) + : [ + [startLocal[0], getWallEffectiveHeightForNodes(wall, nodes, 0) + GUIDE_Y_OFFSET, startLocal[2]], + [endLocal[0], getWallEffectiveHeightForNodes(wall, nodes, 1) + GUIDE_Y_OFFSET, endLocal[2]], + ] + + if (guidePath.length < 2) return null + + let guideLength = 0 + for (let index = 1; index < guidePath.length; index += 1) { + const prev = guidePath[index - 1]! + const next = guidePath[index]! + guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2]) + } + + if (!Number.isFinite(guideLength) || guideLength < 0.001) return null + + // Extension lines coming out of the extremity markers of the wall + const extOvershoot = 0.04 + const guideStart = guidePath[0]! + const guideEnd = guidePath[guidePath.length - 1]! + const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal + const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal + const midpoint = curvedMeasurementPath + ? guidePath[Math.floor(guidePath.length / 2)]! + : ([ + (guideStart[0] + guideEnd[0]) / 2, + guideStart[1], + (guideStart[2] + guideEnd[2]) / 2, + ] as Vec3) + const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 + const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart + const tickDx = guideEnd[0] - beforeGuideEnd[0] + const tickDz = guideEnd[2] - beforeGuideEnd[2] + const tickLength = Math.hypot(tickDx, tickDz) + const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 + const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 + const tickUnitX = -tangentZ + const tickUnitZ = tangentX + const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) + const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] + const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] + const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 + const heightGuidePosition = [ + rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + 0, + rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + ] as Vec3 + const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ + start: [ + heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + end: [ + heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + }) + const bottomHeightTick = getHorizontalHeightTick(0) + const topHeightTick = getHorizontalHeightTick(height) + + return { + guidePath, + extStartStart: [extensionStartBase[0], height, extensionStartBase[2]], + extStartEnd: [ + extensionStartBase[0], + height + GUIDE_Y_OFFSET + extOvershoot, + extensionStartBase[2], + ], + extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], + extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], + labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], + heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], + heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], + heightBottomTickStart: bottomHeightTick.start, + heightBottomTickEnd: bottomHeightTick.end, + heightTopTickStart: topHeightTick.start, + heightTopTickEnd: topHeightTick.end, + heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], + } +} + +function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { + const segment = useMemo(() => { + const startVector = new THREE.Vector3(...start) + const endVector = new THREE.Vector3(...end) + const direction = endVector.clone().sub(startVector) + const length = direction.length() + + if (!Number.isFinite(length) || length < 0.0001) return null + + return { + length, + position: startVector.clone().add(endVector).multiplyScalar(0.5), + quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()), + } + }, [end, start]) + + if (!segment) return null + + return ( + + + + ) +} + +function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { + return ( + <> + {path.slice(1).map((point, index) => ( + + ))} + + ) +} + +function MeasurementLabel({ + label, + position, + color, + shadowColor, +}: { + label: string + position: Vec3 + color: string + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} + +function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { + if (node.type === 'wall') { + return + } + + return null +} + +function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { + const nodes = useScene((state) => state.nodes) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const color = isNight ? '#ffffff' : '#111111' + const shadowColor = isNight ? '#111111' : '#ffffff' + + const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) + const length = useMemo(() => { + if (!guide?.guidePath?.length || guide.guidePath.length < 2) { + return getWallCurveLength(wall) + } + + let total = 0 + for (let index = 1; index < guide.guidePath.length; index += 1) { + const prev = guide.guidePath[index - 1]! + const next = guide.guidePath[index]! + total += Math.hypot(next[0] - prev[0], next[2] - prev[2]) + } + return total + }, [guide, wall]) + const label = formatLinearMeasurement(length, unit, metricNotation) + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}` + + if (!(guide && Number.isFinite(length) && length >= 0.01)) return null + + return ( + + + + + + + + + + + + ) +} diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 6e6165d33f..06cbafc2f8 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -1,1045 +1,1049 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - type FenceNode, - getWallBaseElevationForNodes, - getWallCurveFrameAt, - getWallEffectiveHeightForNodes, - getWallThickness, - isCurvedWall, - MIN_WALL_HEIGHT, - sceneRegistry, - useLiveNodeOverrides, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef, useState } from 'react' -import { - BufferGeometry, - Color, - CylinderGeometry, - DoubleSide, - ExtrudeGeometry, - type Group, - type Object3D, - OrthographicCamera, - Plane, - Quaternion, - Shape, - Vector2, - Vector3, -} from 'three' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { MeshBasicNodeMaterial } from 'three/webgpu' -import { - clearStructuralElevationGuide, - publishStructuralElevationGuide, - resolveStructuralElevationSnap, -} from '../../lib/elevation-guides' -import { isHistoryShortcut } from '../../lib/history' -import { endpointReshapeScope } from '../../lib/interaction/scope' -import { sfxEmitter } from '../../lib/sfx-bus' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' -import useInteractionScope, { - useEndpointReshape, - useIsCurveReshape, - useMovingNode, -} from '../../store/use-interaction-scope' -import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' -import { resolveResizeSnapValue } from './handles/resize-snap' -import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' -import { - createArrowHitAreaGeometry, - createEndpointHitAreaGeometry, - HandleArrow, - InvisibleHandleHitArea, - NO_RAYCAST, - useInvisibleHitAreaMaterial, -} from './node-arrow-handles' - -const HANDLE_OFFSET = 0.27 -const HANDLE_MIN_OFFSET = 0.33 -const HANDLE_MIN_HEIGHT = 0.4 -const HANDLE_TOP_INSET = 0.08 -const HEIGHT_HANDLE_OFFSET = 0.26 -const ARROW_COLOR = '#8381ed' -const ARROW_HOVER_COLOR = '#a5b4fc' -// Match the door arrows: scale the rendered chevron down to ~two-thirds -// so the in-world handles read as a single UI family. -const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.11 -const CORNER_DASH_SIZE = 0.1 -const CORNER_GAP_SIZE = 0.07 -const CORNER_DASH_THICKNESS = 0.006 -const CORNER_FLOOR_OFFSET = 0.01 - -type WallMoveHandle = { - key: string - position: [number, number, number] - rotationY: number -} - -// Pre-empt the synthetic `click` the browser fires immediately after a -// drag's pointerup. Without this, PointerMissedHandler treats the click -// as "missed" and deselects the wall when the height arrow drag commits. -function swallowNextClick() { - const swallow = (clickEvent: Event) => { - clickEvent.stopPropagation() - clickEvent.preventDefault() - } - window.addEventListener('click', swallow, { capture: true, once: true }) - setTimeout(() => { - window.removeEventListener('click', swallow, { capture: true }) - }, 300) -} - -function createArrowHandleGeometry() { - // Classic arrow silhouette — chevron head + rectangular shaft — extruded - // slightly so the handle reads as a 3D plate but stays visually light. - const shape = new Shape() - shape.moveTo(0.22, 0) - shape.lineTo(-0.04, 0.12) - shape.lineTo(-0.04, 0.035) - shape.lineTo(-0.2, 0.035) - shape.lineTo(-0.2, -0.035) - shape.lineTo(-0.04, -0.035) - shape.lineTo(-0.04, -0.12) - shape.lineTo(0.22, 0) - - const geometry = new ExtrudeGeometry(shape, { - depth: 0.045, - bevelEnabled: true, - bevelThickness: 0.018, - bevelSize: 0.02, - bevelOffset: 0, - bevelSegments: 8, - curveSegments: 16, - steps: 1, - }) - - // Centre the extruded plate around y=0 and re-orient it so the depth - // axis points up: the chevron lies flat in the XZ plane, tip along +X, - // wings spread across ±Z. - geometry.translate(0, 0, -0.0225) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - geometry.computeBoundingSphere() - return geometry -} - -export function WallMoveSideHandles() { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const mode = useEditor((state) => state.mode) - const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) - const movingNode = useMovingNode() - const endpointReshape = useEndpointReshape() - const isCurveReshape = useIsCurveReshape() - - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - // Fence side-move / height / corner-pickers now flow through the - // registry handle path (see packages/nodes/src/fence/definition.ts). - // Only walls still need the legacy renderer here — the registry path - // didn't render correctly for walls specifically and was reverted in - // commit 0e207a7f; revisit once that's diagnosed. - const selectedNode = useScene((state) => { - const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null - return node?.type === 'wall' ? node : null - }) - - const shouldRender = - Boolean(selectedNode) && - !isFloorplanHovered && - mode !== 'delete' && - !movingNode && - !endpointReshape && - !isCurveReshape - - if (!shouldRender || !selectedNode) return null - - return -} - -function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { - const nodes = useScene((state) => state.nodes) - // Merge the in-flight drag override so every handle (side-move arrows, - // height arrow, corner leaders) tracks the live height in real time - // during a height drag — the scene store stays at the pre-drag value - // until commit, so reading `wall` alone would freeze them. Same pattern - // as node-arrow-handles. - const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) - const effectiveWall = useMemo( - () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), - [wall, liveOverride], - ) - - const [levelObject, setLevelObject] = useState(() => - wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, - ) - - useEffect(() => { - let frameId = 0 - - const resolveLevelObject = () => { - const nextLevelObject = wall.parentId - ? (sceneRegistry.nodes.get(wall.parentId) ?? null) - : null - setLevelObject((currentLevelObject) => { - if (currentLevelObject === nextLevelObject) { - return currentLevelObject - } - return nextLevelObject - }) - - if (!nextLevelObject) { - frameId = window.requestAnimationFrame(resolveLevelObject) - } - } - - resolveLevelObject() - - return () => { - if (frameId) { - window.cancelAnimationFrame(frameId) - } - } - }, [wall.parentId]) - - const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) - const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes]) - - if (!levelObject || handles.length === 0) return null - - return createPortal( - <> - - {handles.map((handle) => ( - - ))} - - - - - - , - levelObject, - ) -} - -function buildDashedVerticalGeometry(height: number) { - if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry() - - // Build each dash as a thin cylinder section so thickness is - // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU. - const dashes: BufferGeometry[] = [] - let y = 0 - while (y < height) { - const end = Math.min(y + CORNER_DASH_SIZE, height) - const length = end - y - const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8) - cylinder.translate(0, y + length / 2, 0) - dashes.push(cylinder) - y = end + CORNER_GAP_SIZE - } - const merged = - dashes.length > 0 - ? (mergeGeometries(dashes, false) ?? new BufferGeometry()) - : new BufferGeometry() - for (const dash of dashes) dash.dispose() - return merged -} - -function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) { - const [isHovered, setIsHovered] = useState(false) - const { camera } = useThree() - const billboardRef = useRef(null) - const parentWorldQuaternionRef = useRef(new Quaternion()) - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom - const visualScale = isHovered ? 1.25 : 1 - - const corner = endpoint === 'start' ? wall.start : wall.end - const x = corner[0] - const z = corner[1] - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - - const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) - const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) - const hitMaterial = useInvisibleHitAreaMaterial() - useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - - // Node materials matched to the rest of the file — mixing plain - // `meshBasicMaterial` with WebGPU node materials trips - // "Color target has no corresponding fragment stage output". - const dashMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - transparent: true, - opacity: 0.85, - depthTest: false, - depthWrite: false, - }), - [], - ) - const hexMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 0.95, - depthTest: false, - depthWrite: false, - }), - [], - ) - const ringMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 1, - depthTest: false, - depthWrite: false, - }), - [], - ) - - useEffect(() => { - const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR - dashMaterial.color.set(next) - hexMaterial.color.set(next) - ringMaterial.color.set(next) - }, [dashMaterial, hexMaterial, ringMaterial, isHovered]) - - useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - useEffect(() => () => hexMaterial.dispose(), [hexMaterial]) - useEffect(() => () => ringMaterial.dispose(), [ringMaterial]) - - // Billboard the hex disc to the camera so the picker is always - // recognisable regardless of viewing angle. - // - // Why parent-aware: the disc lives under a `createPortal` into the - // level object, which itself sits under a building. Both can have - // non-identity world rotations. `quaternion.copy(camera.quaternion)` - // alone sets the LOCAL quaternion, so any ancestor rotation rotates - // the disc away from the camera. We instead solve for a local - // quaternion whose composition with the parent world quaternion - // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`. - useFrame(() => { - const billboard = billboardRef.current - if (!billboard) return - billboard.quaternion.copy(camera.quaternion) - const parent = billboard.parent - if (parent) { - parent.getWorldQuaternion(parentWorldQuaternionRef.current) - billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert()) - } - }) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - const activateEndpointMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - sfxEmitter.emit('sfx:item-pick') - document.body.style.cursor = 'grabbing' - useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint)) - } - - return ( - <> - - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={1} - /> - - - - - - - - - - - ) -} - -function WallBaseElevationHandle({ - wall, - baseElevation, - levelObject, -}: { - wall: WallNode - baseElevation: number - levelObject: Object3D -}) { - const [isHovered, setIsHovered] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const { camera } = useThree() - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const midpoint: [number, number] = curveFrame - ? [curveFrame.point.x, curveFrame.point.y] - : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const elevationGuideSource = { - nodeId: wall.id, - levelId: wall.parentId, - anchor: midpoint, - } - const leaderBottom = Math.min(0, baseElevation) - const leaderHeight = Math.abs(baseElevation) - const dashedGeometry = useMemo( - () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null), - [leaderHeight], - ) - const dashMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - transparent: true, - opacity: 0.85, - depthTest: false, - depthWrite: false, - }), - [], - ) - - const isActive = isHovered || isDragging - useEffect(() => { - dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [dashMaterial, isActive]) - useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) - useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - const dragControls = useMemo( - () => ({ - onStart: () => {}, - onEnd: () => {}, - }), - [], - ) - - const activateElevationMove = useHandleDrag({ - kind: 'drag', - cursor: 'ns-resize', - dragControls, - handleIndex: 0, - node: wall, - rideObject: levelObject, - setIsDragging, - onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => { - if (initialNode.type !== 'wall') return null - - levelObject.updateWorldMatrix(true, false) - const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes()) - const supportBase = initialBase - (initialNode.supportOffset ?? 0) - const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes()) - const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( - levelObject.matrixWorld, - ) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) - if (planeNormal.lengthSq() === 0) return null - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) - const initialHit = new Vector3() - if ( - !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit) - ) { - return null - } - const initialPointerY = levelObject.worldToLocal(initialHit).y - - return { - onBegin: () => { - useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' }) - }, - onEnd: () => { - useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') - clearStructuralElevationGuide(initialNode.id) - }, - move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { - const hit = new Vector3() - if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null - const pointerY = levelObject.worldToLocal(hit).y - const nextBase = resolveResizeSnapValue({ - rawValue: initialBase + pointerY - initialPointerY, - gridSnapEnabled: true, - gridSnapActive: isGridSnapActive(), - gridSnapStep: useEditor.getState().gridSnapStep, - magneticSnapActive: isMagneticSnapActive(), - magneticSnap: (value) => - resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()), - }) - publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes()) - if (Math.abs(nextBase - initialBase) < 1e-6) { - return { - supportOffset: initialNode.supportOffset, - height: initialNode.height, - } - } - const nextOffset = nextBase - supportBase - return { - supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, - height: initialHeight, - } - }, - } - }, - }) - - return ( - <> - {dashedGeometry ? ( - - ) : null} - - - ) -} - -function WallHeightArrowHandle({ wall }: { wall: WallNode }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera, raycaster, gl } = useThree() - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - const dragCleanupRef = useRef<(() => void) | null>(null) - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - dragCleanupRef.current?.() - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - // Sit on the visual centre of the wall — for curved walls that's the - // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for - // the yaw so the arrow's local frame matches the wall direction at the - // apex, consistent with `getWallMoveHandles`. - const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2 - const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2 - const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] - const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] - const wallAngle = Math.atan2(-dirZ, dirX) - // `wall` is the override-merged effective wall (see - // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - const handleY = wallHeight + HEIGHT_HANDLE_OFFSET - - const activateHeightResize = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null - if (!levelObject) return - - // Vertical plane through the wall midpoint whose normal points toward - // the camera (projected to horizontal). Raycasting against it converts - // pointer movement into a world-space Y value. - const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) - if (planeNormal.lengthSq() === 0) return - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) - - const ndc = new Vector2() - const setNDC = (clientX: number, clientY: number) => { - const rect = gl.domElement.getBoundingClientRect() - ndc.set( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ) - } - - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return - - // Dragging the top makes the wall custom-height; seed from the resolved - // effective height so a plane-bound wall's drag starts at its real top. - const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - const initialY = hit.y - const wallId = wall.id as AnyNodeId - let pendingHeight = initialHeight - - document.body.style.cursor = 'ns-resize' - sfxEmitter.emit('sfx:item-pick') - useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' }) - // Suppress R3F node pointer events until pointerup completes so the - // synthesized click doesn't reroute selection to whatever mesh sits - // under the cursor at release. - useViewer.getState().setInputDragging(true) - useScene.temporal.getState().pause() - - // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks - // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against - // the override-merged value (via `getEffectiveWall`). Zustand stays - // at the pre-drag height until pointerup commits one tracked write. - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - const intersection = new Vector3() - if (!raycaster.ray.intersectPlane(plane, intersection)) return - const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) - pendingHeight = newHeight - useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) - useScene.getState().markDirty(wallId) - } - - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onCancel) - window.removeEventListener('keydown', onKeyDown, true) - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - useScene.temporal.getState().resume() - useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') - useViewer.getState().setInputDragging(false) - dragCleanupRef.current = null - } - const onUp = () => { - swallowNextClick() - sfxEmitter.emit('sfx:item-place') - // Commit: write the final override-merged value to zustand once - // (tracked, undoable), then drop the override so the renderer - // falls back to the scene store. - if (pendingHeight !== initialHeight) { - useScene.getState().updateNode(wallId, { height: pendingHeight }) - } - useLiveNodeOverrides.getState().clear(wallId) - useScene.getState().markDirty(wallId) - cleanup() - } - const onCancel = () => { - // Revert: drop the override, mark dirty so the geometry rebuilds - // against the original scene height. - useLiveNodeOverrides.getState().clear(wallId) - useScene.getState().markDirty(wallId) - cleanup() - } - - // Escape / ⌘Z abort the drag — capture phase so they win over the global - // use-keyboard arms (⌘Z must never history-jump under a live pointer). - const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape' && !isHistoryShortcut(e)) return - e.preventDefault() - e.stopPropagation() - swallowNextClick() - onCancel() - } - - dragCleanupRef.current = cleanup - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) - window.addEventListener('keydown', onKeyDown, true) - } - - return ( - - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'ns-resize' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - - - - ) -} - -function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera } = useThree() - - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const activateWallMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - document.body.style.cursor = 'grabbing' - - sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(wall) - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') - // Keep the wall selected so it stays the active item once the move - // commits; the `!movingNode` guard on the handles hides them mid-drag. - } - - return ( - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - `) - // so the mesh is never rendered with R3F's default empty - // `BufferGeometry`. Combined with `frustumCulled={false}`, the - // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first - // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". - frustumCulled={false} - geometry={arrowGeometry} - material={arrowMaterial} - raycast={NO_RAYCAST} - renderOrder={1002} - scale={scale} - /> - - ) -} - -function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera } = useThree() - - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const activateFenceMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - document.body.style.cursor = 'grabbing' - - sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(fence) - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') - // Keep the fence selected so it stays active once the move commits. - } - - return ( - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - - - ) -} - -function getWallMoveHandles(wall: WallNode, nodes: Record): WallMoveHandle[] { - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) - - if (length < 1e-6) { - return [] - } - - const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const normal: [number, number] = frame - ? [frame.normal.x, frame.normal.y] - : [-dz / length, dx / length] - const midpoint: [number, number] = frame - ? [frame.point.x, frame.point.y] - : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = getWallEffectiveHeightForNodes(wall, nodes) - const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) - const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) - - return [ - buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), - buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), - ] -} - -function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { - const [levelObject, setLevelObject] = useState(() => - fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, - ) - - useEffect(() => { - let frameId = 0 - - const resolveLevelObject = () => { - const nextLevelObject = fence.parentId - ? (sceneRegistry.nodes.get(fence.parentId) ?? null) - : null - setLevelObject((currentLevelObject) => { - if (currentLevelObject === nextLevelObject) { - return currentLevelObject - } - return nextLevelObject - }) - - if (!nextLevelObject) { - frameId = window.requestAnimationFrame(resolveLevelObject) - } - } - - resolveLevelObject() - - return () => { - if (frameId) { - window.cancelAnimationFrame(frameId) - } - } - }, [fence.parentId]) - - const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) - - if (!levelObject || handles.length === 0) return null - - return createPortal( - - {handles.map((handle) => ( - - ))} - , - levelObject, - ) -} - -function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { - const dx = fence.end[0] - fence.start[0] - const dz = fence.end[1] - fence.start[1] - const length = Math.hypot(dx, dz) - - if (length < 1e-6) { - return [] - } - - const midpoint: [number, number] = [ - (fence.start[0] + fence.end[0]) / 2, - (fence.start[1] + fence.end[1]) / 2, - ] - const normal: [number, number] = [-dz / length, dx / length] - const fenceHeight = fence.height ?? 1.8 - const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) - const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) - - return [ - buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), - buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), - ] -} - -function buildWallMoveHandle( - key: string, - midpoint: [number, number], - direction: [number, number], - offset: number, - height: number, -): WallMoveHandle { - return { - key, - position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], - rotationY: Math.atan2(-direction[1], direction[0]), - } -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + type FenceNode, + getWallBaseElevationForNodes, + getWallCurveFrameAt, + getWallEffectiveHeightForNodes, + getWallThickness, + isCurvedWall, + MIN_WALL_HEIGHT, + sceneRegistry, + useLiveNodeOverrides, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + BufferGeometry, + Color, + CylinderGeometry, + DoubleSide, + ExtrudeGeometry, + type Group, + type Object3D, + OrthographicCamera, + Plane, + Quaternion, + Shape, + Vector2, + Vector3, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '../../lib/elevation-guides' +import { isHistoryShortcut } from '../../lib/history' +import { endpointReshapeScope } from '../../lib/interaction/scope' +import { sfxEmitter } from '../../lib/sfx-bus' +import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' +import useInteractionScope, { + useEndpointReshape, + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' +import { resolveResizeSnapValue } from './handles/resize-snap' +import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' +import { + createArrowHitAreaGeometry, + createEndpointHitAreaGeometry, + HandleArrow, + InvisibleHandleHitArea, + NO_RAYCAST, + useInvisibleHitAreaMaterial, +} from './node-arrow-handles' + +const HANDLE_OFFSET = 0.27 +const HANDLE_MIN_OFFSET = 0.33 +const HANDLE_MIN_HEIGHT = 0.4 +const HANDLE_TOP_INSET = 0.08 +const HEIGHT_HANDLE_OFFSET = 0.26 +const ARROW_COLOR = '#8381ed' +const ARROW_HOVER_COLOR = '#a5b4fc' +// Match the door arrows: scale the rendered chevron down to ~two-thirds +// so the in-world handles read as a single UI family. +const ARROW_SCALE = 0.65 +const CORNER_HEX_RADIUS = 0.11 +const CORNER_DASH_SIZE = 0.1 +const CORNER_GAP_SIZE = 0.07 +const CORNER_DASH_THICKNESS = 0.006 +const CORNER_FLOOR_OFFSET = 0.01 + +type WallMoveHandle = { + key: string + position: [number, number, number] + rotationY: number +} + +// Pre-empt the synthetic `click` the browser fires immediately after a +// drag's pointerup. Without this, PointerMissedHandler treats the click +// as "missed" and deselects the wall when the height arrow drag commits. +function swallowNextClick() { + const swallow = (clickEvent: Event) => { + clickEvent.stopPropagation() + clickEvent.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => { + window.removeEventListener('click', swallow, { capture: true }) + }, 300) +} + +function createArrowHandleGeometry() { + // Classic arrow silhouette — chevron head + rectangular shaft — extruded + // slightly so the handle reads as a 3D plate but stays visually light. + const shape = new Shape() + shape.moveTo(0.22, 0) + shape.lineTo(-0.04, 0.12) + shape.lineTo(-0.04, 0.035) + shape.lineTo(-0.2, 0.035) + shape.lineTo(-0.2, -0.035) + shape.lineTo(-0.04, -0.035) + shape.lineTo(-0.04, -0.12) + shape.lineTo(0.22, 0) + + const geometry = new ExtrudeGeometry(shape, { + depth: 0.045, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.02, + bevelOffset: 0, + bevelSegments: 8, + curveSegments: 16, + steps: 1, + }) + + // Centre the extruded plate around y=0 and re-orient it so the depth + // axis points up: the chevron lies flat in the XZ plane, tip along +X, + // wings spread across ±Z. + geometry.translate(0, 0, -0.0225) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +export function WallMoveSideHandles() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const mode = useEditor((state) => state.mode) + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) + const movingNode = useMovingNode() + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() + + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + // Fence side-move / height / corner-pickers now flow through the + // registry handle path (see packages/nodes/src/fence/definition.ts). + // Only walls still need the legacy renderer here — the registry path + // didn't render correctly for walls specifically and was reverted in + // commit 0e207a7f; revisit once that's diagnosed. + const selectedNode = useScene((state) => { + const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null + return node?.type === 'wall' ? node : null + }) + + const shouldRender = + Boolean(selectedNode) && + !isFloorplanHovered && + mode !== 'delete' && + !movingNode && + !endpointReshape && + !isCurveReshape + + if (!shouldRender || !selectedNode) return null + + return +} + +function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + const nodes = useScene((state) => state.nodes) + // Merge the in-flight drag override so every handle (side-move arrows, + // height arrow, corner leaders) tracks the live height in real time + // during a height drag — the scene store stays at the pre-drag value + // until commit, so reading `wall` alone would freeze them. Same pattern + // as node-arrow-handles. + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) + const effectiveWall = useMemo( + () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), + [wall, liveOverride], + ) + + const [levelObject, setLevelObject] = useState(() => + wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = wall.parentId + ? (sceneRegistry.nodes.get(wall.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [wall.parentId]) + + const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) + const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + <> + + {handles.map((handle) => ( + + ))} + + + + + + , + levelObject, + ) +} + +function buildDashedVerticalGeometry(height: number) { + if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry() + + // Build each dash as a thin cylinder section so thickness is + // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU. + const dashes: BufferGeometry[] = [] + let y = 0 + while (y < height) { + const end = Math.min(y + CORNER_DASH_SIZE, height) + const length = end - y + const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8) + cylinder.translate(0, y + length / 2, 0) + dashes.push(cylinder) + y = end + CORNER_GAP_SIZE + } + const merged = + dashes.length > 0 + ? (mergeGeometries(dashes, false) ?? new BufferGeometry()) + : new BufferGeometry() + for (const dash of dashes) dash.dispose() + return merged +} + +function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const billboardRef = useRef(null) + const parentWorldQuaternionRef = useRef(new Quaternion()) + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + const visualScale = isHovered ? 1.25 : 1 + + const corner = endpoint === 'start' ? wall.start : wall.end + const x = corner[0] + const z = corner[1] + const wallHeight = getWallEffectiveHeightForNodes( + wall, + useScene.getState().nodes, + endpoint === 'start' ? 0 : 1, + ) + + const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) + const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) + const hitMaterial = useInvisibleHitAreaMaterial() + useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + + // Node materials matched to the rest of the file — mixing plain + // `meshBasicMaterial` with WebGPU node materials trips + // "Color target has no corresponding fragment stage output". + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + const hexMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 0.95, + depthTest: false, + depthWrite: false, + }), + [], + ) + const ringMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + + useEffect(() => { + const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + dashMaterial.color.set(next) + hexMaterial.color.set(next) + ringMaterial.color.set(next) + }, [dashMaterial, hexMaterial, ringMaterial, isHovered]) + + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + useEffect(() => () => hexMaterial.dispose(), [hexMaterial]) + useEffect(() => () => ringMaterial.dispose(), [ringMaterial]) + + // Billboard the hex disc to the camera so the picker is always + // recognisable regardless of viewing angle. + // + // Why parent-aware: the disc lives under a `createPortal` into the + // level object, which itself sits under a building. Both can have + // non-identity world rotations. `quaternion.copy(camera.quaternion)` + // alone sets the LOCAL quaternion, so any ancestor rotation rotates + // the disc away from the camera. We instead solve for a local + // quaternion whose composition with the parent world quaternion + // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`. + useFrame(() => { + const billboard = billboardRef.current + if (!billboard) return + billboard.quaternion.copy(camera.quaternion) + const parent = billboard.parent + if (parent) { + parent.getWorldQuaternion(parentWorldQuaternionRef.current) + billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert()) + } + }) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + const activateEndpointMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + sfxEmitter.emit('sfx:item-pick') + document.body.style.cursor = 'grabbing' + useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint)) + } + + return ( + <> + + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={1} + /> + + + + + + + + + + + ) +} + +function WallBaseElevationHandle({ + wall, + baseElevation, + levelObject, +}: { + wall: WallNode + baseElevation: number + levelObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midpoint: [number, number] = curveFrame + ? [curveFrame.point.x, curveFrame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const elevationGuideSource = { + nodeId: wall.id, + levelId: wall.parentId, + anchor: midpoint, + } + const leaderBottom = Math.min(0, baseElevation) + const leaderHeight = Math.abs(baseElevation) + const dashedGeometry = useMemo( + () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null), + [leaderHeight], + ) + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + + const isActive = isHovered || isDragging + useEffect(() => { + dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [dashMaterial, isActive]) + useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + const dragControls = useMemo( + () => ({ + onStart: () => {}, + onEnd: () => {}, + }), + [], + ) + + const activateElevationMove = useHandleDrag({ + kind: 'drag', + cursor: 'ns-resize', + dragControls, + handleIndex: 0, + node: wall, + rideObject: levelObject, + setIsDragging, + onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => { + if (initialNode.type !== 'wall') return null + + levelObject.updateWorldMatrix(true, false) + const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes()) + const supportBase = initialBase - (initialNode.supportOffset ?? 0) + const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes()) + const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( + levelObject.matrixWorld, + ) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + if (planeNormal.lengthSq() === 0) return null + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) + const initialHit = new Vector3() + if ( + !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit) + ) { + return null + } + const initialPointerY = levelObject.worldToLocal(initialHit).y + + return { + onBegin: () => { + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' }) + }, + onEnd: () => { + useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') + clearStructuralElevationGuide(initialNode.id) + }, + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const hit = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null + const pointerY = levelObject.worldToLocal(hit).y + const nextBase = resolveResizeSnapValue({ + rawValue: initialBase + pointerY - initialPointerY, + gridSnapEnabled: true, + gridSnapActive: isGridSnapActive(), + gridSnapStep: useEditor.getState().gridSnapStep, + magneticSnapActive: isMagneticSnapActive(), + magneticSnap: (value) => + resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()), + }) + publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes()) + if (Math.abs(nextBase - initialBase) < 1e-6) { + return { + supportOffset: initialNode.supportOffset, + height: initialNode.height, + } + } + const nextOffset = nextBase - supportBase + return { + supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, + height: initialHeight, + } + }, + } + }, + }) + + return ( + <> + {dashedGeometry ? ( + + ) : null} + + + ) +} + +function WallHeightArrowHandle({ wall }: { wall: WallNode }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera, raycaster, gl } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + dragCleanupRef.current?.() + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + // Sit on the visual centre of the wall — for curved walls that's the + // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for + // the yaw so the arrow's local frame matches the wall direction at the + // apex, consistent with `getWallMoveHandles`. + const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2 + const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2 + const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] + const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] + const wallAngle = Math.atan2(-dirZ, dirX) + // `wall` is the override-merged effective wall (see + // WallMoveSideHandlesForWall), so this height is already live during a drag. + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0.5) + const handleY = wallHeight + HEIGHT_HANDLE_OFFSET + + const activateHeightResize = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null + if (!levelObject) return + + // Vertical plane through the wall midpoint whose normal points toward + // the camera (projected to horizontal). Raycasting against it converts + // pointer movement into a world-space Y value. + const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + if (planeNormal.lengthSq() === 0) return + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) + + const ndc = new Vector2() + const setNDC = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) return + + // Dragging the top makes the wall custom-height; seed from the resolved + // effective height so a plane-bound wall's drag starts at its real top. + const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) + const initialY = hit.y + const wallId = wall.id as AnyNodeId + let pendingHeight = initialHeight + + document.body.style.cursor = 'ns-resize' + sfxEmitter.emit('sfx:item-pick') + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' }) + // Suppress R3F node pointer events until pointerup completes so the + // synthesized click doesn't reroute selection to whatever mesh sits + // under the cursor at release. + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + + // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks + // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against + // the override-merged value (via `getEffectiveWall`). Zustand stays + // at the pre-drag height until pointerup commits one tracked write. + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + const intersection = new Vector3() + if (!raycaster.ray.intersectPlane(plane, intersection)) return + const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) + pendingHeight = newHeight + useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) + useScene.getState().markDirty(wallId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + window.removeEventListener('keydown', onKeyDown, true) + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') + useViewer.getState().setInputDragging(false) + dragCleanupRef.current = null + } + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + // Commit: write the final override-merged value to zustand once + // (tracked, undoable), then drop the override so the renderer + // falls back to the scene store. + if (pendingHeight !== initialHeight) { + useScene.getState().updateNode(wallId, { height: pendingHeight }) + } + useLiveNodeOverrides.getState().clear(wallId) + useScene.getState().markDirty(wallId) + cleanup() + } + const onCancel = () => { + // Revert: drop the override, mark dirty so the geometry rebuilds + // against the original scene height. + useLiveNodeOverrides.getState().clear(wallId) + useScene.getState().markDirty(wallId) + cleanup() + } + + // Escape / ⌘Z abort the drag — capture phase so they win over the global + // use-keyboard arms (⌘Z must never history-jump under a live pointer). + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' && !isHistoryShortcut(e)) return + e.preventDefault() + e.stopPropagation() + swallowNextClick() + onCancel() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + window.addEventListener('keydown', onKeyDown, true) + } + + return ( + + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'ns-resize' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + + + + ) +} + +function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + const activateWallMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(wall) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') + // Keep the wall selected so it stays the active item once the move + // commits; the `!movingNode` guard on the handles hides them mid-drag. + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + `) + // so the mesh is never rendered with R3F's default empty + // `BufferGeometry`. Combined with `frustumCulled={false}`, the + // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first + // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". + frustumCulled={false} + geometry={arrowGeometry} + material={arrowMaterial} + raycast={NO_RAYCAST} + renderOrder={1002} + scale={scale} + /> + + ) +} + +function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + const activateFenceMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(fence) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') + // Keep the fence selected so it stays active once the move commits. + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + + + ) +} + +function getWallMoveHandles(wall: WallNode, nodes: Record): WallMoveHandle[] { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const normal: [number, number] = frame + ? [frame.normal.x, frame.normal.y] + : [-dz / length, dx / length] + const midpoint: [number, number] = frame + ? [frame.point.x, frame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5) + const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + +function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { + const [levelObject, setLevelObject] = useState(() => + fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = fence.parentId + ? (sceneRegistry.nodes.get(fence.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [fence.parentId]) + + const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + + {handles.map((handle) => ( + + ))} + , + levelObject, + ) +} + +function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { + const dx = fence.end[0] - fence.start[0] + const dz = fence.end[1] - fence.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const midpoint: [number, number] = [ + (fence.start[0] + fence.end[0]) / 2, + (fence.start[1] + fence.end[1]) / 2, + ] + const normal: [number, number] = [-dz / length, dx / length] + const fenceHeight = fence.height ?? 1.8 + const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + +function buildWallMoveHandle( + key: string, + midpoint: [number, number], + direction: [number, number], + offset: number, + height: number, +): WallMoveHandle { + return { + key, + position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], + rotationY: Math.atan2(-direction[1], direction[0]), + } +} diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 7ef0ee9b1d..7bdd827077 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -1,306 +1,315 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - getWallCurveFrameAt, - getWallCurveLength, - getWallPlaneTop, - getWallThickness, - isCurvedWall, - resolveLevelId, - resolveWallTop, - sceneRegistry, - spatialGridManager, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useFrame } from '@react-three/fiber' -import { memo, useMemo, useRef } from 'react' -import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' -import { MeshBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../lib/constants' - -/** - * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that - * stands at the draft / move endpoint while it's locked onto existing wall - * geometry. It's the spatial cue from the design reference: a standing pillar - * so you can see *where* the snap caught even at an angle, plus a floor marker - * whose shape tells you *what* it caught (CAD-style osnap glyphs): - * - * endpoint (corner) → square midpoint → triangle - * intersection → ✕ cross wall body (edge) → circle - * - * Subscribes to the shared `useWallSnapIndicator` store (published by the wall - * draft + endpoint-move tools). The vertical mouse pillar and the corner - * (endpoint) square are green; the other floor glyphs are indigo. - * - * The point carries only XZ (building-local plan coords); like the alignment - * guides it's lifted to the active level's building-local Y each frame so it - * stands on the floor being edited when floors are stacked. Mounted inside - * ToolManager's building-local group. - */ - -const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent -const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph -const BEACON_HEIGHT = 2.5 // world-meter height of the pillar -const BEACON_RADIUS = 0.018 // world-meter radius of the pillar -const MARKER = 0.13 // world-meter base size of the floor glyph -const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid -const WALL_TOP_HIGHLIGHT_LIFT = 0.035 -const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 -const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 -const WALL_TOP_GLOW_HEIGHT = 0.026 -const WALL_TOP_GLOW_OVERHANG = 0.36 -const WALL_TOP_END_OVERHANG = 0.08 -const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 -const NO_RAYCAST = () => null - -// Shared resources — one material + unit geometries, so snap churn during a -// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). -const beaconMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.9, -}) -const greenMarkerMaterial = new MeshBasicNodeMaterial({ - color: MARKER_GREEN, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.9, -}) -const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.88, -}) -const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.26, -}) -const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) -// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as -// a flat plate; circles/triangles lie flat via an X rotation at the mesh. -const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) -const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) -const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) -const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) - -export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { - const point = useWallSnapIndicator((s) => s.point) - const levelId = useViewer((s) => s.selection.levelId) - const nodes = useScene((s) => s.nodes) - const groupRef = useRef(null) - const highlightedWalls = useMemo(() => { - if (!point?.wallIds?.length) return [] - return point.wallIds - .map((wallId) => nodes[wallId as AnyNodeId]) - .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) - }, [nodes, point?.wallIds]) - - // Track the active level's building-local Y each frame so the beacon stands - // on the floor being edited, not the building base — same source the - // alignment guide layer and `grid.tsx` read. - useFrame(() => { - const group = groupRef.current - if (!group) return - const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null - group.position.y = levelMesh ? levelMesh.position.y : 0 - }) - - if (!point) return null - return ( - - {highlightedWalls.map((wall) => ( - - ))} - - - - ) -}) - -type WallTopHighlightSegment = { - angle: number - center: [number, number] - length: number -} - -function getWallTopY(wall: WallNode, nodes: Readonly>) { - const levelId = resolveLevelId(wall, nodes as Record) - const support = spatialGridManager.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId, - ) - const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT -} - -function buildHighlightSegment(start: [number, number], end: [number, number]) { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length < 1e-6) return null - - return { - angle: -Math.atan2(dz, dx), - center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], - length, - } -} - -function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { - if (!isCurvedWall(wall)) { - const segment = buildHighlightSegment(wall.start, wall.end) - return segment ? [segment] : [] - } - - const sampleCount = Math.max( - 8, - Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), - ) - const segments: WallTopHighlightSegment[] = [] - let previous = getWallCurveFrameAt(wall, 0).point - for (let index = 1; index <= sampleCount; index += 1) { - const current = getWallCurveFrameAt(wall, index / sampleCount).point - const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y]) - if (segment) segments.push(segment) - previous = current - } - return segments -} - -function WallTopHighlight({ - nodes, - wall, -}: { - nodes: Readonly> - wall: WallNode -}) { - const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) - const y = getWallTopY(wall, nodes) - const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) - const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) - - return ( - <> - {segments.map((segment, index) => ( - - - - - ))} - - ) -} - -/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ -function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { - const y = FLOOR_LIFT - if (kind === 'endpoint') { - return ( - - ) - } - if (kind === 'midpoint') { - return ( - - ) - } - if (kind === 'intersection') { - // Two crossed bars → an ✕, the universal "crossing" glyph. - return ( - <> - - - - ) - } - // 'wall' (edge / along-wall) → circle - return ( - - ) -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + getWallCurveFrameAt, + getWallCurveLength, + getWallPlaneTop, + getWallThickness, + isCurvedWall, + resolveLevelId, + resolveWallTop, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { memo, useMemo, useRef } from 'react' +import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +/** + * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that + * stands at the draft / move endpoint while it's locked onto existing wall + * geometry. It's the spatial cue from the design reference: a standing pillar + * so you can see *where* the snap caught even at an angle, plus a floor marker + * whose shape tells you *what* it caught (CAD-style osnap glyphs): + * + * endpoint (corner) → square midpoint → triangle + * intersection → ✕ cross wall body (edge) → circle + * + * Subscribes to the shared `useWallSnapIndicator` store (published by the wall + * draft + endpoint-move tools). The vertical mouse pillar and the corner + * (endpoint) square are green; the other floor glyphs are indigo. + * + * The point carries only XZ (building-local plan coords); like the alignment + * guides it's lifted to the active level's building-local Y each frame so it + * stands on the floor being edited when floors are stacked. Mounted inside + * ToolManager's building-local group. + */ + +const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent +const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph +const BEACON_HEIGHT = 2.5 // world-meter height of the pillar +const BEACON_RADIUS = 0.018 // world-meter radius of the pillar +const MARKER = 0.13 // world-meter base size of the floor glyph +const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid +const WALL_TOP_HIGHLIGHT_LIFT = 0.035 +const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 +const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 +const WALL_TOP_GLOW_HEIGHT = 0.026 +const WALL_TOP_GLOW_OVERHANG = 0.36 +const WALL_TOP_END_OVERHANG = 0.08 +const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 +const NO_RAYCAST = () => null + +// Shared resources — one material + unit geometries, so snap churn during a +// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). +const beaconMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) +const greenMarkerMaterial = new MeshBasicNodeMaterial({ + color: MARKER_GREEN, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) +const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.88, +}) +const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.26, +}) +const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) +// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as +// a flat plate; circles/triangles lie flat via an X rotation at the mesh. +const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) +const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) +const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) +const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) + +export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { + const point = useWallSnapIndicator((s) => s.point) + const levelId = useViewer((s) => s.selection.levelId) + const nodes = useScene((s) => s.nodes) + const groupRef = useRef(null) + const highlightedWalls = useMemo(() => { + if (!point?.wallIds?.length) return [] + return point.wallIds + .map((wallId) => nodes[wallId as AnyNodeId]) + .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) + }, [nodes, point?.wallIds]) + + // Track the active level's building-local Y each frame so the beacon stands + // on the floor being edited, not the building base — same source the + // alignment guide layer and `grid.tsx` read. + useFrame(() => { + const group = groupRef.current + if (!group) return + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null + group.position.y = levelMesh ? levelMesh.position.y : 0 + }) + + if (!point) return null + return ( + + {highlightedWalls.map((wall) => ( + + ))} + + + + ) +}) + +type WallTopHighlightSegment = { + angle: number + center: [number, number] + length: number + tCenter: number +} + +function getWallTopY(wall: WallNode, nodes: Readonly>, t = 0.5): number { + const levelId = resolveLevelId(wall, nodes as Record) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallTop(wall, planeTop, support.elevation, t) + WALL_TOP_HIGHLIGHT_LIFT +} + +function buildHighlightSegment( + start: [number, number], + end: [number, number], + tCenter = 0.5, +): WallTopHighlightSegment | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + + return { + angle: -Math.atan2(dz, dx), + center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], + length, + tCenter, + } +} + +function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { + if (!isCurvedWall(wall)) { + const segment = buildHighlightSegment(wall.start, wall.end, 0.5) + return segment ? [segment] : [] + } + + const sampleCount = Math.max( + 8, + Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), + ) + const segments: WallTopHighlightSegment[] = [] + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const tCenter = (index - 0.5) / sampleCount + const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y], tCenter) + if (segment) segments.push(segment) + previous = current + } + return segments +} + +function WallTopHighlight({ + nodes, + wall, +}: { + nodes: Readonly> + wall: WallNode +}) { + const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) + const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) + const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) + + return ( + <> + {segments.map((segment, index) => { + const y = getWallTopY(wall, nodes, segment.tCenter) + return ( + + + + + ) + })} + + ) +} + +/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ +function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { + const y = FLOOR_LIFT + if (kind === 'endpoint') { + return ( + + ) + } + if (kind === 'midpoint') { + return ( + + ) + } + if (kind === 'intersection') { + // Two crossed bars → an ✕, the universal "crossing" glyph. + return ( + <> + + + + ) + } + // 'wall' (edge / along-wall) → circle + return ( + + ) +} diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts index b828be9ba7..c204efeb73 100644 --- a/packages/editor/src/lib/elevation-guides.ts +++ b/packages/editor/src/lib/elevation-guides.ts @@ -1,265 +1,280 @@ -import { - type AnyNode, - type AnyNodeId, - findLevelAncestorId, - getWallBaseElevationForNodes, - getWallEffectiveHeightForNodes, - levelBaseElevationAt, - resolveCeilingHeight, -} from '@pascal-app/core' -import useElevationGuides from '../store/use-elevation-guides' - -export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08 -const GUIDE_MATCH_EPSILON_M = 1e-4 - -export type ElevationGuideSource = { - nodeId: string - levelId: string | null | undefined - anchor: readonly [number, number] -} - -export type ElevationSnapTarget = { - id: string - elevation: number - anchor: readonly [number, number] - label: string -} - -export type ElevationSnapMatch = { - target: ElevationSnapTarget - elevation: number -} - -function polygonCenter(polygon: ReadonlyArray): [number, number] { - if (polygon.length === 0) return [0, 0] - let x = 0 - let z = 0 - for (const point of polygon) { - x += point[0] - z += point[1] - } - return [x / polygon.length, z / polygon.length] -} - -function segmentCenter( - start: readonly [number, number], - end: readonly [number, number], -): [number, number] { - return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] -} - -// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this -// package cannot import (the fence definition imports the guides from here). -// A stale host resolves to the level base — the sculpted ground under the -// fence's start point, sampled where the builder samples it, so the guide line -// lands on the rail it claims to describe. -function fenceBaseElevation(node: AnyNode, nodes: Record): number { - if (node.type !== 'fence') return 0 - const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined - const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) - const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) - const support = hosted - ? (host.elevation ?? 0) - : levelId - ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) - : 0 - return support + (node.supportOffset ?? 0) -} - -/** - * Structural Y datums on the source node's level. This is editor-runtime - * collection over authoritative vertical resolvers; matching remains a pure - * scalar operation in {@link resolveElevationSnapMatch}. - */ -export function collectElevationSnapTargets( - source: ElevationGuideSource, - nodes: Record, -): ElevationSnapTarget[] { - if (!source.levelId) return [] - - const targets: ElevationSnapTarget[] = [ - { - id: `${source.levelId}:level`, - elevation: 0, - anchor: source.anchor, - label: 'Level', - }, - ] - // Sculpted ground under the thing being dragged. A separate target rather than - // a redefinition of `Level`: the storey plane is still a real datum a user may - // want (a fence sunk to the building's floor line), and on a hillside the - // ground is a second, different one. Emitted only when they actually differ, - // so a flat scene keeps exactly one target at 0. - const groundElevation = levelBaseElevationAt( - nodes, - source.levelId, - source.anchor[0], - source.anchor[1], - ) - if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) { - targets.push({ - id: `${source.levelId}:ground`, - elevation: groundElevation, - anchor: source.anchor, - label: 'Ground', - }) - } - const level = nodes[source.levelId as AnyNodeId] - if (level?.type !== 'level') return targets - - for (const childId of level.children) { - if (childId === source.nodeId) continue - const node = nodes[childId as AnyNodeId] - if (!node) continue - - if (node.type === 'slab') { - const center = polygonCenter(node.polygon) - const top = node.elevation ?? 0.05 - targets.push({ - id: `${node.id}:top`, - elevation: top, - anchor: center, - label: 'Slab top', - }) - if (!node.recessed) { - targets.push({ - id: `${node.id}:base`, - elevation: top - (node.thickness ?? 0.05), - anchor: center, - label: 'Slab underside', - }) - } - continue - } - - if (node.type === 'ceiling') { - targets.push({ - id: `${node.id}:ceiling`, - elevation: resolveCeilingHeight(node, nodes as Record), - anchor: polygonCenter(node.polygon), - label: 'Ceiling', - }) - continue - } - - if (node.type === 'wall') { - const base = getWallBaseElevationForNodes(node, nodes) - const center = segmentCenter(node.start, node.end) - targets.push({ - id: `${node.id}:base`, - elevation: base, - anchor: center, - label: 'Wall base', - }) - targets.push({ - id: `${node.id}:top`, - elevation: base + getWallEffectiveHeightForNodes(node, nodes), - anchor: center, - label: 'Wall top', - }) - continue - } - - if (node.type === 'fence') { - const base = fenceBaseElevation(node, nodes) - const center = segmentCenter(node.start, node.end) - targets.push({ - id: `${node.id}:base`, - elevation: base, - anchor: center, - label: 'Fence base', - }) - targets.push({ - id: `${node.id}:top`, - elevation: base + (node.height ?? 1.8), - anchor: center, - label: 'Fence top', - }) - } - } - - return targets -} - -export function resolveElevationSnapMatch( - proposedElevation: number, - sourceAnchor: readonly [number, number], - targets: readonly ElevationSnapTarget[], - threshold = ELEVATION_ALIGNMENT_THRESHOLD_M, -): ElevationSnapMatch | null { - let best: ElevationSnapTarget | null = null - let bestDelta = Number.POSITIVE_INFINITY - let bestPlanDistance = Number.POSITIVE_INFINITY - - for (const target of targets) { - const delta = Math.abs(target.elevation - proposedElevation) - if (delta > threshold) continue - const dx = target.anchor[0] - sourceAnchor[0] - const dz = target.anchor[1] - sourceAnchor[1] - const planDistance = dx * dx + dz * dz - if ( - delta < bestDelta - 1e-9 || - (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance) - ) { - best = target - bestDelta = delta - bestPlanDistance = planDistance - } - } - - return best ? { target: best, elevation: best.elevation } : null -} - -export function resolveStructuralElevationSnap( - source: ElevationGuideSource, - proposedElevation: number, - nodes: Record, -): number { - return ( - resolveElevationSnapMatch( - proposedElevation, - source.anchor, - collectElevationSnapTargets(source, nodes), - )?.elevation ?? proposedElevation - ) -} - -export function publishStructuralElevationGuide( - source: ElevationGuideSource, - elevation: number, - nodes: Record, -): void { - if (!source.levelId) { - clearStructuralElevationGuide(source.nodeId) - return - } - - const match = resolveElevationSnapMatch( - elevation, - source.anchor, - collectElevationSnapTargets(source, nodes), - GUIDE_MATCH_EPSILON_M, - ) - if (!match) { - clearStructuralElevationGuide(source.nodeId) - return - } - - const dx = match.target.anchor[0] - source.anchor[0] - const dz = match.target.anchor[1] - source.anchor[1] - const length = Math.hypot(dx, dz) - const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] - - useElevationGuides.getState().publish({ - ownerId: source.nodeId, - levelId: source.levelId, - center: source.anchor, - direction, - elevation: match.elevation, - label: match.target.label, - }) -} - -export function clearStructuralElevationGuide(ownerId: string): void { - useElevationGuides.getState().clear(ownerId) -} +import { + type AnyNode, + type AnyNodeId, + findLevelAncestorId, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + levelBaseElevationAt, + resolveCeilingHeight, +} from '@pascal-app/core' +import useElevationGuides from '../store/use-elevation-guides' + +export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08 +const GUIDE_MATCH_EPSILON_M = 1e-4 + +export type ElevationGuideSource = { + nodeId: string + levelId: string | null | undefined + anchor: readonly [number, number] +} + +export type ElevationSnapTarget = { + id: string + elevation: number + anchor: readonly [number, number] + label: string +} + +export type ElevationSnapMatch = { + target: ElevationSnapTarget + elevation: number +} + +function polygonCenter(polygon: ReadonlyArray): [number, number] { + if (polygon.length === 0) return [0, 0] + let x = 0 + let z = 0 + for (const point of polygon) { + x += point[0] + z += point[1] + } + return [x / polygon.length, z / polygon.length] +} + +function segmentCenter( + start: readonly [number, number], + end: readonly [number, number], +): [number, number] { + return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] +} + +// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this +// package cannot import (the fence definition imports the guides from here). +// A stale host resolves to the level base — the sculpted ground under the +// fence's start point, sampled where the builder samples it, so the guide line +// lands on the rail it claims to describe. +function fenceBaseElevation(node: AnyNode, nodes: Record): number { + if (node.type !== 'fence') return 0 + const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined + const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) + const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) + const support = hosted + ? (host.elevation ?? 0) + : levelId + ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) + : 0 + return support + (node.supportOffset ?? 0) +} + +/** + * Structural Y datums on the source node's level. This is editor-runtime + * collection over authoritative vertical resolvers; matching remains a pure + * scalar operation in {@link resolveElevationSnapMatch}. + */ +export function collectElevationSnapTargets( + source: ElevationGuideSource, + nodes: Record, +): ElevationSnapTarget[] { + if (!source.levelId) return [] + + const targets: ElevationSnapTarget[] = [ + { + id: `${source.levelId}:level`, + elevation: 0, + anchor: source.anchor, + label: 'Level', + }, + ] + // Sculpted ground under the thing being dragged. A separate target rather than + // a redefinition of `Level`: the storey plane is still a real datum a user may + // want (a fence sunk to the building's floor line), and on a hillside the + // ground is a second, different one. Emitted only when they actually differ, + // so a flat scene keeps exactly one target at 0. + const groundElevation = levelBaseElevationAt( + nodes, + source.levelId, + source.anchor[0], + source.anchor[1], + ) + if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) { + targets.push({ + id: `${source.levelId}:ground`, + elevation: groundElevation, + anchor: source.anchor, + label: 'Ground', + }) + } + const level = nodes[source.levelId as AnyNodeId] + if (level?.type !== 'level') return targets + + for (const childId of level.children) { + if (childId === source.nodeId) continue + const node = nodes[childId as AnyNodeId] + if (!node) continue + + if (node.type === 'slab') { + const center = polygonCenter(node.polygon) + const top = node.elevation ?? 0.05 + targets.push({ + id: `${node.id}:top`, + elevation: top, + anchor: center, + label: 'Slab top', + }) + if (!node.recessed) { + targets.push({ + id: `${node.id}:base`, + elevation: top - (node.thickness ?? 0.05), + anchor: center, + label: 'Slab underside', + }) + } + continue + } + + if (node.type === 'ceiling') { + targets.push({ + id: `${node.id}:ceiling`, + elevation: resolveCeilingHeight(node, nodes as Record), + anchor: polygonCenter(node.polygon), + label: 'Ceiling', + }) + continue + } + + if (node.type === 'wall') { + const base = getWallBaseElevationForNodes(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Wall base', + }) + if (node.endHeightOffset) { + targets.push({ + id: `${node.id}:top-start`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0), + anchor: node.start, + label: 'Wall top start', + }) + targets.push({ + id: `${node.id}:top-end`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 1), + anchor: node.end, + label: 'Wall top end', + }) + } else { + targets.push({ + id: `${node.id}:top`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0.5), + anchor: center, + label: 'Wall top', + }) + } + continue + } + + if (node.type === 'fence') { + const base = fenceBaseElevation(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Fence base', + }) + targets.push({ + id: `${node.id}:top`, + elevation: base + (node.height ?? 1.8), + anchor: center, + label: 'Fence top', + }) + } + } + + return targets +} + +export function resolveElevationSnapMatch( + proposedElevation: number, + sourceAnchor: readonly [number, number], + targets: readonly ElevationSnapTarget[], + threshold = ELEVATION_ALIGNMENT_THRESHOLD_M, +): ElevationSnapMatch | null { + let best: ElevationSnapTarget | null = null + let bestDelta = Number.POSITIVE_INFINITY + let bestPlanDistance = Number.POSITIVE_INFINITY + + for (const target of targets) { + const delta = Math.abs(target.elevation - proposedElevation) + if (delta > threshold) continue + const dx = target.anchor[0] - sourceAnchor[0] + const dz = target.anchor[1] - sourceAnchor[1] + const planDistance = dx * dx + dz * dz + if ( + delta < bestDelta - 1e-9 || + (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance) + ) { + best = target + bestDelta = delta + bestPlanDistance = planDistance + } + } + + return best ? { target: best, elevation: best.elevation } : null +} + +export function resolveStructuralElevationSnap( + source: ElevationGuideSource, + proposedElevation: number, + nodes: Record, +): number { + return ( + resolveElevationSnapMatch( + proposedElevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + )?.elevation ?? proposedElevation + ) +} + +export function publishStructuralElevationGuide( + source: ElevationGuideSource, + elevation: number, + nodes: Record, +): void { + if (!source.levelId) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const match = resolveElevationSnapMatch( + elevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + GUIDE_MATCH_EPSILON_M, + ) + if (!match) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const dx = match.target.anchor[0] - source.anchor[0] + const dz = match.target.anchor[1] - source.anchor[1] + const length = Math.hypot(dx, dz) + const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] + + useElevationGuides.getState().publish({ + ownerId: source.nodeId, + levelId: source.levelId, + center: source.anchor, + direction, + elevation: match.elevation, + label: match.target.label, + }) +} + +export function clearStructuralElevationGuide(ownerId: string): void { + useElevationGuides.getState().clear(ownerId) +} From cf8f12c73634b90fe10b6947953da22d6aa4bf50 Mon Sep 17 00:00:00 2001 From: "Ducasse, Vincent (external)" Date: Fri, 14 Aug 2026 12:31:33 +0200 Subject: [PATCH 12/12] fix(core): invalidate spatial-grid and space-detection on wall slope changes - Subscribe spatial-grid sync to wall height, endHeightOffset, and support updates. - Read live scene node in getWall to ensure spatial grid queries always reflect current wall slope. - Include endHeightOffset in space-detection wallGeometrySignature so room ceilings reactively re-derive on slope edits. --- .../spatial-grid/spatial-grid-manager.ts | 4 +- .../hooks/spatial-grid/spatial-grid-sync.ts | 962 +++++++++--------- packages/core/src/lib/space-detection.ts | 1 + 3 files changed, 485 insertions(+), 482 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index dbd153ff4f..03872e9774 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -353,14 +353,12 @@ export class SpatialGridManager { } private getWall(wallId: string): WallNode | undefined { - const cached = this.walls.get(wallId) - if (cached) return cached const fromScene = useScene.getState().nodes[wallId as AnyNodeId] if (fromScene && fromScene.type === 'wall') { this.walls.set(wallId, fromScene as WallNode) return fromScene as WallNode } - return undefined + return this.walls.get(wallId) } private getWallLength(wallId: string): number { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d05d0d68e4..9deba30179 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,479 +1,483 @@ -import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' -import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema' -import { getLevelBelow } from '../../services/storey' -import useLiveTerrain from '../../store/use-live-terrain' -import useScene from '../../store/use-scene' -import { getFloorPlacedFootprints } from './floor-placed-elevation' -import { - itemOverlapsPolygon, - spatialGridManager, - wallOverlapsPolygon, -} from './spatial-grid-manager' -import { GROUND_SUPPORT_ID } from './support-host-id' - -export function resolveLevelId(node: AnyNode, nodes: Record): string { - // If the node itself is a level - if (node.type === 'level') return node.id - - // Walk up parent chain to find level - // This assumes you track parentId or can derive it - let current: AnyNode | undefined = node - - while (current) { - if (current.type === 'level') return current.id - // Find parent (you might need to add parentId to your schema or derive it) - if (current.parentId) { - current = nodes[current.parentId] - } else { - current = undefined - } - } - - return 'default' // fallback for orphaned items -} - -/** - * Walks the parent chain of `nodeId` and returns the id of the first ancestor - * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned - * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant: - * - * - accepts a node **id** rather than a resolved node, saving the caller a - * `nodes[id]` lookup when only the id is at hand. - * - returns `null` instead of the `'default'` fallback, which lets callers - * distinguish "genuinely has no level" from "is a level". - * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot - * hang the frame loop. - */ -export function findLevelAncestorId( - nodeId: AnyNodeId, - nodes: Record, -): string | null { - let current: AnyNode | undefined = nodes[nodeId] - let guard = 0 - while (current && guard < 16) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId] : undefined - guard += 1 - } - return null -} - -/** - * Returns the building id that contains the given level, or `null` if - * the level is unparented or no enclosing building exists. - * - * Most scenes record the relationship via `level.parentId → - * building.id`, but older serialisations occasionally drop `parentId` - * even though the building's `children` array still references the - * level. The fallback scan covers that case. - * - * Used by `FloorplanRegistryLayer` to discover building-scoped kinds - * (`def.floorplanScope === 'building'`) without hardcoding any kind - * name in the editor layer. - */ -export function resolveBuildingForLevel( - levelId: AnyNodeId, - nodes: Record, -): AnyNodeId | null { - const level = nodes[levelId] as AnyNode | undefined - if (!level) return null - const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null - if (directParent) { - const candidate = nodes[directParent] - if (candidate?.type === 'building') return candidate.id as AnyNodeId - } - for (const candidate of Object.values(nodes)) { - if (candidate?.type !== 'building') continue - const children = (candidate as { children?: AnyNodeId[] }).children - if (Array.isArray(children) && children.includes(levelId)) { - return candidate.id as AnyNodeId - } - } - return null -} - -// Call this once at app initialization. Returns an unsubscribe function that -// detaches the scene-store listener (useful when the editor is unmounted so -// the spatial grid singleton does not hold stale references to old scenes). -export function initSpatialGridSync(): () => void { - const store = useScene - // 1. Initial sync - process all existing nodes - const state = store.getState() - for (const node of Object.values(state.nodes)) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeCreated(node, levelId) - } - - // 2. Then subscribe to future changes - const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) - - // Subscribe to all changes - const unsubscribeScene = store.subscribe((state, prevState) => { - // Detect added nodes - for (const [id, node] of Object.entries(state.nodes)) { - if (!prevState.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeCreated(node, levelId) - - // When a slab is added, mark overlapping items/walls dirty - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - markCoveringDependentsBelow(levelId, state.nodes, markDirty) - } - - // A site arriving with terrain already on it (scene load, paste, - // imported elevation data) is the same event as a stroke: ground exists - // where flat ground was assumed. - if (node.type === 'site' && (node as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } - } - - // Detect removed nodes - for (const [id, node] of Object.entries(prevState.nodes)) { - if (!state.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, prevState.nodes) - spatialGridManager.handleNodeDeleted(id, node.type, levelId) - - // When a slab is removed, mark items/walls that were on it dirty (using current state) - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - markCoveringDependentsBelow(levelId, state.nodes, markDirty) - } - - // Deleting a sculpted site drops the ground back to the datum, so its - // contents have to come down with it. - if (node.type === 'site' && (node as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } - } - - // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) - for (const [id, node] of Object.entries(state.nodes)) { - const prev = prevState.nodes[id as AnyNode['id']] - if (!prev) continue - - if (node.type === 'item' && prev.type === 'item') { - if ( - !( - arraysEqual(node.position, prev.position) && - arraysEqual(node.rotation, prev.rotation) && - arraysEqual(node.scale, prev.scale) - ) || - node.parentId !== prev.parentId || - node.side !== prev.side - ) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - // Scale changes affect footprint size — mark dirty so slab elevation recalculates - if (!arraysEqual(node.scale, prev.scale)) { - markDirty(node.id) - } - } - } else if (node.type === 'slab' && prev.type === 'slab') { - const supportChanged = - node.polygon !== prev.polygon || - node.elevation !== prev.elevation || - node.holes !== prev.holes - if (supportChanged) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - } - markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) - } else if (node.type === 'level' && prev.type === 'level') { - if (node.height !== prev.height) { - markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) - } - } else if (node.type === 'site' && prev.type === 'site') { - // Object identity, not deep equality: the store is - // immutable-by-convention, so a sculpt commit necessarily produces a new - // `terrain` object and an unrelated site edit (polygon, name) keeps the - // old one. The same reasoning `terrain-source`'s field cache is keyed on. - if ((node as SiteNode).terrain !== (prev as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } else if (node.type === 'wall' && prev.type === 'wall') { - if ( - node.start !== prev.start || - node.end !== prev.end || - node.curveOffset !== prev.curveOffset || - node.thickness !== prev.thickness - ) { - // Rendered slab polygons adopt wall bands, so a wall reshape - // must reach the manager to refresh its wall map and drop the - // level's rendered-polygon cache. - spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) - } - } - } - }) - - // Live terrain is deliberately not written into `useScene` per dab: doing so - // would encode the whole field, flood history, and wake every scene subscriber. - // Reuse the committed-terrain dependency sweep against the transient field - // instead. Dirty marks coalesce in their Set until the next frame, while an - // `end` notification also restores every dependent after an abandoned stroke. - const unsubscribeLiveTerrain = useLiveTerrain.subscribe(() => { - markTerrainSupportDependents(store.getState().nodes, markDirty) - }) - - return () => { - unsubscribeScene() - unsubscribeLiveTerrain() - } -} - -function arraysEqual(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]) -} - -/** - * A level's stored height moved: plane-bound walls follow the new plane, - * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark - * them all so their systems rebuild. Restacking the level containers alone - * leaves their geometry stale. - */ -export function markLevelHeightDependents( - level: LevelNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - for (const childId of level.children) { - const child = nodes[childId] - if (!child) continue - if ( - child.type === 'wall' || - child.type === 'stair' || - child.type === 'ceiling' || - child.type === 'fence' - ) { - markDirty(child.id) - } - } -} - -/** - * A deck slab's walking surface moved: stairs attached to it via - * `deckSlabId` derive their rise from that elevation, so their geometry - * (and rise-derived affordances) must rebuild. - */ -export function markDeckAttachedStairs( - slabId: string, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - for (const node of Object.values(nodes)) { - if (node.type === 'stair' && node.deckSlabId === slabId) { - markDirty(node.id) - } - } -} - -/** - * Dirty every consumer of a slab's top or underside. Kept pure so committed - * scene writes and live handle previews use the same dependency boundary. - */ -export function markSlabChangeDependents( - previous: SlabNode, - next: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const supportChanged = - next.polygon !== previous.polygon || - next.elevation !== previous.elevation || - next.holes !== previous.holes - - if (supportChanged) { - markNodesOverlappingSlab(previous, nodes, markDirty) - markNodesOverlappingSlab(next, nodes, markDirty) - } - if (next.elevation !== previous.elevation) { - markDeckAttachedStairs(next.id, nodes, markDirty) - } - if ( - supportChanged || - next.thickness !== previous.thickness || - next.recessed !== previous.recessed - ) { - markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) - } -} - -/** - * The sculpted ground moved: every node the terrain *supports* must re-elevate. - * - * The other rules in this file gate on a footprint overlapping the changed - * surface. Terrain has no such gate — a stroke rewrites a field that spans the - * whole lot, and the resolver samples it at each node's own XZ — so the sweep is - * every floor-placed node on a storey at grade, plus every wall whose explicit - * terrain infill samples that field. During a live stroke it fires per dab, but - * dirty ids coalesce in a Set until the frame systems consume them; the scene - * graph itself is still written only once on commit. - * - * Without this a sculpt silently desyncs the scene from its own ground. Nothing - * re-runs `getFloorPlacedElevation`, so the React commit that rebinds a node - * group's base Y leaves it there: a column that was resting on a hillside drops - * to the datum and stays buried under the terrain it used to stand on. - * - * Gated on `isLevelAtSiteDatum` — the same predicate `terrainSupportLift` uses to - * decide whether it drapes at all, so the two cannot disagree about which storey - * is on the ground. - */ -export function markTerrainSupportDependents( - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const gradeLevels = new Map() - const isGrade = (levelId: string) => { - let cached = gradeLevels.get(levelId) - if (cached === undefined) { - cached = isLevelAtSiteDatum(nodes, levelId) - gradeLevels.set(levelId, cached) - } - return cached - } - - for (const node of Object.values(nodes)) { - if (node.type === 'slab' && node.fillToTerrain === true) { - if (isGrade(resolveLevelId(node, nodes))) markDirty(node.id) - continue - } - - if (node.type === 'wall') { - if (node.supportSlabId !== GROUND_SUPPORT_ID && node.fillToTerrain !== true) continue - if (!isGrade(resolveLevelId(node, nodes))) continue - markDirty(node.id) - continue - } - - const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced) { - // A kind whose geometry builder resolved its own origin from the ground - // (`ctx.levelBaseAt`) has that ground baked into its meshes, so it has to - // rebuild even though nothing about the node changed. This is the - // invalidation half of the builder seam: without it a fence keeps the - // hillside it was built on and floats after the next stroke. Kinds that - // are `floorPlaced` need no entry here — the sweep below already covers - // them, through a mesh transform rather than a rebuild. - if (isLevelBaseConsumer(node.type) && isGrade(resolveLevelId(node, nodes))) { - markDirty(node.id) - } - continue - } - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - // Items hosted on a shelf or table inherit Y from the parent group; only - // level-parented nodes read the ground. Mirrors the resolver's own gate. - const parentId = node.parentId as AnyNodeId | null - const parent = parentId ? nodes[parentId] : null - if (parent && parent.type !== 'level') continue - if (!isGrade(resolveLevelId(node, nodes))) continue - markDirty(node.id) - } -} - -/** - * A slab on `slabLevelId` was created/deleted or changed shape/placement: - * the covering bound (slab underside) over the level BELOW moved, so that - * level's plane-bound walls and clamped ceilings must rebuild. - */ -export function markCoveringDependentsBelow( - slabLevelId: string, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const below = getLevelBelow(slabLevelId, nodes) - if (!below) return - for (const childId of below.children) { - const child = nodes[childId] - if (child?.type === 'wall' || child?.type === 'ceiling') { - markDirty(child.id) - } - } -} - -/** - * Mark all floor items and walls that may be affected by a slab change as dirty. - */ -function markNodesOverlappingSlab( - slab: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - if (slab.polygon.length < 3) return - const slabLevelId = resolveLevelId(slab, nodes) - - // Walls AND floor-placed nodes follow the slab's RENDERED footprint - // (band-adopted edges reach the wall's outer face), so the dirty gate - // must test the same polygon the support queries re-evaluate — a stored - // polygon that stops short of the wall body would otherwise never - // re-elevate nodes sitting over the adopted band. - const levelWalls: WallNode[] = [] - const siblingSlabs: SlabNode[] = [] - for (const node of Object.values(nodes)) { - if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) { - levelWalls.push(node as WallNode) - } else if ( - node.type === 'slab' && - node.id !== slab.id && - resolveLevelId(node, nodes) === slabLevelId - ) { - siblingSlabs.push(node as SlabNode) - } - } - const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) - - for (const node of Object.values(nodes)) { - if (node.type === 'wall') { - const wall = node as WallNode - if (resolveLevelId(node, nodes) !== slabLevelId) continue - if ( - wallOverlapsPolygon( - { - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset ?? 0, - thickness: wall.thickness, - }, - renderedPolygon, - ) - ) { - markDirty(node.id) - } - continue - } - // Generic floor-placed sweep: any registry kind that opts in via - // `capabilities.floorPlaced` (item / shelf / column / spawn / …) - // re-elevates through `` when a slab below - // changes. We dirty-mark when the kind's footprint overlaps the - // changed slab so the system picks it up next frame. - const def = nodeRegistry.get(node.type) - const floorPlaced = def?.capabilities?.floorPlaced - if (!floorPlaced) continue - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - const parentId = node.parentId as AnyNodeId | null - const parent = parentId ? nodes[parentId] : null - if (parent && parent.type !== 'level') continue - if (resolveLevelId(node, nodes) !== slabLevelId) continue - const position = (node as { position?: [number, number, number] }).position - if (!position) continue - for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { - if ( - itemOverlapsPolygon( - footprint.position ?? position, - footprint.dimensions, - footprint.rotation, - renderedPolygon, - 0.01, - ) - ) { - markDirty(node.id) - break - } - } - } -} +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' +import useLiveTerrain from '../../store/use-live-terrain' +import useScene from '../../store/use-scene' +import { getFloorPlacedFootprints } from './floor-placed-elevation' +import { + itemOverlapsPolygon, + spatialGridManager, + wallOverlapsPolygon, +} from './spatial-grid-manager' +import { GROUND_SUPPORT_ID } from './support-host-id' + +export function resolveLevelId(node: AnyNode, nodes: Record): string { + // If the node itself is a level + if (node.type === 'level') return node.id + + // Walk up parent chain to find level + // This assumes you track parentId or can derive it + let current: AnyNode | undefined = node + + while (current) { + if (current.type === 'level') return current.id + // Find parent (you might need to add parentId to your schema or derive it) + if (current.parentId) { + current = nodes[current.parentId] + } else { + current = undefined + } + } + + return 'default' // fallback for orphaned items +} + +/** + * Walks the parent chain of `nodeId` and returns the id of the first ancestor + * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned + * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant: + * + * - accepts a node **id** rather than a resolved node, saving the caller a + * `nodes[id]` lookup when only the id is at hand. + * - returns `null` instead of the `'default'` fallback, which lets callers + * distinguish "genuinely has no level" from "is a level". + * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot + * hang the frame loop. + */ +export function findLevelAncestorId( + nodeId: AnyNodeId, + nodes: Record, +): string | null { + let current: AnyNode | undefined = nodes[nodeId] + let guard = 0 + while (current && guard < 16) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + guard += 1 + } + return null +} + +/** + * Returns the building id that contains the given level, or `null` if + * the level is unparented or no enclosing building exists. + * + * Most scenes record the relationship via `level.parentId → + * building.id`, but older serialisations occasionally drop `parentId` + * even though the building's `children` array still references the + * level. The fallback scan covers that case. + * + * Used by `FloorplanRegistryLayer` to discover building-scoped kinds + * (`def.floorplanScope === 'building'`) without hardcoding any kind + * name in the editor layer. + */ +export function resolveBuildingForLevel( + levelId: AnyNodeId, + nodes: Record, +): AnyNodeId | null { + const level = nodes[levelId] as AnyNode | undefined + if (!level) return null + const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null + if (directParent) { + const candidate = nodes[directParent] + if (candidate?.type === 'building') return candidate.id as AnyNodeId + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type !== 'building') continue + const children = (candidate as { children?: AnyNodeId[] }).children + if (Array.isArray(children) && children.includes(levelId)) { + return candidate.id as AnyNodeId + } + } + return null +} + +// Call this once at app initialization. Returns an unsubscribe function that +// detaches the scene-store listener (useful when the editor is unmounted so +// the spatial grid singleton does not hold stale references to old scenes). +export function initSpatialGridSync(): () => void { + const store = useScene + // 1. Initial sync - process all existing nodes + const state = store.getState() + for (const node of Object.values(state.nodes)) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) + } + + // 2. Then subscribe to future changes + const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) + + // Subscribe to all changes + const unsubscribeScene = store.subscribe((state, prevState) => { + // Detect added nodes + for (const [id, node] of Object.entries(state.nodes)) { + if (!prevState.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) + + // When a slab is added, mark overlapping items/walls dirty + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) + } + + // A site arriving with terrain already on it (scene load, paste, + // imported elevation data) is the same event as a stroke: ground exists + // where flat ground was assumed. + if (node.type === 'site' && (node as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } + } + + // Detect removed nodes + for (const [id, node] of Object.entries(prevState.nodes)) { + if (!state.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, prevState.nodes) + spatialGridManager.handleNodeDeleted(id, node.type, levelId) + + // When a slab is removed, mark items/walls that were on it dirty (using current state) + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) + } + + // Deleting a sculpted site drops the ground back to the datum, so its + // contents have to come down with it. + if (node.type === 'site' && (node as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } + } + + // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) + for (const [id, node] of Object.entries(state.nodes)) { + const prev = prevState.nodes[id as AnyNode['id']] + if (!prev) continue + + if (node.type === 'item' && prev.type === 'item') { + if ( + !( + arraysEqual(node.position, prev.position) && + arraysEqual(node.rotation, prev.rotation) && + arraysEqual(node.scale, prev.scale) + ) || + node.parentId !== prev.parentId || + node.side !== prev.side + ) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + // Scale changes affect footprint size — mark dirty so slab elevation recalculates + if (!arraysEqual(node.scale, prev.scale)) { + markDirty(node.id) + } + } + } else if (node.type === 'slab' && prev.type === 'slab') { + const supportChanged = + node.polygon !== prev.polygon || + node.elevation !== prev.elevation || + node.holes !== prev.holes + if (supportChanged) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + } + markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) + } else if (node.type === 'level' && prev.type === 'level') { + if (node.height !== prev.height) { + markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) + } + } else if (node.type === 'site' && prev.type === 'site') { + // Object identity, not deep equality: the store is + // immutable-by-convention, so a sculpt commit necessarily produces a new + // `terrain` object and an unrelated site edit (polygon, name) keeps the + // old one. The same reasoning `terrain-source`'s field cache is keyed on. + if ((node as SiteNode).terrain !== (prev as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } else if (node.type === 'wall' && prev.type === 'wall') { + if ( + node.start !== prev.start || + node.end !== prev.end || + node.curveOffset !== prev.curveOffset || + node.thickness !== prev.thickness || + node.height !== prev.height || + node.endHeightOffset !== prev.endHeightOffset || + node.supportSlabId !== prev.supportSlabId || + node.supportOffset !== prev.supportOffset + ) { + // Rendered slab polygons adopt wall bands, and wall height/slope + // queries must see the latest node state — reach the manager to + // refresh its wall map and drop the level's rendered-polygon cache. + spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) + } + } + } + }) + + // Live terrain is deliberately not written into `useScene` per dab: doing so + // would encode the whole field, flood history, and wake every scene subscriber. + // Reuse the committed-terrain dependency sweep against the transient field + // instead. Dirty marks coalesce in their Set until the next frame, while an + // `end` notification also restores every dependent after an abandoned stroke. + const unsubscribeLiveTerrain = useLiveTerrain.subscribe(() => { + markTerrainSupportDependents(store.getState().nodes, markDirty) + }) + + return () => { + unsubscribeScene() + unsubscribeLiveTerrain() + } +} + +function arraysEqual(a: number[], b: number[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]) +} + +/** + * A level's stored height moved: plane-bound walls follow the new plane, + * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark + * them all so their systems rebuild. Restacking the level containers alone + * leaves their geometry stale. + */ +export function markLevelHeightDependents( + level: LevelNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const childId of level.children) { + const child = nodes[childId] + if (!child) continue + if ( + child.type === 'wall' || + child.type === 'stair' || + child.type === 'ceiling' || + child.type === 'fence' + ) { + markDirty(child.id) + } + } +} + +/** + * A deck slab's walking surface moved: stairs attached to it via + * `deckSlabId` derive their rise from that elevation, so their geometry + * (and rise-derived affordances) must rebuild. + */ +export function markDeckAttachedStairs( + slabId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const node of Object.values(nodes)) { + if (node.type === 'stair' && node.deckSlabId === slabId) { + markDirty(node.id) + } + } +} + +/** + * Dirty every consumer of a slab's top or underside. Kept pure so committed + * scene writes and live handle previews use the same dependency boundary. + */ +export function markSlabChangeDependents( + previous: SlabNode, + next: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const supportChanged = + next.polygon !== previous.polygon || + next.elevation !== previous.elevation || + next.holes !== previous.holes + + if (supportChanged) { + markNodesOverlappingSlab(previous, nodes, markDirty) + markNodesOverlappingSlab(next, nodes, markDirty) + } + if (next.elevation !== previous.elevation) { + markDeckAttachedStairs(next.id, nodes, markDirty) + } + if ( + supportChanged || + next.thickness !== previous.thickness || + next.recessed !== previous.recessed + ) { + markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) + } +} + +/** + * The sculpted ground moved: every node the terrain *supports* must re-elevate. + * + * The other rules in this file gate on a footprint overlapping the changed + * surface. Terrain has no such gate — a stroke rewrites a field that spans the + * whole lot, and the resolver samples it at each node's own XZ — so the sweep is + * every floor-placed node on a storey at grade, plus every wall whose explicit + * terrain infill samples that field. During a live stroke it fires per dab, but + * dirty ids coalesce in a Set until the frame systems consume them; the scene + * graph itself is still written only once on commit. + * + * Without this a sculpt silently desyncs the scene from its own ground. Nothing + * re-runs `getFloorPlacedElevation`, so the React commit that rebinds a node + * group's base Y leaves it there: a column that was resting on a hillside drops + * to the datum and stays buried under the terrain it used to stand on. + * + * Gated on `isLevelAtSiteDatum` — the same predicate `terrainSupportLift` uses to + * decide whether it drapes at all, so the two cannot disagree about which storey + * is on the ground. + */ +export function markTerrainSupportDependents( + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const gradeLevels = new Map() + const isGrade = (levelId: string) => { + let cached = gradeLevels.get(levelId) + if (cached === undefined) { + cached = isLevelAtSiteDatum(nodes, levelId) + gradeLevels.set(levelId, cached) + } + return cached + } + + for (const node of Object.values(nodes)) { + if (node.type === 'slab' && node.fillToTerrain === true) { + if (isGrade(resolveLevelId(node, nodes))) markDirty(node.id) + continue + } + + if (node.type === 'wall') { + if (node.supportSlabId !== GROUND_SUPPORT_ID && node.fillToTerrain !== true) continue + if (!isGrade(resolveLevelId(node, nodes))) continue + markDirty(node.id) + continue + } + + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced) { + // A kind whose geometry builder resolved its own origin from the ground + // (`ctx.levelBaseAt`) has that ground baked into its meshes, so it has to + // rebuild even though nothing about the node changed. This is the + // invalidation half of the builder seam: without it a fence keeps the + // hillside it was built on and floats after the next stroke. Kinds that + // are `floorPlaced` need no entry here — the sweep below already covers + // them, through a mesh transform rather than a rebuild. + if (isLevelBaseConsumer(node.type) && isGrade(resolveLevelId(node, nodes))) { + markDirty(node.id) + } + continue + } + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + // Items hosted on a shelf or table inherit Y from the parent group; only + // level-parented nodes read the ground. Mirrors the resolver's own gate. + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent && parent.type !== 'level') continue + if (!isGrade(resolveLevelId(node, nodes))) continue + markDirty(node.id) + } +} + +/** + * A slab on `slabLevelId` was created/deleted or changed shape/placement: + * the covering bound (slab underside) over the level BELOW moved, so that + * level's plane-bound walls and clamped ceilings must rebuild. + */ +export function markCoveringDependentsBelow( + slabLevelId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const below = getLevelBelow(slabLevelId, nodes) + if (!below) return + for (const childId of below.children) { + const child = nodes[childId] + if (child?.type === 'wall' || child?.type === 'ceiling') { + markDirty(child.id) + } + } +} + +/** + * Mark all floor items and walls that may be affected by a slab change as dirty. + */ +function markNodesOverlappingSlab( + slab: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + if (slab.polygon.length < 3) return + const slabLevelId = resolveLevelId(slab, nodes) + + // Walls AND floor-placed nodes follow the slab's RENDERED footprint + // (band-adopted edges reach the wall's outer face), so the dirty gate + // must test the same polygon the support queries re-evaluate — a stored + // polygon that stops short of the wall body would otherwise never + // re-elevate nodes sitting over the adopted band. + const levelWalls: WallNode[] = [] + const siblingSlabs: SlabNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) { + levelWalls.push(node as WallNode) + } else if ( + node.type === 'slab' && + node.id !== slab.id && + resolveLevelId(node, nodes) === slabLevelId + ) { + siblingSlabs.push(node as SlabNode) + } + } + const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + + for (const node of Object.values(nodes)) { + if (node.type === 'wall') { + const wall = node as WallNode + if (resolveLevelId(node, nodes) !== slabLevelId) continue + if ( + wallOverlapsPolygon( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset ?? 0, + thickness: wall.thickness, + }, + renderedPolygon, + ) + ) { + markDirty(node.id) + } + continue + } + // Generic floor-placed sweep: any registry kind that opts in via + // `capabilities.floorPlaced` (item / shelf / column / spawn / …) + // re-elevates through `` when a slab below + // changes. We dirty-mark when the kind's footprint overlaps the + // changed slab so the system picks it up next frame. + const def = nodeRegistry.get(node.type) + const floorPlaced = def?.capabilities?.floorPlaced + if (!floorPlaced) continue + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent && parent.type !== 'level') continue + if (resolveLevelId(node, nodes) !== slabLevelId) continue + const position = (node as { position?: [number, number, number] }).position + if (!position) continue + for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { + if ( + itemOverlapsPolygon( + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + renderedPolygon, + 0.01, + ) + ) { + markDirty(node.id) + break + } + } + } +} diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index b17032bb28..db43509755 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1211,6 +1211,7 @@ function wallGeometrySignature(wall: WallNode, nodes: Record, level // 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), + (wall.endHeightOffset ?? 0).toFixed(4), wall.supportSlabId ?? 'elected', (wall.supportOffset ?? 0).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4),