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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 {