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..03872e9774 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,1319 @@ -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 getWall(wallId: string): WallNode | undefined { + 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 this.walls.get(wallId) + } + + private getWallLength(wallId: string): number { + 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] + return Math.hypot(dx, dy) + } + + private getWallHeight(wallId: string, t?: number): number { + const wall = this.getWall(wallId) + if (!wall) return 0 + const nodes = useScene.getState().nodes as Record + return getWallEffectiveHeightForNodes(wall, nodes, 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: [] } + } + const [itemWidth, itemHeight] = dimensions + // Convert local X position to parametric t (0-1) + const tCenter = localX / wallLength + 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, + 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, + t?: number, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const baseElevation = getWallBaseElevationForNodes(wall, nodes) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation, t) +} diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d05d0d68e4..9deba30179 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,479 +1,483 @@ -import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' -import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema' -import { getLevelBelow } from '../../services/storey' -import useLiveTerrain from '../../store/use-live-terrain' -import useScene from '../../store/use-scene' -import { getFloorPlacedFootprints } from './floor-placed-elevation' -import { - itemOverlapsPolygon, - spatialGridManager, - wallOverlapsPolygon, -} from './spatial-grid-manager' -import { GROUND_SUPPORT_ID } from './support-host-id' - -export function resolveLevelId(node: AnyNode, nodes: Record): string { - // If the node itself is a level - if (node.type === 'level') return node.id - - // Walk up parent chain to find level - // This assumes you track parentId or can derive it - let current: AnyNode | undefined = node - - while (current) { - if (current.type === 'level') return current.id - // Find parent (you might need to add parentId to your schema or derive it) - if (current.parentId) { - current = nodes[current.parentId] - } else { - current = undefined - } - } - - return 'default' // fallback for orphaned items -} - -/** - * Walks the parent chain of `nodeId` and returns the id of the first ancestor - * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned - * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant: - * - * - accepts a node **id** rather than a resolved node, saving the caller a - * `nodes[id]` lookup when only the id is at hand. - * - returns `null` instead of the `'default'` fallback, which lets callers - * distinguish "genuinely has no level" from "is a level". - * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot - * hang the frame loop. - */ -export function findLevelAncestorId( - nodeId: AnyNodeId, - nodes: Record, -): string | null { - let current: AnyNode | undefined = nodes[nodeId] - let guard = 0 - while (current && guard < 16) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId] : undefined - guard += 1 - } - return null -} - -/** - * Returns the building id that contains the given level, or `null` if - * the level is unparented or no enclosing building exists. - * - * Most scenes record the relationship via `level.parentId → - * building.id`, but older serialisations occasionally drop `parentId` - * even though the building's `children` array still references the - * level. The fallback scan covers that case. - * - * Used by `FloorplanRegistryLayer` to discover building-scoped kinds - * (`def.floorplanScope === 'building'`) without hardcoding any kind - * name in the editor layer. - */ -export function resolveBuildingForLevel( - levelId: AnyNodeId, - nodes: Record, -): AnyNodeId | null { - const level = nodes[levelId] as AnyNode | undefined - if (!level) return null - const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null - if (directParent) { - const candidate = nodes[directParent] - if (candidate?.type === 'building') return candidate.id as AnyNodeId - } - for (const candidate of Object.values(nodes)) { - if (candidate?.type !== 'building') continue - const children = (candidate as { children?: AnyNodeId[] }).children - if (Array.isArray(children) && children.includes(levelId)) { - return candidate.id as AnyNodeId - } - } - return null -} - -// Call this once at app initialization. Returns an unsubscribe function that -// detaches the scene-store listener (useful when the editor is unmounted so -// the spatial grid singleton does not hold stale references to old scenes). -export function initSpatialGridSync(): () => void { - const store = useScene - // 1. Initial sync - process all existing nodes - const state = store.getState() - for (const node of Object.values(state.nodes)) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeCreated(node, levelId) - } - - // 2. Then subscribe to future changes - const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) - - // Subscribe to all changes - const unsubscribeScene = store.subscribe((state, prevState) => { - // Detect added nodes - for (const [id, node] of Object.entries(state.nodes)) { - if (!prevState.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeCreated(node, levelId) - - // When a slab is added, mark overlapping items/walls dirty - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - markCoveringDependentsBelow(levelId, state.nodes, markDirty) - } - - // A site arriving with terrain already on it (scene load, paste, - // imported elevation data) is the same event as a stroke: ground exists - // where flat ground was assumed. - if (node.type === 'site' && (node as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } - } - - // Detect removed nodes - for (const [id, node] of Object.entries(prevState.nodes)) { - if (!state.nodes[id as AnyNode['id']]) { - const levelId = resolveLevelId(node, prevState.nodes) - spatialGridManager.handleNodeDeleted(id, node.type, levelId) - - // When a slab is removed, mark items/walls that were on it dirty (using current state) - if (node.type === 'slab') { - markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) - markCoveringDependentsBelow(levelId, state.nodes, markDirty) - } - - // Deleting a sculpted site drops the ground back to the datum, so its - // contents have to come down with it. - if (node.type === 'site' && (node as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } - } - - // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) - for (const [id, node] of Object.entries(state.nodes)) { - const prev = prevState.nodes[id as AnyNode['id']] - if (!prev) continue - - if (node.type === 'item' && prev.type === 'item') { - if ( - !( - arraysEqual(node.position, prev.position) && - arraysEqual(node.rotation, prev.rotation) && - arraysEqual(node.scale, prev.scale) - ) || - node.parentId !== prev.parentId || - node.side !== prev.side - ) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - // Scale changes affect footprint size — mark dirty so slab elevation recalculates - if (!arraysEqual(node.scale, prev.scale)) { - markDirty(node.id) - } - } - } else if (node.type === 'slab' && prev.type === 'slab') { - const supportChanged = - node.polygon !== prev.polygon || - node.elevation !== prev.elevation || - node.holes !== prev.holes - if (supportChanged) { - const levelId = resolveLevelId(node, state.nodes) - spatialGridManager.handleNodeUpdated(node, levelId) - } - markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) - } else if (node.type === 'level' && prev.type === 'level') { - if (node.height !== prev.height) { - markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) - } - } else if (node.type === 'site' && prev.type === 'site') { - // Object identity, not deep equality: the store is - // immutable-by-convention, so a sculpt commit necessarily produces a new - // `terrain` object and an unrelated site edit (polygon, name) keeps the - // old one. The same reasoning `terrain-source`'s field cache is keyed on. - if ((node as SiteNode).terrain !== (prev as SiteNode).terrain) { - markTerrainSupportDependents(state.nodes, markDirty) - } - } else if (node.type === 'wall' && prev.type === 'wall') { - if ( - node.start !== prev.start || - node.end !== prev.end || - node.curveOffset !== prev.curveOffset || - node.thickness !== prev.thickness - ) { - // Rendered slab polygons adopt wall bands, so a wall reshape - // must reach the manager to refresh its wall map and drop the - // level's rendered-polygon cache. - spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) - } - } - } - }) - - // Live terrain is deliberately not written into `useScene` per dab: doing so - // would encode the whole field, flood history, and wake every scene subscriber. - // Reuse the committed-terrain dependency sweep against the transient field - // instead. Dirty marks coalesce in their Set until the next frame, while an - // `end` notification also restores every dependent after an abandoned stroke. - const unsubscribeLiveTerrain = useLiveTerrain.subscribe(() => { - markTerrainSupportDependents(store.getState().nodes, markDirty) - }) - - return () => { - unsubscribeScene() - unsubscribeLiveTerrain() - } -} - -function arraysEqual(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]) -} - -/** - * A level's stored height moved: plane-bound walls follow the new plane, - * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark - * them all so their systems rebuild. Restacking the level containers alone - * leaves their geometry stale. - */ -export function markLevelHeightDependents( - level: LevelNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - for (const childId of level.children) { - const child = nodes[childId] - if (!child) continue - if ( - child.type === 'wall' || - child.type === 'stair' || - child.type === 'ceiling' || - child.type === 'fence' - ) { - markDirty(child.id) - } - } -} - -/** - * A deck slab's walking surface moved: stairs attached to it via - * `deckSlabId` derive their rise from that elevation, so their geometry - * (and rise-derived affordances) must rebuild. - */ -export function markDeckAttachedStairs( - slabId: string, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - for (const node of Object.values(nodes)) { - if (node.type === 'stair' && node.deckSlabId === slabId) { - markDirty(node.id) - } - } -} - -/** - * Dirty every consumer of a slab's top or underside. Kept pure so committed - * scene writes and live handle previews use the same dependency boundary. - */ -export function markSlabChangeDependents( - previous: SlabNode, - next: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const supportChanged = - next.polygon !== previous.polygon || - next.elevation !== previous.elevation || - next.holes !== previous.holes - - if (supportChanged) { - markNodesOverlappingSlab(previous, nodes, markDirty) - markNodesOverlappingSlab(next, nodes, markDirty) - } - if (next.elevation !== previous.elevation) { - markDeckAttachedStairs(next.id, nodes, markDirty) - } - if ( - supportChanged || - next.thickness !== previous.thickness || - next.recessed !== previous.recessed - ) { - markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) - } -} - -/** - * The sculpted ground moved: every node the terrain *supports* must re-elevate. - * - * The other rules in this file gate on a footprint overlapping the changed - * surface. Terrain has no such gate — a stroke rewrites a field that spans the - * whole lot, and the resolver samples it at each node's own XZ — so the sweep is - * every floor-placed node on a storey at grade, plus every wall whose explicit - * terrain infill samples that field. During a live stroke it fires per dab, but - * dirty ids coalesce in a Set until the frame systems consume them; the scene - * graph itself is still written only once on commit. - * - * Without this a sculpt silently desyncs the scene from its own ground. Nothing - * re-runs `getFloorPlacedElevation`, so the React commit that rebinds a node - * group's base Y leaves it there: a column that was resting on a hillside drops - * to the datum and stays buried under the terrain it used to stand on. - * - * Gated on `isLevelAtSiteDatum` — the same predicate `terrainSupportLift` uses to - * decide whether it drapes at all, so the two cannot disagree about which storey - * is on the ground. - */ -export function markTerrainSupportDependents( - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const gradeLevels = new Map() - const isGrade = (levelId: string) => { - let cached = gradeLevels.get(levelId) - if (cached === undefined) { - cached = isLevelAtSiteDatum(nodes, levelId) - gradeLevels.set(levelId, cached) - } - return cached - } - - for (const node of Object.values(nodes)) { - if (node.type === 'slab' && node.fillToTerrain === true) { - if (isGrade(resolveLevelId(node, nodes))) markDirty(node.id) - continue - } - - if (node.type === 'wall') { - if (node.supportSlabId !== GROUND_SUPPORT_ID && node.fillToTerrain !== true) continue - if (!isGrade(resolveLevelId(node, nodes))) continue - markDirty(node.id) - continue - } - - const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced) { - // A kind whose geometry builder resolved its own origin from the ground - // (`ctx.levelBaseAt`) has that ground baked into its meshes, so it has to - // rebuild even though nothing about the node changed. This is the - // invalidation half of the builder seam: without it a fence keeps the - // hillside it was built on and floats after the next stroke. Kinds that - // are `floorPlaced` need no entry here — the sweep below already covers - // them, through a mesh transform rather than a rebuild. - if (isLevelBaseConsumer(node.type) && isGrade(resolveLevelId(node, nodes))) { - markDirty(node.id) - } - continue - } - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - // Items hosted on a shelf or table inherit Y from the parent group; only - // level-parented nodes read the ground. Mirrors the resolver's own gate. - const parentId = node.parentId as AnyNodeId | null - const parent = parentId ? nodes[parentId] : null - if (parent && parent.type !== 'level') continue - if (!isGrade(resolveLevelId(node, nodes))) continue - markDirty(node.id) - } -} - -/** - * A slab on `slabLevelId` was created/deleted or changed shape/placement: - * the covering bound (slab underside) over the level BELOW moved, so that - * level's plane-bound walls and clamped ceilings must rebuild. - */ -export function markCoveringDependentsBelow( - slabLevelId: string, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - const below = getLevelBelow(slabLevelId, nodes) - if (!below) return - for (const childId of below.children) { - const child = nodes[childId] - if (child?.type === 'wall' || child?.type === 'ceiling') { - markDirty(child.id) - } - } -} - -/** - * Mark all floor items and walls that may be affected by a slab change as dirty. - */ -function markNodesOverlappingSlab( - slab: SlabNode, - nodes: Record, - markDirty: (id: AnyNodeId) => void, -) { - if (slab.polygon.length < 3) return - const slabLevelId = resolveLevelId(slab, nodes) - - // Walls AND floor-placed nodes follow the slab's RENDERED footprint - // (band-adopted edges reach the wall's outer face), so the dirty gate - // must test the same polygon the support queries re-evaluate — a stored - // polygon that stops short of the wall body would otherwise never - // re-elevate nodes sitting over the adopted band. - const levelWalls: WallNode[] = [] - const siblingSlabs: SlabNode[] = [] - for (const node of Object.values(nodes)) { - if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) { - levelWalls.push(node as WallNode) - } else if ( - node.type === 'slab' && - node.id !== slab.id && - resolveLevelId(node, nodes) === slabLevelId - ) { - siblingSlabs.push(node as SlabNode) - } - } - const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) - - for (const node of Object.values(nodes)) { - if (node.type === 'wall') { - const wall = node as WallNode - if (resolveLevelId(node, nodes) !== slabLevelId) continue - if ( - wallOverlapsPolygon( - { - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset ?? 0, - thickness: wall.thickness, - }, - renderedPolygon, - ) - ) { - markDirty(node.id) - } - continue - } - // Generic floor-placed sweep: any registry kind that opts in via - // `capabilities.floorPlaced` (item / shelf / column / spawn / …) - // re-elevates through `` when a slab below - // changes. We dirty-mark when the kind's footprint overlaps the - // changed slab so the system picks it up next frame. - const def = nodeRegistry.get(node.type) - const floorPlaced = def?.capabilities?.floorPlaced - if (!floorPlaced) continue - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - const parentId = node.parentId as AnyNodeId | null - const parent = parentId ? nodes[parentId] : null - if (parent && parent.type !== 'level') continue - if (resolveLevelId(node, nodes) !== slabLevelId) continue - const position = (node as { position?: [number, number, number] }).position - if (!position) continue - for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { - if ( - itemOverlapsPolygon( - footprint.position ?? position, - footprint.dimensions, - footprint.rotation, - renderedPolygon, - 0.01, - ) - ) { - markDirty(node.id) - break - } - } - } -} +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import { isLevelAtSiteDatum, isLevelBaseConsumer } from '../../lib/terrain-support' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, LevelNode, SiteNode, SlabNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' +import useLiveTerrain from '../../store/use-live-terrain' +import useScene from '../../store/use-scene' +import { getFloorPlacedFootprints } from './floor-placed-elevation' +import { + itemOverlapsPolygon, + spatialGridManager, + wallOverlapsPolygon, +} from './spatial-grid-manager' +import { GROUND_SUPPORT_ID } from './support-host-id' + +export function resolveLevelId(node: AnyNode, nodes: Record): string { + // If the node itself is a level + if (node.type === 'level') return node.id + + // Walk up parent chain to find level + // This assumes you track parentId or can derive it + let current: AnyNode | undefined = node + + while (current) { + if (current.type === 'level') return current.id + // Find parent (you might need to add parentId to your schema or derive it) + if (current.parentId) { + current = nodes[current.parentId] + } else { + current = undefined + } + } + + return 'default' // fallback for orphaned items +} + +/** + * Walks the parent chain of `nodeId` and returns the id of the first ancestor + * whose `type` is `'level'`, or `null` when no level ancestor exists (orphaned + * node, top-level building node, etc.). Unlike `resolveLevelId`, this variant: + * + * - accepts a node **id** rather than a resolved node, saving the caller a + * `nodes[id]` lookup when only the id is at hand. + * - returns `null` instead of the `'default'` fallback, which lets callers + * distinguish "genuinely has no level" from "is a level". + * - has a loop guard (16 iterations) so a corrupt parent-chain cycle cannot + * hang the frame loop. + */ +export function findLevelAncestorId( + nodeId: AnyNodeId, + nodes: Record, +): string | null { + let current: AnyNode | undefined = nodes[nodeId] + let guard = 0 + while (current && guard < 16) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + guard += 1 + } + return null +} + +/** + * Returns the building id that contains the given level, or `null` if + * the level is unparented or no enclosing building exists. + * + * Most scenes record the relationship via `level.parentId → + * building.id`, but older serialisations occasionally drop `parentId` + * even though the building's `children` array still references the + * level. The fallback scan covers that case. + * + * Used by `FloorplanRegistryLayer` to discover building-scoped kinds + * (`def.floorplanScope === 'building'`) without hardcoding any kind + * name in the editor layer. + */ +export function resolveBuildingForLevel( + levelId: AnyNodeId, + nodes: Record, +): AnyNodeId | null { + const level = nodes[levelId] as AnyNode | undefined + if (!level) return null + const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null + if (directParent) { + const candidate = nodes[directParent] + if (candidate?.type === 'building') return candidate.id as AnyNodeId + } + for (const candidate of Object.values(nodes)) { + if (candidate?.type !== 'building') continue + const children = (candidate as { children?: AnyNodeId[] }).children + if (Array.isArray(children) && children.includes(levelId)) { + return candidate.id as AnyNodeId + } + } + return null +} + +// Call this once at app initialization. Returns an unsubscribe function that +// detaches the scene-store listener (useful when the editor is unmounted so +// the spatial grid singleton does not hold stale references to old scenes). +export function initSpatialGridSync(): () => void { + const store = useScene + // 1. Initial sync - process all existing nodes + const state = store.getState() + for (const node of Object.values(state.nodes)) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) + } + + // 2. Then subscribe to future changes + const markDirty = (id: AnyNodeId) => store.getState().markDirty(id) + + // Subscribe to all changes + const unsubscribeScene = store.subscribe((state, prevState) => { + // Detect added nodes + for (const [id, node] of Object.entries(state.nodes)) { + if (!prevState.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeCreated(node, levelId) + + // When a slab is added, mark overlapping items/walls dirty + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) + } + + // A site arriving with terrain already on it (scene load, paste, + // imported elevation data) is the same event as a stroke: ground exists + // where flat ground was assumed. + if (node.type === 'site' && (node as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } + } + + // Detect removed nodes + for (const [id, node] of Object.entries(prevState.nodes)) { + if (!state.nodes[id as AnyNode['id']]) { + const levelId = resolveLevelId(node, prevState.nodes) + spatialGridManager.handleNodeDeleted(id, node.type, levelId) + + // When a slab is removed, mark items/walls that were on it dirty (using current state) + if (node.type === 'slab') { + markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) + } + + // Deleting a sculpted site drops the ground back to the datum, so its + // contents have to come down with it. + if (node.type === 'site' && (node as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } + } + + // Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes) + for (const [id, node] of Object.entries(state.nodes)) { + const prev = prevState.nodes[id as AnyNode['id']] + if (!prev) continue + + if (node.type === 'item' && prev.type === 'item') { + if ( + !( + arraysEqual(node.position, prev.position) && + arraysEqual(node.rotation, prev.rotation) && + arraysEqual(node.scale, prev.scale) + ) || + node.parentId !== prev.parentId || + node.side !== prev.side + ) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + // Scale changes affect footprint size — mark dirty so slab elevation recalculates + if (!arraysEqual(node.scale, prev.scale)) { + markDirty(node.id) + } + } + } else if (node.type === 'slab' && prev.type === 'slab') { + const supportChanged = + node.polygon !== prev.polygon || + node.elevation !== prev.elevation || + node.holes !== prev.holes + if (supportChanged) { + const levelId = resolveLevelId(node, state.nodes) + spatialGridManager.handleNodeUpdated(node, levelId) + } + markSlabChangeDependents(prev as SlabNode, node as SlabNode, state.nodes, markDirty) + } else if (node.type === 'level' && prev.type === 'level') { + if (node.height !== prev.height) { + markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) + } + } else if (node.type === 'site' && prev.type === 'site') { + // Object identity, not deep equality: the store is + // immutable-by-convention, so a sculpt commit necessarily produces a new + // `terrain` object and an unrelated site edit (polygon, name) keeps the + // old one. The same reasoning `terrain-source`'s field cache is keyed on. + if ((node as SiteNode).terrain !== (prev as SiteNode).terrain) { + markTerrainSupportDependents(state.nodes, markDirty) + } + } else if (node.type === 'wall' && prev.type === 'wall') { + if ( + node.start !== prev.start || + node.end !== prev.end || + node.curveOffset !== prev.curveOffset || + node.thickness !== prev.thickness || + node.height !== prev.height || + node.endHeightOffset !== prev.endHeightOffset || + node.supportSlabId !== prev.supportSlabId || + node.supportOffset !== prev.supportOffset + ) { + // Rendered slab polygons adopt wall bands, and wall height/slope + // queries must see the latest node state — reach the manager to + // refresh its wall map and drop the level's rendered-polygon cache. + spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) + } + } + } + }) + + // Live terrain is deliberately not written into `useScene` per dab: doing so + // would encode the whole field, flood history, and wake every scene subscriber. + // Reuse the committed-terrain dependency sweep against the transient field + // instead. Dirty marks coalesce in their Set until the next frame, while an + // `end` notification also restores every dependent after an abandoned stroke. + const unsubscribeLiveTerrain = useLiveTerrain.subscribe(() => { + markTerrainSupportDependents(store.getState().nodes, markDirty) + }) + + return () => { + unsubscribeScene() + unsubscribeLiveTerrain() + } +} + +function arraysEqual(a: number[], b: number[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]) +} + +/** + * A level's stored height moved: plane-bound walls follow the new plane, + * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark + * them all so their systems rebuild. Restacking the level containers alone + * leaves their geometry stale. + */ +export function markLevelHeightDependents( + level: LevelNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const childId of level.children) { + const child = nodes[childId] + if (!child) continue + if ( + child.type === 'wall' || + child.type === 'stair' || + child.type === 'ceiling' || + child.type === 'fence' + ) { + markDirty(child.id) + } + } +} + +/** + * A deck slab's walking surface moved: stairs attached to it via + * `deckSlabId` derive their rise from that elevation, so their geometry + * (and rise-derived affordances) must rebuild. + */ +export function markDeckAttachedStairs( + slabId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const node of Object.values(nodes)) { + if (node.type === 'stair' && node.deckSlabId === slabId) { + markDirty(node.id) + } + } +} + +/** + * Dirty every consumer of a slab's top or underside. Kept pure so committed + * scene writes and live handle previews use the same dependency boundary. + */ +export function markSlabChangeDependents( + previous: SlabNode, + next: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const supportChanged = + next.polygon !== previous.polygon || + next.elevation !== previous.elevation || + next.holes !== previous.holes + + if (supportChanged) { + markNodesOverlappingSlab(previous, nodes, markDirty) + markNodesOverlappingSlab(next, nodes, markDirty) + } + if (next.elevation !== previous.elevation) { + markDeckAttachedStairs(next.id, nodes, markDirty) + } + if ( + supportChanged || + next.thickness !== previous.thickness || + next.recessed !== previous.recessed + ) { + markCoveringDependentsBelow(resolveLevelId(next, nodes), nodes, markDirty) + } +} + +/** + * The sculpted ground moved: every node the terrain *supports* must re-elevate. + * + * The other rules in this file gate on a footprint overlapping the changed + * surface. Terrain has no such gate — a stroke rewrites a field that spans the + * whole lot, and the resolver samples it at each node's own XZ — so the sweep is + * every floor-placed node on a storey at grade, plus every wall whose explicit + * terrain infill samples that field. During a live stroke it fires per dab, but + * dirty ids coalesce in a Set until the frame systems consume them; the scene + * graph itself is still written only once on commit. + * + * Without this a sculpt silently desyncs the scene from its own ground. Nothing + * re-runs `getFloorPlacedElevation`, so the React commit that rebinds a node + * group's base Y leaves it there: a column that was resting on a hillside drops + * to the datum and stays buried under the terrain it used to stand on. + * + * Gated on `isLevelAtSiteDatum` — the same predicate `terrainSupportLift` uses to + * decide whether it drapes at all, so the two cannot disagree about which storey + * is on the ground. + */ +export function markTerrainSupportDependents( + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const gradeLevels = new Map() + const isGrade = (levelId: string) => { + let cached = gradeLevels.get(levelId) + if (cached === undefined) { + cached = isLevelAtSiteDatum(nodes, levelId) + gradeLevels.set(levelId, cached) + } + return cached + } + + for (const node of Object.values(nodes)) { + if (node.type === 'slab' && node.fillToTerrain === true) { + if (isGrade(resolveLevelId(node, nodes))) markDirty(node.id) + continue + } + + if (node.type === 'wall') { + if (node.supportSlabId !== GROUND_SUPPORT_ID && node.fillToTerrain !== true) continue + if (!isGrade(resolveLevelId(node, nodes))) continue + markDirty(node.id) + continue + } + + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced) { + // A kind whose geometry builder resolved its own origin from the ground + // (`ctx.levelBaseAt`) has that ground baked into its meshes, so it has to + // rebuild even though nothing about the node changed. This is the + // invalidation half of the builder seam: without it a fence keeps the + // hillside it was built on and floats after the next stroke. Kinds that + // are `floorPlaced` need no entry here — the sweep below already covers + // them, through a mesh transform rather than a rebuild. + if (isLevelBaseConsumer(node.type) && isGrade(resolveLevelId(node, nodes))) { + markDirty(node.id) + } + continue + } + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + // Items hosted on a shelf or table inherit Y from the parent group; only + // level-parented nodes read the ground. Mirrors the resolver's own gate. + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent && parent.type !== 'level') continue + if (!isGrade(resolveLevelId(node, nodes))) continue + markDirty(node.id) + } +} + +/** + * A slab on `slabLevelId` was created/deleted or changed shape/placement: + * the covering bound (slab underside) over the level BELOW moved, so that + * level's plane-bound walls and clamped ceilings must rebuild. + */ +export function markCoveringDependentsBelow( + slabLevelId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const below = getLevelBelow(slabLevelId, nodes) + if (!below) return + for (const childId of below.children) { + const child = nodes[childId] + if (child?.type === 'wall' || child?.type === 'ceiling') { + markDirty(child.id) + } + } +} + +/** + * Mark all floor items and walls that may be affected by a slab change as dirty. + */ +function markNodesOverlappingSlab( + slab: SlabNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + if (slab.polygon.length < 3) return + const slabLevelId = resolveLevelId(slab, nodes) + + // Walls AND floor-placed nodes follow the slab's RENDERED footprint + // (band-adopted edges reach the wall's outer face), so the dirty gate + // must test the same polygon the support queries re-evaluate — a stored + // polygon that stops short of the wall body would otherwise never + // re-elevate nodes sitting over the adopted band. + const levelWalls: WallNode[] = [] + const siblingSlabs: SlabNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type === 'wall' && resolveLevelId(node, nodes) === slabLevelId) { + levelWalls.push(node as WallNode) + } else if ( + node.type === 'slab' && + node.id !== slab.id && + resolveLevelId(node, nodes) === slabLevelId + ) { + siblingSlabs.push(node as SlabNode) + } + } + const renderedPolygon = getRenderableSlabPolygon(slab, { walls: levelWalls, siblingSlabs }) + + for (const node of Object.values(nodes)) { + if (node.type === 'wall') { + const wall = node as WallNode + if (resolveLevelId(node, nodes) !== slabLevelId) continue + if ( + wallOverlapsPolygon( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset ?? 0, + thickness: wall.thickness, + }, + renderedPolygon, + ) + ) { + markDirty(node.id) + } + continue + } + // Generic floor-placed sweep: any registry kind that opts in via + // `capabilities.floorPlaced` (item / shelf / column / spawn / …) + // re-elevates through `` when a slab below + // changes. We dirty-mark when the kind's footprint overlaps the + // changed slab so the system picks it up next frame. + const def = nodeRegistry.get(node.type) + const floorPlaced = def?.capabilities?.floorPlaced + if (!floorPlaced) continue + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent && parent.type !== 'level') continue + if (resolveLevelId(node, nodes) !== slabLevelId) continue + const position = (node as { position?: [number, number, number] }).position + if (!position) continue + for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { + if ( + itemOverlapsPolygon( + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + renderedPolygon, + 0.01, + ) + ) { + markDirty(node.id) + break + } + } + } +} diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 3594b0c6cc..db43509755 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -1,2569 +1,2574 @@ -import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id' -import { - type AnyNodeId, - CeilingNode, - type CeilingNode as CeilingNodeType, - type LevelNode, - SlabNode, - type SlabNode as SlabNodeType, - type WallNode, - ZoneNode, - type ZoneNode as ZoneNodeType, -} from '../schema' -import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' -import { - CEILING_CLAMP_MARGIN, - findLevelAboveId, - getCeilingClampBound, - getLevelBelow, - getLevelElevations, - getStoredLevelHeight, -} from '../services/storey' -import { - activeSceneCommitNodeIds, - getSceneHistoryPauseDepth, - pauseSceneHistory, - resumeSceneHistory, - subscribeSceneCommits, -} from '../store/history-control' -import { computeWallSlabSupport } from '../systems/slab/slab-support' -import { - getClampedWallCurveOffset, - getWallCurveFrameAt, - isCurvedWall, -} from '../systems/wall/wall-curve' -import { resolveWallTop } from '../systems/wall/wall-top' -import { simplifyClosedPolygon } from './polygon-geometry' -import { - distanceToSegment, - type IndexedTopologyDelta, - RoomTopologyIndex, -} from './room-topology-index' -import { levelBaseElevationAt } from './terrain-support' - -type Point2D = { x: number; y: number } - -export type SpaceBoundaryFace = { - wallId: WallNode['id'] - face: 'front' | 'back' - points: Array<[number, number]> -} - -export type Space = { - id: string - levelId: string - polygon: Array<[number, number]> - wallIds: Array - boundaryFaces: SpaceBoundaryFace[] - isExterior: boolean -} - -export type SpaceTopologyReconcileEvent = { - levelId: string - strategy: 'indexed' | 'fallback' - examinedWallIds: string[] - affectedBeforeRoomCount: number - affectedCurrentRoomCount: number -} - -export type SpaceDetectionSyncOptions = { - onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void -} - -type ExtractedRoom = { - polygon: Point2D[] - boundaryFaces: SpaceBoundaryFace[] -} - -type WallSideUpdate = { - wallId: string - frontSide: 'interior' | 'exterior' | 'unknown' - backSide: 'interior' | 'exterior' | 'unknown' -} - -type DetectedRoom = { - poly: Point2D[] - sig: string - centroid: Point2D - area: number - bbox: ReturnType -} - -export type AutoSlabSyncPlan = { - create: SlabNodeType[] - update: Array<{ id: SlabNodeType['id']; data: Partial }> - delete: Array -} - -export type AutoSlabPlanningContext = { - elevationForRoom?: (polygon: Array<[number, number]>) => number | undefined - previousElevationForRoom?: (polygon: Array<[number, number]>) => number | undefined -} - -export type AutoCeilingSyncPlan = { - create: CeilingNodeType[] - update: Array<{ id: CeilingNodeType['id']; data: Partial }> - delete: Array - reparent: Array<{ id: AnyNodeId; parentId: CeilingNodeType['id'] }> -} - -export type AutoZoneSyncPlan = { - update: Array<{ id: ZoneNodeType['id']; data: Partial }> -} - -const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 -const CEILING_HEIGHT_EPSILON = 1e-6 -const ROOM_CURVE_TOLERANCE = 0.04 -const MAX_CURVE_SUBDIVISION_DEPTH = 6 -const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 -const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 -// A wall endpoint within this distance of another wall's interior is treated as a -// T-junction and splits that wall (see `splitStraightWallAtVertices`). -const WALL_JUNCTION_TOLERANCE = 0.08 -// An unmatched auto slab/ceiling whose polygon is still substantially covered -// by a detected room was absorbed by a room merge — the surviving auto surface -// owns that area, so keeping it would z-fight and it is deleted. Below this -// coverage the room genuinely ceased to exist (e.g. an enclosing wall was -// deleted) and the node is demoted to manual so user data survives. -const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 -const COVERAGE_SAMPLE_STEPS = 12 -// Rewrite deadband for an existing auto surface's elevation/height: below this -// the derived plane is the same plane and writing it would churn history. -const ROOM_VERTICAL_PLANE_EPSILON = 1e-3 - -// Pure planner callers omit `heightForRoom`, so auto ceilings keep their -// height-less level-following behavior. The live room sync supplies a height -// derived from the enclosing walls' own bases and tops. -export type AutoCeilingPlanningContext = { - /** Stored storey height of the level being planned (floor-to-floor). */ - storeyHeight?: number - /** - * Stage 3-B clamp-bound resolver for a polygon on the planned level: - * `min(storey plane, lowest covering-slab underside from the level - * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent - * (pure-planner callers without a nodes record), the bound degrades to - * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. - */ - ceilingClampBound?: (polygon: Array<[number, number]>) => number - heightForRoom?: (polygon: Array<[number, number]>) => number | undefined - previousHeightForRoom?: (polygon: Array<[number, number]>) => number | undefined - childPosition?: (childId: AnyNodeId) => [number, number] | undefined -} - -function pointFromTuple(point: [number, number]): Point2D { - return { x: point[0], y: point[1] } -} - -function pointToTuple(point: Point2D): [number, number] { - return [point.x, point.y] -} - -function pointKey(point: Point2D) { - return `${point.x.toFixed(3)},${point.y.toFixed(3)}` -} - -function polygonArea(points: Point2D[]) { - let area = 0 - for (let i = 0; i < points.length; i++) { - const a = points[i] - const b = points[(i + 1) % points.length] - if (!(a && b)) continue - area += a.x * b.y - b.x * a.y - } - return area / 2 -} - -function minRotationSignature(keys: string[]) { - if (keys.length === 0) return '' - let best = '' - for (let i = 0; i < keys.length; i++) { - const rotated = [...keys.slice(i), ...keys.slice(0, i)] - const value = rotated.join('|') - if (!best || value < best) best = value - } - return best -} - -function polygonSignature(points: Point2D[]) { - const keys = points.map(pointKey) - const forward = minRotationSignature(keys) - const reversed = minRotationSignature([...keys].reverse()) - return forward < reversed ? forward : reversed -} - -function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { - return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance -} - -function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) { - const deduped: Point2D[] = [] - - for (const point of points) { - const previous = deduped[deduped.length - 1] - if (previous && samePointWithinTolerance(previous, point, tolerance)) { - continue - } - deduped.push(point) - } - - const firstPoint = deduped[0] - const lastPoint = deduped[deduped.length - 1] - if ( - deduped.length > 2 && - firstPoint && - lastPoint && - samePointWithinTolerance(firstPoint, lastPoint, tolerance) - ) { - deduped.pop() - } - - return deduped -} - -function pointInPolygon(point: Point2D, polygon: Point2D[]) { - if (polygon.length < 3) return false - - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i]?.x ?? 0 - const yi = polygon[i]?.y ?? 0 - const xj = polygon[j]?.x ?? 0 - const yj = polygon[j]?.y ?? 0 - - const intersect = - yi > point.y !== yj > point.y && - point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi - if (intersect) inside = !inside - } - - return inside -} - -function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) { - return polygons.some((polygon) => pointInPolygon(point, polygon)) -} - -function polygonCentroid(points: Point2D[]) { - const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { - x: 0, - y: 0, - }) - - return { - x: sum.x / Math.max(points.length, 1), - y: sum.y / Math.max(points.length, 1), - } -} - -function bboxOf(points: Point2D[]) { - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const point of points) { - minX = Math.min(minX, point.x) - minY = Math.min(minY, point.y) - maxX = Math.max(maxX, point.x) - maxY = Math.max(maxY, point.y) - } - - return { minX, minY, maxX, maxY } -} - -function bboxOverlapArea(a: ReturnType, b: ReturnType) { - const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)) - const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)) - return ix * iy -} - -// Fraction of `subject`'s area lying inside any of `covers`, estimated by -// sampling a grid of cell centers over the subject's bbox. Cheap and robust -// enough for the merge-vs-demote decision; exact polygon clipping would be a -// heavy dependency for a 60% threshold. -function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) { - if (subject.length < 3 || covers.length === 0) return 0 - - const bbox = bboxOf(subject) - const width = bbox.maxX - bbox.minX - const height = bbox.maxY - bbox.minY - - let inside = 0 - let covered = 0 - for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) { - for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) { - const point = { - x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width, - y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height, - } - if (!pointInPolygon(point, subject)) continue - inside += 1 - if (pointInAnyPolygon(point, covers)) covered += 1 - } - } - - if (inside === 0) { - return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0 - } - - return covered / inside -} - -// Demoted auto surfaces keep their polygon untouched, so a re-closed room -// usually hits the exact-signature manual check. Coverage handles the rest: -// a room split across multiple manual surfaces AND a single manual surface -// spanning multiple rooms both suppress a replacement auto surface — what -// matters is that the ROOM is already substantially covered, not that any -// one manual surface belongs to it (a per-surface "mostly inside the room" -// filter dropped multi-room slabs and resurrected deleted auto slabs). -function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { - return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD -} - -function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { - let minDistance = Number.POSITIVE_INFINITY - for (let index = 0; index < polygon.length; index += 1) { - const start = polygon[index] - const end = polygon[(index + 1) % polygon.length] - if (!(start && end)) continue - minDistance = Math.min( - minDistance, - distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)), - ) - } - return minDistance -} - -function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { - const sampled = sampleWallPointsForRoomDetection(wall) - if (sampled.length === 0) return false - - const candidates = - sampled.length === 2 - ? [ - sampled[0]!, - { - x: (sampled[0]!.x + sampled[1]!.x) / 2, - y: (sampled[0]!.y + sampled[1]!.y) / 2, - }, - sampled[1]!, - ] - : sampled - - const matchingPoints = candidates.filter( - (point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, - ) - - return matchingPoints.length >= 2 -} - -/** - * The clamp bound for a ceiling polygon under this planning context — - * the context's cross-level resolver when provided, else the plane-only - * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. - */ -function resolveCeilingClampBound( - polygon: Array<[number, number]>, - context: AutoCeilingPlanningContext, -) { - if (context.ceilingClampBound) return context.ceilingClampBound(polygon) - return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN -} - -/** - * The base a boundary wall actually stands on, in level-local metres. - * - * Resolved the same way the wall renderer resolves it — the level base under - * the wall's own start point (sculpted ground or the flat plane), the slab - * election on top of that, plus the wall's stored `supportOffset`. Reading the - * ground for `GROUND_SUPPORT_ID` walls only is what left stamped room presets - * flat: `resolveWallSupportSlabPatch` writes no host at all for a wall on bare - * terrain, so the sentinel is a hint about pointer intent, never a precondition - * for standing on the ground. - */ -function boundaryWallBase( - wall: WallNode, - walls: WallNode[], - supportSlabs: readonly SlabNodeType[], - nodes: Record, - levelId: string, -): number { - const levelBase = levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]) - const offset = wall.supportOffset ?? 0 - if (wall.supportSlabId === GROUND_SUPPORT_ID) return levelBase + offset - return ( - computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null, null, levelBase) - .elevation + offset - ) -} - -/** - * The plane an auto floor/ceiling takes when its enclosing walls disagree. - * - * `floor` takes the HIGHEST wall base and `ceiling` the LOWEST wall top — - * the only pair that cannot open a hole: a floor at the lowest base would - * leave daylight under every wall standing higher, and a ceiling at the - * highest top would poke out through the shortest wall. Both surfaces stay - * flat (a slab is one scalar elevation by schema; see `vertical-model.md`), - * so a room on a slope is a level room cut into the hillside — the walls on - * the low side extend down to meet it, which is what their `baseSegments` - * fill-down already does. - * - * Non-finite inputs are the one abstain: a broken graph should keep the - * existing placement rather than move a surface to NaN. - */ -function roomFloorPlane(wallBases: number[]): number | undefined { - if (wallBases.length === 0 || wallBases.some((value) => !Number.isFinite(value))) return undefined - return Math.max(...wallBases) -} - -function roomCeilingPlane(wallTops: number[]): number | undefined { - if (wallTops.length === 0 || wallTops.some((value) => !Number.isFinite(value))) return undefined - return Math.min(...wallTops) -} - -function autoRoomVerticalPlacements( - spaces: readonly Space[], - walls: WallNode[], - supportSlabs: readonly SlabNodeType[], - nodes: Record, - storeyHeight: number, -) { - const wallsById = new Map(walls.map((wall) => [wall.id, wall])) - const placements = new Map() - - for (const space of spaces) { - const boundaryWalls = space.wallIds.flatMap((id) => { - const wall = wallsById.get(id) - return wall ? [wall] : [] - }) - if (boundaryWalls.length !== space.wallIds.length) continue - - const wallBases = boundaryWalls.map((wall) => - boundaryWallBase(wall, walls, supportSlabs, nodes, space.levelId), - ) - const base = roomFloorPlane(wallBases) - if (base === undefined) continue - - const wallTops = boundaryWalls.map((wall, index) => - resolveWallTop(wall, storeyHeight, wallBases[index] ?? base), - ) - const top = roomCeilingPlane(wallTops) - if (top === undefined) continue - - placements.set(polygonSignature(space.polygon.map(pointFromTuple)), { - slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION, - ceilingHeight: top - CEILING_CLAMP_MARGIN, - }) - } - - return placements -} - -function getWallDirection(wall: Pick) { - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - - if (length < 1e-9) { - return { - point: pointFromTuple(wall.start), - tangent: { x: 1, y: 0 }, - normal: { x: 0, y: 1 }, - } - } - - const tangent = { x: dx / length, y: dy / length } - return { - point: { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - }, - tangent, - normal: { x: -tangent.y, y: tangent.x }, - } -} - -function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { - const dx = end.x - start.x - const dy = end.y - start.y - const lengthSquared = dx * dx + dy * dy - - if (lengthSquared < 1e-9) { - return Math.hypot(point.x - start.x, point.y - start.y) - } - - const cross = (point.x - start.x) * dy - (point.y - start.y) * dx - return Math.abs(cross) / Math.sqrt(lengthSquared) -} - -function sampleWallPointsForRoomDetection( - wall: Pick, - tolerance = ROOM_CURVE_TOLERANCE, -) { - const start = { x: wall.start[0], y: wall.start[1] } - const end = { x: wall.end[0], y: wall.end[1] } - - if (!isCurvedWall(wall)) { - return [start, end] - } - - const subdivide = ( - t0: number, - p0: Point2D, - t1: number, - p1: Point2D, - depth: number, - ): Point2D[] => { - const midT = (t0 + t1) / 2 - const midPoint = getWallCurveFrameAt(wall, midT).point - const deviation = pointLineDistance(midPoint, p0, p1) - - if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) { - return [p0, p1] - } - - const left = subdivide(t0, p0, midT, midPoint, depth + 1) - const right = subdivide(midT, midPoint, t1, p1, depth + 1) - return [...left.slice(0, -1), ...right] - } - - return subdivide(0, start, 1, end, 0) -} - -function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { - const dx = end.x - start.x - const dy = end.y - start.y - const lengthSquared = dx * dx + dy * dy - if (lengthSquared < 1e-12) { - return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } - } - const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared - const clampedT = Math.max(0, Math.min(1, t)) - const projX = start.x + clampedT * dx - const projY = start.y + clampedT * dy - return { t, distance: Math.hypot(point.x - projX, point.y - projY) } -} - -// Break a straight wall at any junction vertex (another wall's endpoint) that -// lands on its interior, returning the ordered polyline [start, …splits, end]. -// Splitting at the *vertex* position (not the projection) keeps the split node's -// key identical to the touching wall's endpoint so the two share a graph node. -function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { - const length = Math.hypot(end.x - start.x, end.y - start.y) - if (length < 1e-9) return [start, end] - - const interior: Array<{ point: Point2D; t: number }> = [] - for (const vertex of vertices) { - const { t, distance } = segmentProjection(vertex, start, end) - if (distance > WALL_JUNCTION_TOLERANCE) continue - const along = t * length - if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue - interior.push({ point: vertex, t }) - } - interior.sort((a, b) => a.t - b.t) - - const ordered: Point2D[] = [start] - let lastKey = pointKey(start) - for (const { point } of interior) { - const key = pointKey(point) - if (key === lastKey) continue - ordered.push(point) - lastKey = key - } - if (lastKey !== pointKey(end)) ordered.push(end) - return ordered -} - -function extractRooms(walls: WallNode[]): ExtractedRoom[] { - if (walls.length < 3) return [] - - type HalfEdge = { - id: string - reverseId: string - fromKey: string - toKey: string - angle: number - points: Point2D[] - wallId: WallNode['id'] - face: 'front' | 'back' - } - type Node = { point: Point2D; outgoing: string[] } - - const graph = new Map() - const halfEdges = new Map() - - const upsertNode = (point: Point2D) => { - const key = pointKey(point) - if (!graph.has(key)) { - graph.set(key, { point: { ...point }, outgoing: [] }) - } - return key - } - - // Planarize first: collect every wall endpoint as a candidate graph vertex so - // straight walls can be split at T-junctions where another wall ends mid-span. - // Without this the touching wall's endpoint is a dangling degree-1 node and the - // enclosed area (e.g. a room added against the middle of an existing wall) - // never forms a cycle. - const vertexByKey = new Map() - for (const wall of walls) { - for (const tuple of [wall.start, wall.end]) { - const point = pointFromTuple(tuple) - const key = pointKey(point) - if (!vertexByKey.has(key)) vertexByKey.set(key, point) - } - } - const vertices = [...vertexByKey.values()] - - for (const wall of walls) { - const start = pointFromTuple(wall.start) - const end = pointFromTuple(wall.end) - if (samePointWithinTolerance(start, end)) continue - - // Curved walls keep their sampled polyline as one edge; straight walls split - // into consecutive sub-edges at their interior junction vertices. - const subPolylines: Point2D[][] = isCurvedWall(wall) - ? [sampleWallPointsForRoomDetection(wall)] - : (() => { - const ordered = splitStraightWallAtVertices(start, end, vertices) - const parts: Point2D[][] = [] - for (let index = 0; index < ordered.length - 1; index += 1) { - parts.push([ordered[index]!, ordered[index + 1]!]) - } - return parts - })() - - subPolylines.forEach((points, subIndex) => { - const from = points[0]! - const to = points[points.length - 1]! - const fromKey = upsertNode(from) - const toKey = upsertNode(to) - if (fromKey === toKey) return - - const reversePoints = [...points].reverse() - const forwardId = `${wall.id}#${subIndex}:f` - const reverseId = `${wall.id}#${subIndex}:r` - - halfEdges.set(forwardId, { - id: forwardId, - reverseId, - fromKey, - toKey, - angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), - points, - wallId: wall.id, - face: 'front', - }) - halfEdges.set(reverseId, { - id: reverseId, - reverseId: forwardId, - fromKey: toKey, - toKey: fromKey, - angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), - points: reversePoints, - wallId: wall.id, - face: 'back', - }) - - graph.get(fromKey)?.outgoing.push(forwardId) - graph.get(toKey)?.outgoing.push(reverseId) - }) - } - - const sortedOutgoing = new Map() - for (const [key, node] of graph.entries()) { - const outgoing = [...node.outgoing] - outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) - sortedOutgoing.set(key, outgoing) - } - - const nextEdge = (edgeId: string) => { - const edge = halfEdges.get(edgeId) - if (!edge) return null - - const outgoing = sortedOutgoing.get(edge.toKey) - if (!outgoing || outgoing.length === 0) return null - - const idx = outgoing.indexOf(edge.reverseId) - if (idx === -1) return null - - const nextIdx = (idx - 1 + outgoing.length) % outgoing.length - return outgoing[nextIdx] ?? null - } - - const splitIntoSimpleCycles = (walkEdgeIds: string[]) => { - const cycles: string[][] = [] - const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '') - if (!firstEdge) return cycles - - const pathEdges: string[] = [] - const pathVertices = [firstEdge.fromKey] - const vertexIndex = new Map([[firstEdge.fromKey, 0]]) - - for (const edgeId of walkEdgeIds) { - const edge = halfEdges.get(edgeId) - if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return [] - - pathEdges.push(edgeId) - const repeatedIndex = vertexIndex.get(edge.toKey) - if (repeatedIndex === undefined) { - pathVertices.push(edge.toKey) - vertexIndex.set(edge.toKey, pathVertices.length - 1) - continue - } - - const cycle = pathEdges.slice(repeatedIndex) - if (cycle.length >= 3) cycles.push(cycle) - - for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) { - vertexIndex.delete(pathVertices[index]!) - } - pathVertices.length = repeatedIndex + 1 - pathEdges.length = repeatedIndex - } - - return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [] - } - - const visitedDirected = new Set() - const rooms: ExtractedRoom[] = [] - // A face walk cannot revisit a half-edge, so the half-edge count bounds its - // length. It can revisit a vertex when dangling walls or other graph bridges - // are traced out and back; those excursions are removed below. - const maxSteps = Math.min(2000, halfEdges.size + 10) - - for (const edgeId of halfEdges.keys()) { - if (visitedDirected.has(edgeId)) continue - - const cycleEdgeIds: string[] = [] - let currentEdgeId = edgeId - let valid = true - let closed = false - - for (let step = 0; step < maxSteps; step += 1) { - const currentEdge = halfEdges.get(currentEdgeId) - if (!currentEdge) { - valid = false - break - } - - visitedDirected.add(currentEdgeId) - cycleEdgeIds.push(currentEdgeId) - - const next = nextEdge(currentEdgeId) - if (!next) { - valid = false - break - } - - currentEdgeId = next - if (currentEdgeId === edgeId) { - closed = true - break - } - } - - if (!(valid && closed) || cycleEdgeIds.length < 3) continue - - for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) { - const polygon = dedupeSequentialPoints( - simpleCycleEdgeIds.flatMap((id, index) => { - const points = halfEdges.get(id)?.points ?? [] - return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1) - }), - ) - - if (polygon.length < 3) continue - - const signedArea = polygonArea(polygon) - if (signedArea <= 0) continue - if (signedArea < 0.5 || signedArea > 10_000) continue - - const signature = polygonSignature(polygon) - if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue - - rooms.push({ - polygon, - boundaryFaces: simpleCycleEdgeIds.flatMap((id) => { - const edge = halfEdges.get(id) - if (!edge) return [] - return [ - { - wallId: edge.wallId, - face: edge.face, - points: edge.points.map(pointToTuple), - }, - ] - }), - }) - } - } - - rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon))) - return rooms -} - -function extractRoomPolygons(walls: WallNode[]): Point2D[][] { - return extractRooms(walls).map((room) => room.polygon) -} - -/** - * True when `wall` lies on the boundary of a room enclosed by `walls`, using the - * same planar room graph the auto slab/ceiling sync uses. The wall builder's - * "Room (auto-close)" mode calls this so drafting stops the moment a segment - * closes a room — whether the chain loops back to its own start or seals a bay - * against the middle of an existing wall (a T-junction). Sharing one graph means - * auto-close and auto-slab detection can never disagree about what is "closed". - */ -export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean { - const roomPolygons = extractRoomPolygons(walls) - if (roomPolygons.length === 0) return false - return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon)) -} - -export function resolveWallSurfaceSides( - wall: Pick, - roomPolygons: Point2D[][], -): Pick { - if (roomPolygons.length === 0) { - return { - frontSide: 'unknown' as const, - backSide: 'unknown' as const, - } - } - - const frame = getWallDirection(wall) - const normalLength = Math.hypot(frame.normal.x, frame.normal.y) - if (normalLength < 1e-9) { - return { - frontSide: wall.frontSide, - backSide: wall.backSide, - } - } - - const normalX = frame.normal.x / normalLength - const normalY = frame.normal.y / normalLength - const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16) - - const frontPoint = { - x: frame.point.x + normalX * sampleDistance, - y: frame.point.y + normalY * sampleDistance, - } - const backPoint = { - x: frame.point.x - normalX * sampleDistance, - y: frame.point.y - normalY * sampleDistance, - } - - const frontInside = pointInAnyPolygon(frontPoint, roomPolygons) - const backInside = pointInAnyPolygon(backPoint, roomPolygons) - - if (frontInside === backInside) { - return { - frontSide: wall.frontSide, - backSide: wall.backSide, - } - } - - return { - frontSide: frontInside ? 'interior' : 'exterior', - backSide: backInside ? 'interior' : 'exterior', - } -} - -function nextAutoRoomName( - nodes: Array<{ - name?: string - }>, - suffix: 'Slab' | 'Ceiling', -) { - let maxIndex = 0 - - for (const node of nodes) { - const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim()) - if (!match) continue - const index = Number(match[1]) - if (Number.isFinite(index)) { - maxIndex = Math.max(maxIndex, index) - } - } - - return `Room ${maxIndex + 1} ${suffix}` -} - -function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) { - return ( - current.length === next.length && - current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) - ) -} - -function sameTuplePolygons( - current: Array>, - next: Array>, -) { - return ( - current.length === next.length && - current.every((polygon, index) => { - const nextPolygon = next[index] - return nextPolygon ? sameTuplePolygon(polygon, nextPolygon) : false - }) - ) -} - -type SurfaceWithOpenings = { - holes: Array> - holeMetadata: SlabNodeType['holeMetadata'] -} - -function crossProduct(a: Point2D, b: Point2D, c: Point2D) { - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) -} - -function lineIntersection(start: Point2D, end: Point2D, clipStart: Point2D, clipEnd: Point2D) { - const segment = { x: end.x - start.x, y: end.y - start.y } - const clip = { x: clipEnd.x - clipStart.x, y: clipEnd.y - clipStart.y } - const denominator = segment.x * clip.y - segment.y * clip.x - if (Math.abs(denominator) < 1e-9) return end - const offset = { x: clipStart.x - start.x, y: clipStart.y - start.y } - const t = (offset.x * clip.y - offset.y * clip.x) / denominator - return { x: start.x + segment.x * t, y: start.y + segment.y * t } -} - -function clipPolygonToConvex(subject: Point2D[], clipPolygon: Point2D[]) { - if (subject.length < 3 || clipPolygon.length < 3) return [] - const orientation = polygonArea(clipPolygon) >= 0 ? 1 : -1 - let output = [...subject] - - for (let index = 0; index < clipPolygon.length; index += 1) { - const clipStart = clipPolygon[index]! - const clipEnd = clipPolygon[(index + 1) % clipPolygon.length]! - const input = output - output = [] - if (input.length === 0) break - - let previous = input[input.length - 1]! - let previousInside = orientation * crossProduct(clipStart, clipEnd, previous) >= -1e-8 - for (const current of input) { - const currentInside = orientation * crossProduct(clipStart, clipEnd, current) >= -1e-8 - if (currentInside !== previousInside) { - output.push(lineIntersection(previous, current, clipStart, clipEnd)) - } - if (currentInside) output.push(current) - previous = current - previousInside = currentInside - } - output = dedupeSequentialPoints(output, 1e-7) - } - - return output.length >= 3 && Math.abs(polygonArea(output)) > 1e-8 ? output : [] -} - -function isConvexPolygon(polygon: Point2D[]) { - let direction = 0 - for (let index = 0; index < polygon.length; index += 1) { - const cross = crossProduct( - polygon[index]!, - polygon[(index + 1) % polygon.length]!, - polygon[(index + 2) % polygon.length]!, - ) - if (Math.abs(cross) < 1e-8) continue - const nextDirection = Math.sign(cross) - if (direction !== 0 && nextDirection !== direction) return false - direction = nextDirection - } - return true -} - -function pointInTriangle(point: Point2D, a: Point2D, b: Point2D, c: Point2D) { - return ( - crossProduct(a, b, point) >= -1e-8 && - crossProduct(b, c, point) >= -1e-8 && - crossProduct(c, a, point) >= -1e-8 - ) -} - -function triangulatePolygon(polygon: Point2D[]) { - const points = polygonArea(polygon) >= 0 ? [...polygon] : [...polygon].reverse() - const indices = points.map((_, index) => index) - const triangles: Point2D[][] = [] - let attempts = 0 - - while (indices.length > 3 && attempts < points.length * points.length) { - let clippedEar = false - for (let index = 0; index < indices.length; index += 1) { - const previousIndex = indices[(index - 1 + indices.length) % indices.length]! - const currentIndex = indices[index]! - const nextIndex = indices[(index + 1) % indices.length]! - const previous = points[previousIndex]! - const current = points[currentIndex]! - const next = points[nextIndex]! - if (crossProduct(previous, current, next) <= 1e-8) continue - if ( - indices.some( - (candidateIndex) => - candidateIndex !== previousIndex && - candidateIndex !== currentIndex && - candidateIndex !== nextIndex && - pointInTriangle(points[candidateIndex]!, previous, current, next), - ) - ) { - continue - } - triangles.push([previous, current, next]) - indices.splice(index, 1) - clippedEar = true - break - } - if (!clippedEar) break - attempts += 1 - } - - if (indices.length === 3) triangles.push(indices.map((index) => points[index]!)) - return triangles -} - -function clipOpeningToRoom(opening: Point2D[], room: Point2D[]) { - const openingIsInside = opening.every( - (point) => pointInPolygon(point, room) || pointDistanceToPolygonBoundary(point, room) <= 1e-7, - ) - if (openingIsInside) return [opening] - - const clipRegions = isConvexPolygon(room) ? [room] : triangulatePolygon(room) - return clipRegions - .map((region) => clipPolygonToConvex(opening, region)) - .filter((polygon) => polygon.length >= 3) -} - -function partitionSurfaceOpenings( - surface: SurfaceWithOpenings, - roomIndices: number[], - detected: DetectedRoom[], -) { - const assignments = new Map< - number, - { holes: Array>; holeMetadata: SlabNodeType['holeMetadata'] } - >() - for (const roomIndex of roomIndices) { - assignments.set(roomIndex, { holes: [], holeMetadata: [] }) - } - - surface.holes.forEach((hole, holeIndex) => { - const holePolygon = hole.map(pointFromTuple) - for (const roomIndex of roomIndices) { - const room = detected[roomIndex] - const assignment = assignments.get(roomIndex) - if (!(room && assignment)) continue - for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) { - assignment.holes.push(clipped.map(pointToTuple)) - assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) - } - } - }) - - return assignments -} - -function partitionCeilingChildren( - ceiling: CeilingNodeType, - roomIndices: number[], - detected: DetectedRoom[], - fallbackRoomIndex: number | undefined, - childPosition: AutoCeilingPlanningContext['childPosition'], -) { - const assignments = new Map() - for (const roomIndex of roomIndices) assignments.set(roomIndex, []) - - for (const childId of ceiling.children) { - const tuple = childPosition?.(childId) - const point = tuple ? pointFromTuple(tuple) : undefined - const boundaryRoomIndices = point - ? roomIndices.filter((roomIndex) => { - const room = detected[roomIndex] - return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false - }) - : [] - const interiorRoomIndex = - point && boundaryRoomIndices.length === 0 - ? roomIndices.find((roomIndex) => { - const room = detected[roomIndex] - return room ? pointInPolygon(point, room.poly) : false - }) - : undefined - const roomIndex = - interiorRoomIndex ?? - (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex) - ? fallbackRoomIndex - : boundaryRoomIndices[0]) ?? - fallbackRoomIndex ?? - roomIndices[0] - if (roomIndex !== undefined) assignments.get(roomIndex)?.push(childId) - } - - return assignments -} - -function sameHoleMetadata( - current: SlabNodeType['holeMetadata'], - next: SlabNodeType['holeMetadata'], -) { - return ( - current.length === next.length && - current.every((metadata, index) => { - const candidate = next[index] - return ( - candidate?.source === metadata.source && - candidate.stairId === metadata.stairId && - candidate.elevatorId === metadata.elevatorId - ) - }) - ) -} - -function mergedSurfaceOpenings(surfaces: SurfaceWithOpenings[]) { - const holes: Array> = [] - const holeMetadata: SlabNodeType['holeMetadata'] = [] - const seen = new Set() - - for (const surface of surfaces) { - surface.holes.forEach((hole, index) => { - const metadata = surface.holeMetadata[index] ?? { source: 'manual' } - const key = JSON.stringify([hole, metadata]) - if (seen.has(key)) return - seen.add(key) - holes.push(hole) - holeMetadata.push(metadata) - }) - } - - return { holes, holeMetadata } -} - -function ceilingMergeSettingsSignature(ceiling: CeilingNodeType) { - return JSON.stringify([ - ceiling.height ?? null, - ceiling.material ?? null, - ceiling.materialPreset ?? null, - ceiling.slots ?? null, - ceiling.visible, - ]) -} - -function slabMergeSettingsSignature(slab: SlabNodeType) { - return JSON.stringify([ - slab.elevation, - slab.thickness, - slab.recessed, - slab.recessedRimElevation ?? null, - slab.fillToTerrain ?? null, - slab.material ?? null, - slab.materialPreset ?? null, - slab.slots ?? null, - slab.visible, - ]) -} - -function slabElevationForReconciledRoom( - source: SlabNodeType, - polygon: Array<[number, number]>, - context: AutoSlabPlanningContext, -) { - const currentDerived = context.elevationForRoom?.(polygon) - if (!context.previousElevationForRoom) return currentDerived ?? source.elevation - - const previousDerived = context.previousElevationForRoom(source.polygon) - const sourceWasDerived = - previousDerived !== undefined && - Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON - return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation -} - -function ceilingHeightForReconciledRoom( - source: CeilingNodeType, - polygon: Array<[number, number]>, - context: AutoCeilingPlanningContext, -) { - if (source.height === undefined) return undefined - - const currentDerived = context.heightForRoom?.(polygon) - if (!context.previousHeightForRoom) return currentDerived ?? source.height - - const previousDerived = context.previousHeightForRoom(source.polygon) - const sourceWasDerived = - previousDerived !== undefined && - Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON - return sourceWasDerived ? (currentDerived ?? source.height) : source.height -} - -function wallGeometrySignature(wall: WallNode, nodes: Record, levelId: string) { - return [ - wall.id, - wall.start[0].toFixed(4), - wall.start[1].toFixed(4), - wall.end[0].toFixed(4), - wall.end[1].toFixed(4), - (wall.thickness ?? 0.2).toFixed(4), - // Plane-bound (no stored height) is a distinct state, not a default - // value: it resolves to the storey plane, so it must not alias an - // explicit height of the same magnitude in the trigger signature. - wall.height == null ? 'plane' : wall.height.toFixed(4), - wall.supportSlabId ?? 'elected', - (wall.supportOffset ?? 0).toFixed(4), - getClampedWallCurveOffset(wall).toFixed(4), - // The ground under this wall, sampled at the SAME point - // `boundaryWallBase` samples it. Sculpting changes only `site.terrain`, - // so without a terrain term here every signature stays byte-identical - // and the sync early-exits — a room's floor and ceiling could never - // follow ground that moved beneath its walls. - // - // The sample, not the field, and not the resolved base: hashing the - // heightfield would re-trigger every level for a stroke on the far side - // of the lot, and resolving the full slab election would fold slab - // POLYGONS into the signature, which is exactly the delete/recreate - // feedback the comment below is about. Sampling where the placement - // samples means the two cannot disagree in either direction — no missed - // re-run, no spurious one. - // - // Granularity is per stroke, not per dab: live dabs publish to - // `useLiveTerrain` and never touch the scene store, so this runs once on - // release — inside the stroke's own `runAsSingleSceneHistoryStep`, which - // is what puts the moved floor and the terrain that moved it in the same - // undo step. Mid-drag the ground-hosted walls follow the brush while the - // floor waits for release; re-deriving per dab would mean a scene write - // per dab and a floor that jitters under the cursor. - levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]).toFixed(4), - ].join('|') -} - -function levelWallSnapshot(walls: WallNode[], nodes: Record, levelId: string) { - return walls - .map((wall) => wallGeometrySignature(wall, nodes, levelId)) - .sort() - .join('||') -} - -function zoneGeometrySignature(zone: ZoneNodeType) { - return [ - zone.id, - zone.autoFromWalls ? 'auto' : 'manual', - zone.boundaryWallIds.slice().sort().join(','), - zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'), - ].join('|') -} - -// Slab/ceiling POLYGONS stay out of the trigger signature: including -// generated footprints caused delete/recreate feedback. Zones are included -// only so a newly traced room footprint can adopt its enclosing walls -// without waiting for the next remodel. Slab ELEVATIONS and the level's -// stored storey height ARE included — both feed the explicit-ceiling -// re-clamp bound (the storey plane), and neither is rewritten by -// the sync, so regeneration triggers when they change without feedback. -// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − -// thickness, recessed pools excluded): a deck created, lowered, or -// thickened above must re-run the sync below so ceilings re-clamp under -// it. Same polygon exclusion applies — the level-above's own auto sync -// rewrites its slab footprints, and hashing them here would re-trigger -// this level on every remodel above. -function levelStructureSnapshots(nodes: Record) { - const wallsByLevel = new Map() - const zonesByLevel = new Map() - const slabElevationsByLevel = new Map() - const coveringUndersidesByLevel = new Map() - - for (const node of Object.values(nodes)) { - if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue - const levelId = (node as any).parentId as string - if ((node as any).type === 'wall') { - const walls = wallsByLevel.get(levelId) ?? [] - walls.push(node as WallNode) - wallsByLevel.set(levelId, walls) - } else if ((node as any).type === 'zone') { - const zones = zonesByLevel.get(levelId) ?? [] - zones.push(ZoneNode.parse(node)) - zonesByLevel.set(levelId, zones) - } else if ((node as any).type === 'slab') { - const elevations = slabElevationsByLevel.get(levelId) ?? [] - elevations.push( - `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, - ) - slabElevationsByLevel.set(levelId, elevations) - if ((node as any).recessed !== true) { - const undersides = coveringUndersidesByLevel.get(levelId) ?? [] - const elevation = ((node as any).elevation as number | undefined) ?? 0.05 - const thickness = ((node as any).thickness as number | undefined) ?? 0.05 - undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) - coveringUndersidesByLevel.set(levelId, undersides) - } - } - } - - const levelElevations = getLevelElevations(nodes as Record) - const snapshots = new Map() - const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) - for (const levelId of levelIds) { - const walls = wallsByLevel.get(levelId) ?? [] - const zones = zonesByLevel.get(levelId) ?? [] - const level = nodes[levelId] - const storeyKey = - level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' - const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') - const aboveId = findLevelAboveId(levelId, levelElevations) - const aboveSlabKey = aboveId - ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') - : '' - snapshots.set( - levelId, - `${storeyKey}#${levelWallSnapshot(walls, nodes, levelId)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, - ) - } - - return snapshots -} - -function buildSpace(levelId: string, room: ExtractedRoom): Space { - const signature = polygonSignature(room.polygon) - return { - id: `space-${levelId}-${signature.slice(0, 12)}`, - levelId, - polygon: room.polygon.map(pointToTuple), - wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))], - boundaryFaces: room.boundaryFaces, - isExterior: false, - } -} - -type RoomSurface = SlabNodeType | CeilingNodeType - -function surfaceTouchesRooms(surface: RoomSurface, rooms: ExtractedRoom[]) { - const polygon = surface.polygon.map(pointFromTuple) - return rooms.some( - (room) => - polygonCoverageRatio(polygon, [room.polygon]) > 0 || - polygonCoverageRatio(room.polygon, [polygon]) > 0, - ) -} - -function roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { - const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId)) - const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId)) - const sharedWallCount = [...currentIds].filter((wallId) => beforeIds.has(wallId)).length - const smallerBoundarySize = Math.min(beforeIds.size, currentIds.size) - if (sharedWallCount >= 2 && sharedWallCount >= Math.ceil(smallerBoundarySize / 2)) return true - if (bboxOverlapArea(bboxOf(beforeRoom.polygon), bboxOf(currentRoom.polygon)) <= 1e-6) return false - return ( - polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 || - polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0 - ) -} - -function roomHasAutoSurface(room: ExtractedRoom, surfaces: RoomSurface[]) { - return matchesManualFootprint( - room.polygon, - surfaces - .filter((surface) => surface.autoFromWalls) - .map((surface) => surface.polygon.map(pointFromTuple)), - ) -} - -function roomsEligibleForAutoSurface( - beforeRooms: ExtractedRoom[], - currentRooms: ExtractedRoom[], - currentSurfaces: RoomSurface[], -) { - return currentRooms.filter((currentRoom) => { - const related = beforeRooms.flatMap((beforeRoom) => { - if (!roomsAreRelated(beforeRoom, currentRoom)) return [] - return [ - { - room: beforeRoom, - coverage: polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]), - }, - ] - }) - const maxCoverage = Math.max(0, ...related.map(({ coverage }) => coverage)) - const predecessors = - currentRooms.length >= beforeRooms.length && maxCoverage > 0 - ? related.filter(({ coverage }) => coverage >= maxCoverage - 1e-6).map(({ room }) => room) - : related.map(({ room }) => room) - if (predecessors.length === 0) return true - return predecessors.every((beforeRoom) => roomHasAutoSurface(beforeRoom, currentSurfaces)) - }) -} - -function detectedRoomsByLevel(nodes: Record) { - const wallsByLevel = new Map() - for (const node of Object.values(nodes)) { - if (node?.type !== 'wall' || !node.parentId) continue - const walls = wallsByLevel.get(node.parentId) ?? [] - walls.push(node) - wallsByLevel.set(node.parentId, walls) - } - return new Map( - [...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)] as const), - ) -} - -type SceneNodes = Record - -function levelChildren(nodes: SceneNodes, levelId: string) { - const level = nodes[levelId] - if (level?.type !== 'level') return [] - return level.children.flatMap((id: string) => { - const node = nodes[id] - return node ? [node] : [] - }) -} - -function changedWallIdsByLevel( - before: SceneNodes, - current: SceneNodes, - candidateIds?: ReadonlySet, -) { - const changes = new Map>() - const wallIds = new Set(candidateIds) - if (!candidateIds) { - for (const node of Object.values(before)) { - if (node?.type === 'wall') wallIds.add(node.id) - } - for (const node of Object.values(current)) { - if (node?.type === 'wall') wallIds.add(node.id) - } - } - - const markChanged = (levelId: string | null | undefined, wallId: string) => { - if (!levelId) return - const ids = changes.get(levelId) ?? new Set() - ids.add(wallId) - changes.set(levelId, ids) - } - - for (const wallId of wallIds) { - const previous = before[wallId]?.type === 'wall' ? (before[wallId] as WallNode) : null - const next = current[wallId]?.type === 'wall' ? (current[wallId] as WallNode) : null - if (previous === next) continue - markChanged(previous?.parentId, wallId) - markChanged(next?.parentId, wallId) - } - - return changes -} - -function descendantLevelIds(nodes: SceneNodes, rootId: string) { - const levelIds = new Set() - const queue = [rootId] - const visited = new Set() - while (queue.length > 0) { - const id = queue.pop()! - if (visited.has(id)) continue - visited.add(id) - const node = nodes[id] - if (!node) continue - if (node.type === 'level') levelIds.add(node.id) - if ('children' in node && Array.isArray(node.children)) queue.push(...node.children) - } - return levelIds -} - -function fallbackLevelIdsForCandidates( - before: SceneNodes, - current: SceneNodes, - candidateIds: ReadonlySet, -) { - const levelIds = new Set() - const addLevelAndLower = (levelId: string | null | undefined, nodes: SceneNodes) => { - if (!levelId) return - levelIds.add(levelId) - const lower = getLevelBelow(levelId, nodes) - if (lower) levelIds.add(lower.id) - } - - for (const id of candidateIds) { - for (const nodes of [before, current]) { - const node = nodes[id] - if (!node) continue - if (node.type === 'level' || node.type === 'building' || node.type === 'site') { - for (const levelId of descendantLevelIds(nodes, node.id)) levelIds.add(levelId) - } else if (node.type === 'slab') { - addLevelAndLower(node.parentId, nodes) - } else if (node.type === 'zone') { - if (node.parentId) levelIds.add(node.parentId) - } - } - } - return levelIds -} - -function sameStringSet(a: readonly string[], b: readonly string[]) { - if (a.length !== b.length) return false - const right = new Set(b) - return a.every((value) => right.has(value)) -} - -type AutoSurfaceMatch = { - detectedAll: DetectedRoom[] - detected: DetectedRoom[] - existingAuto: TSurface[] - compatibleMergesByRoomIndex: Map - matchedDetectedIndices: Set - roomIndexBySurfaceId: Map - sourceSurfaceIdByRoomIndex: Map - polygonBySurfaceId: Map> - delete: Array - demote: Array<{ id: TSurface['id']; data: Partial }> -} - -function matchAutoSurfaces( - roomPolygons: Point2D[][], - existingSurfaces: TSurface[], - mergeSettingsSignature: (surface: TSurface) => string, -): AutoSurfaceMatch { - const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls) - const manualSignatures = new Set( - manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))), - ) - const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple)) - const detectedAll: DetectedRoom[] = roomPolygons - .map((poly) => ({ - poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( - pointFromTuple, - ), - sig: '', - centroid: { x: 0, y: 0 }, - area: 0, - bbox: bboxOf([]), - })) - .map((room) => ({ - ...room, - sig: polygonSignature(room.poly), - centroid: polygonCentroid(room.poly), - area: Math.abs(polygonArea(room.poly)), - bbox: bboxOf(room.poly), - })) - const detected = detectedAll.filter( - ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), - ) - const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls) - const metadata = existingAuto.map((surface) => { - const poly = surface.polygon.map(pointFromTuple) - return { - surface, - sig: polygonSignature(poly), - centroid: polygonCentroid(poly), - area: Math.abs(polygonArea(poly)), - bbox: bboxOf(poly), - } - }) - - const conflictingSurfaceIds = new Set() - const conflictingRoomIndices = new Set() - const compatibleMergesByRoomIndex = new Map() - detected.forEach((room, roomIndex) => { - const contributors = existingAuto.filter( - (surface) => - polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >= - ORPHAN_MERGE_COVERAGE_THRESHOLD, - ) - if (contributors.length < 2) return - if (new Set(contributors.map(mergeSettingsSignature)).size > 1) { - conflictingRoomIndices.add(roomIndex) - for (const surface of contributors) conflictingSurfaceIds.add(surface.id) - return - } - compatibleMergesByRoomIndex.set(roomIndex, contributors) - }) - - const matchedSurfaceIds = new Set() - const matchedDetectedIndices = new Set() - const roomIndexBySurfaceId = new Map() - const sourceSurfaceIdByRoomIndex = new Map() - const polygonBySurfaceId = new Map>() - const autoBySignature = new Map>() - for (const entry of metadata) { - const bucket = autoBySignature.get(entry.sig) ?? [] - bucket.push(entry) - autoBySignature.set(entry.sig, bucket) - } - - detected.forEach((room, index) => { - if (conflictingRoomIndices.has(index)) { - matchedDetectedIndices.add(index) - return - } - const existing = autoBySignature.get(room.sig)?.shift() - if (!existing) return - matchedDetectedIndices.add(index) - matchedSurfaceIds.add(existing.surface.id) - roomIndexBySurfaceId.set(existing.surface.id, index) - sourceSurfaceIdByRoomIndex.set(index, existing.surface.id) - polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple)) - }) - - const remainingDetected = detected - .map((room, index) => ({ room, index })) - .filter(({ index }) => !matchedDetectedIndices.has(index)) - .sort((left, right) => right.room.area - left.room.area) - const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id)) - - for (const { room, index } of remainingDetected) { - let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null - for (const entry of remainingAuto) { - if (matchedSurfaceIds.has(entry.surface.id)) continue - const distance = Math.hypot( - room.centroid.x - entry.centroid.x, - room.centroid.y - entry.centroid.y, - ) - const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999 - const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio))) - if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5) continue - const score = distance + areaPenalty * 0.35 - if (!bestMatch || score < bestMatch.score) bestMatch = { entry, score } - } - if (!bestMatch) continue - matchedDetectedIndices.add(index) - matchedSurfaceIds.add(bestMatch.entry.surface.id) - roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index) - sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id) - polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple)) - } - - detected.forEach((room, index) => { - if (sourceSurfaceIdByRoomIndex.has(index)) return - let bestSource: { id: string; coverage: number } | null = null - for (const entry of metadata) { - const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]) - if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue - bestSource = { id: entry.surface.id, coverage } - } - if (bestSource) sourceSurfaceIdByRoomIndex.set(index, bestSource.id) - }) - - const detectedRoomPolygons = detectedAll.map((room) => room.poly) - const deleted: Array = [] - const demote: AutoSurfaceMatch['demote'] = [] - for (const surface of existingAuto) { - if (polygonBySurfaceId.has(surface.id)) continue - if (conflictingSurfaceIds.has(surface.id)) { - demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) - continue - } - const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons) - if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) deleted.push(surface.id) - else demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) - } - - return { - detectedAll, - detected, - existingAuto, - compatibleMergesByRoomIndex, - matchedDetectedIndices, - roomIndexBySurfaceId, - sourceSurfaceIdByRoomIndex, - polygonBySurfaceId, - delete: deleted, - demote, - } -} - -export function planAutoZonesForLevel( - spaces: readonly Space[], - existingZones: readonly ZoneNodeType[], -): AutoZoneSyncPlan { - const update: AutoZoneSyncPlan['update'] = [] - - for (const zone of existingZones) { - const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple)) - const matchingSpace = - zone.autoFromWalls && zone.boundaryWallIds.length >= 3 - ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds)) - : spaces.find( - (space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature, - ) - if (!matchingSpace) continue - - const data: Partial = {} - if (!zone.autoFromWalls) data.autoFromWalls = true - if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) { - data.boundaryWallIds = matchingSpace.wallIds - } - if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) { - data.polygon = matchingSpace.polygon - } - if (Object.keys(data).length > 0) update.push({ id: zone.id, data }) - } - - return { update } -} - -export function resolveAutoZonePolygon( - zone: Pick, - resolve: (id: AnyNodeId) => unknown, -): ZoneNodeType['polygon'] { - if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3) return zone.polygon - const walls = zone.boundaryWallIds.flatMap((id) => { - const node = resolve(id) - return node && typeof node === 'object' && 'type' in node && node.type === 'wall' - ? [node as WallNode] - : [] - }) - if (walls.length !== zone.boundaryWallIds.length) return zone.polygon - const room = extractRooms(walls).find((candidate) => - sameStringSet( - [...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], - zone.boundaryWallIds, - ), - ) - return room ? room.polygon.map(pointToTuple) : zone.polygon -} - -export function planAutoSlabsForLevel( - roomPolygons: Point2D[][], - existingSlabs: SlabNodeType[], - context: AutoSlabPlanningContext = {}, - namingSlabs: Array<{ name?: string }> = existingSlabs, -): AutoSlabSyncPlan { - const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature) - const { - detected, - existingAuto, - compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, - matchedDetectedIndices: matchedDetectedIdx, - roomIndexBySurfaceId: roomIndexBySlabId, - sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, - delete: slabsToDelete, - demote: slabDemotions, - } = match - const updatesById = new Map< - string, - { polygon: [number, number][]; elevation: number | undefined } - >() - for (const slab of existingAuto) { - const polygon = match.polygonBySurfaceId.get(slab.id) - if (!polygon) continue - updatesById.set(slab.id, { - polygon, - elevation: slabElevationForReconciledRoom(slab, polygon, context), - }) - } - - const openingAssignmentsBySlabId = new Map>() - for (const slab of existingAuto) { - const roomIndices = [...sourceSlabIdByRoomIndex.entries()] - .filter(([, slabId]) => slabId === slab.id) - .map(([roomIndex]) => roomIndex) - if (roomIndices.length === 0) continue - openingAssignmentsBySlabId.set(slab.id, partitionSurfaceOpenings(slab, roomIndices, detected)) - } - - const slabsToUpdate = [ - ...existingAuto - .filter((slab) => updatesById.has(slab.id)) - .flatMap((slab) => { - const update = updatesById.get(slab.id) - if (!update) return [] - const roomIndex = roomIndexBySlabId.get(slab.id) - const openings = - roomIndex == null - ? { holes: slab.holes, holeMetadata: slab.holeMetadata } - : compatibleMergeSlabsByRoomIndex.has(roomIndex) - ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? []) - : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - }) - const data: Partial = {} - if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon - if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes - if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { - data.holeMetadata = openings.holeMetadata - } - if ( - update.elevation !== undefined && - Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON - ) { - data.elevation = update.elevation - } - return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [] - }), - ...slabDemotions, - ] - - const plannedSlabsForNaming: Array<{ name?: string }> = [...namingSlabs] - const slabsToCreate: SlabNodeType[] = [] - for (let index = 0; index < detected.length; index += 1) { - if (matchedDetectedIdx.has(index)) continue - - const room = detected[index] - if (!room) continue - - const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') - plannedSlabsForNaming.push({ name }) - - const polygon = room.poly.map(pointToTuple) - const sourceId = sourceSlabIdByRoomIndex.get(index) - const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined - const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined - const elevation = source - ? slabElevationForReconciledRoom(source, polygon, context) - : context.elevationForRoom?.(polygon) - slabsToCreate.push( - SlabNode.parse({ - name, - polygon, - holes: openings?.holes ?? [], - holeMetadata: openings?.holeMetadata ?? [], - elevation: - elevation !== undefined && Number.isFinite(elevation) - ? elevation - : DEFAULT_AUTO_SLAB_ELEVATION, - thickness: source?.thickness, - recessed: source?.recessed, - recessedRimElevation: source?.recessedRimElevation, - fillToTerrain: source?.fillToTerrain, - material: source?.material, - materialPreset: source?.materialPreset, - slots: source?.slots, - visible: source?.visible, - autoFromWalls: true, - }), - ) - } - - return { - create: slabsToCreate, - update: slabsToUpdate, - delete: slabsToDelete, - } -} - -function syncAutoSlabsForLevel( - levelId: string, - roomPolygons: Point2D[][], - existingSlabs: SlabNodeType[], - sceneStore: any, - context: AutoSlabPlanningContext = {}, - namingSlabs: Array<{ name?: string }> = existingSlabs, -) { - const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs) - - if (plan.delete.length > 0) { - sceneStore.getState().deleteNodes(plan.delete) - } - - if (plan.update.length > 0) { - sceneStore.getState().updateNodes(plan.update) - } - - if (plan.create.length > 0) { - sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) - } - - return plan -} - -export function planAutoCeilingsForLevel( - roomPolygons: Point2D[][], - existingCeilings: CeilingNodeType[], - context: AutoCeilingPlanningContext = {}, - namingCeilings: Array<{ name?: string }> = existingCeilings, -): AutoCeilingSyncPlan { - const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) - const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature) - const { - detected, - existingAuto, - compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, - matchedDetectedIndices: matchedDetectedIdx, - roomIndexBySurfaceId: roomIndexByCeilingId, - sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, - delete: ceilingsToDelete, - demote: ceilingDemotions, - } = match - const updatesById = new Map() - for (const ceiling of existingAuto) { - const polygon = match.polygonBySurfaceId.get(ceiling.id) - if (!polygon) continue - updatesById.set(ceiling.id, { - polygon, - height: ceilingHeightForReconciledRoom(ceiling, polygon, context), - }) - } - - // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab - // created, moved, or thickened on the level above can leave an EXISTING - // manual explicit-height ceiling poking into its solid. Clamp explicit - // heights down to the bound; never raise them — a user-lowered ceiling - // is intent, only an over-bound one is a conflict. Follows-mode - // ceilings (absent height) derive under the bound by construction and - // are skipped, so the clamp can never convert one to an explicit - // height. - const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { - if (ceiling.height == null) return [] - const bound = resolveCeilingClampBound(ceiling.polygon, context) - if (!Number.isFinite(bound)) return [] - return ceiling.height > bound + CEILING_HEIGHT_EPSILON - ? [{ id: ceiling.id, data: { height: bound } }] - : [] - }) - - const openingAssignmentsByCeilingId = new Map< - string, - ReturnType - >() - const childAssignmentsByCeilingId = new Map>() - for (const ceiling of existingAuto) { - const roomIndices = [...sourceCeilingIdByRoomIndex.entries()] - .filter(([, ceilingId]) => ceilingId === ceiling.id) - .map(([roomIndex]) => roomIndex) - if (roomIndices.length === 0) continue - openingAssignmentsByCeilingId.set( - ceiling.id, - partitionSurfaceOpenings(ceiling, roomIndices, detected), - ) - childAssignmentsByCeilingId.set( - ceiling.id, - partitionCeilingChildren( - ceiling, - roomIndices, - detected, - roomIndexByCeilingId.get(ceiling.id), - context.childPosition, - ), - ) - } - - const childReparents: AutoCeilingSyncPlan['reparent'] = [] - const ceilingsToUpdate = [ - ...existingAuto - .filter((ceiling) => updatesById.has(ceiling.id)) - .flatMap((ceiling) => { - const update = updatesById.get(ceiling.id) - if (!update) return [] - const roomIndex = roomIndexByCeilingId.get(ceiling.id) - const openings = - roomIndex == null - ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata } - : compatibleMergeCeilingsByRoomIndex.has(roomIndex) - ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? []) - : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { - holes: [], - holeMetadata: [], - }) - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon - if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes - if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { - data.holeMetadata = openings.holeMetadata - } - const mergeContributors = - roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex) - const children = mergeContributors - ? ([ - ...new Set(mergeContributors.flatMap((contributor) => contributor.children)), - ] as CeilingNodeType['children']) - : roomIndex == null - ? ceiling.children - : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children) - for (const contributor of mergeContributors ?? []) { - if (contributor.id === ceiling.id) continue - for (const childId of contributor.children) { - childReparents.push({ id: childId, parentId: ceiling.id }) - } - } - if (!sameStringSet(ceiling.children, children)) data.children = children - if ( - update.height !== undefined && - (ceiling.height === undefined || - Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON) - ) { - data.height = update.height - } - return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [] - }), - ...ceilingDemotions, - ...manualClamps, - ] - - const plannedCeilingsForNaming: Array<{ name?: string }> = [...namingCeilings] - const ceilingsToCreate: CeilingNodeType[] = [] - for (let index = 0; index < detected.length; index += 1) { - if (matchedDetectedIdx.has(index)) continue - - const room = detected[index] - if (!room) continue - - const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') - plannedCeilingsForNaming.push({ name }) - - const polygon = room.poly.map(pointToTuple) - const sourceId = sourceCeilingIdByRoomIndex.get(index) - const source = sourceId ? existingAuto.find((ceiling) => ceiling.id === sourceId) : undefined - const openings = sourceId ? openingAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined - const children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined - const height = source - ? ceilingHeightForReconciledRoom(source, polygon, context) - : context.heightForRoom?.(polygon) - const created = CeilingNode.parse({ - name, - polygon, - children: children ?? [], - holes: openings?.holes ?? [], - holeMetadata: openings?.holeMetadata ?? [], - material: source?.material, - materialPreset: source?.materialPreset, - slots: source?.slots, - visible: source?.visible, - ...(height !== undefined && Number.isFinite(height) ? { height } : {}), - autoFromWalls: true, - }) - ceilingsToCreate.push(created) - for (const childId of children ?? []) { - childReparents.push({ id: childId, parentId: created.id }) - } - } - - return { - create: ceilingsToCreate, - update: ceilingsToUpdate, - delete: ceilingsToDelete, - reparent: childReparents, - } -} - -function syncAutoCeilingsForLevel( - levelId: string, - roomPolygons: Point2D[][], - existingCeilings: CeilingNodeType[], - sceneStore: any, - context: AutoCeilingPlanningContext = {}, - namingCeilings: Array<{ name?: string }> = existingCeilings, -) { - const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings) - - if (plan.update.length > 0) { - sceneStore.getState().updateNodes(plan.update) - } - - if (plan.create.length > 0) { - sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) - } - - if (plan.reparent.length > 0) { - sceneStore - .getState() - .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } }))) - } - - if (plan.delete.length > 0) { - sceneStore.getState().deleteNodes(plan.delete) - } -} - -function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { - const rooms = extractRooms(walls) - const roomPolygons = rooms.map((room) => room.polygon) - const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ - wallId: wall.id, - ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< - WallSideUpdate, - 'frontSide' | 'backSide' - >), - })) - - return { - rooms, - roomPolygons, - spaces: rooms.map((room) => buildSpace(levelId, room)), - wallUpdates, - } -} - -export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { - return detectSpacesFromWalls(levelId, walls) -} - -function runSpaceDetection( - levelIds: string[], - sceneStore: any, - editorStore: any, - nodes: any, - previousNodes: any, - previousRoomsByLevel: Map, -): void { - const { updateNodes } = sceneStore.getState() - const existingSpaces = editorStore.getState().spaces as Record - const nextSpaces: Record = {} - - for (const [spaceId, space] of Object.entries(existingSpaces)) { - if (!levelIds.includes(space.levelId)) { - nextSpaces[spaceId] = space - } - } - - for (const levelId of levelIds) { - const children = levelChildren(nodes, levelId) - const walls = children.filter( - (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, - ) - const slabs = children.filter((node: any) => node?.type === 'slab') - const ceilings = children.filter((node: any) => node?.type === 'ceiling') - const zones = children.filter((node: any) => node?.type === 'zone') - - const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls) - - const changedWallUpdates = wallUpdates.filter((update) => { - const wall = nodes[update.wallId] - return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) - }) - - if (changedWallUpdates.length > 0) { - updateNodes( - changedWallUpdates.map((update) => ({ - id: update.wallId, - data: { - frontSide: update.frontSide, - backSide: update.backSide, - }, - })), - ) - } - - const levelNode = nodes[levelId] - const storeyHeight = - levelNode?.type === 'level' - ? getStoredLevelHeight(levelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const parsedSlabs: SlabNodeType[] = slabs.map((slab: any) => SlabNode.parse(slab)) - const parsedCeilings: CeilingNodeType[] = ceilings.map((ceiling: any) => - CeilingNode.parse(ceiling), - ) - const previousRooms = previousRoomsByLevel.get(levelId) ?? [] - const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs) - const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings) - const verticalPlacements = autoRoomVerticalPlacements( - spaces, - walls, - // A derived floor cannot be evidence for its own next elevation: that - // would lift unpinned walls, then lift the floor again on every pass. - parsedSlabs.filter((slab) => !slab.autoFromWalls), - nodes, - storeyHeight, - ) - const previousChildren = levelChildren(previousNodes, levelId) - const previousWalls = previousChildren.filter( - (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, - ) - const previousSlabs: SlabNodeType[] = previousChildren - .filter((node: any) => node?.type === 'slab') - .map((slab: any) => SlabNode.parse(slab)) - const previousLevelNode = previousNodes[levelId] - const previousStoreyHeight = - previousLevelNode?.type === 'level' - ? getStoredLevelHeight(previousLevelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces - const previousVerticalPlacements = autoRoomVerticalPlacements( - previousSpaces, - previousWalls, - previousSlabs.filter((slab) => !slab.autoFromWalls), - previousNodes, - previousStoreyHeight, - ) - const placementFor = (polygon: Array<[number, number]>) => - verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - const previousPlacementFor = (polygon: Array<[number, number]>) => - previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - syncAutoSlabsForLevel( - levelId, - slabRooms.map((room) => room.polygon), - parsedSlabs, - sceneStore, - { - elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, - previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, - }, - ) - syncAutoCeilingsForLevel( - levelId, - ceilingRooms.map((room) => room.polygon), - parsedCeilings, - sceneStore, - { - storeyHeight, - ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), - heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, - previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, - childPosition: (childId) => { - const child = nodes[childId] - return child && Array.isArray(child.position) - ? [child.position[0], child.position[2]] - : undefined - }, - }, - ) - const zonePlan = planAutoZonesForLevel( - spaces, - zones.map((zone: any) => ZoneNode.parse(zone)), - ) - if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - - for (const space of spaces) { - nextSpaces[space.id] = space - } - previousRoomsByLevel.set(levelId, rooms) - } - - editorStore.getState().setSpaces(nextSpaces) -} - -function runIndexedSpaceDetection( - levelId: string, - topologyDelta: IndexedTopologyDelta, - sceneStore: any, - editorStore: any, - nodes: SceneNodes, - previousNodes: SceneNodes, -) { - const { updateNodes } = sceneStore.getState() - const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) - const changedWallUpdates = topologyDelta.currentWalls - .map((wall) => ({ - wallId: wall.id, - ...resolveWallSurfaceSides(wall, allRoomPolygons), - })) - .filter((update) => { - const wall = nodes[update.wallId] - return ( - wall?.type === 'wall' && - (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) - ) - }) - if (changedWallUpdates.length > 0) { - updateNodes( - changedWallUpdates.map((update) => ({ - id: update.wallId, - data: { frontSide: update.frontSide, backSide: update.backSide }, - })), - ) - } - - const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] - if (scopedRooms.length > 0) { - const unaffectedRooms = topologyDelta.allCurrentRooms.filter( - (room) => !topologyDelta.currentRooms.includes(room), - ) - const currentChildren = levelChildren(nodes, levelId) - const allSlabs: SlabNodeType[] = currentChildren - .filter((node: any): node is SlabNodeType => node.type === 'slab') - .map((slab: SlabNodeType) => SlabNode.parse(slab)) - const allCeilings: CeilingNodeType[] = currentChildren - .filter((node: any): node is CeilingNodeType => node.type === 'ceiling') - .map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling)) - const slabs = allSlabs.filter( - (slab) => - surfaceTouchesRooms(slab, scopedRooms) && - (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)), - ) - const ceilings = allCeilings.filter( - (ceiling) => - surfaceTouchesRooms(ceiling, scopedRooms) && - (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)), - ) - const slabRooms = roomsEligibleForAutoSurface( - topologyDelta.beforeRooms, - topologyDelta.currentRooms, - slabs, - ) - const ceilingRooms = roomsEligibleForAutoSurface( - topologyDelta.beforeRooms, - topologyDelta.currentRooms, - ceilings, - ) - const levelNode = nodes[levelId] - const storeyHeight = - levelNode?.type === 'level' - ? getStoredLevelHeight(levelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room)) - const verticalPlacements = autoRoomVerticalPlacements( - currentSpaces, - topologyDelta.currentWalls, - allSlabs.filter((slab) => !slab.autoFromWalls), - nodes, - storeyHeight, - ) - const previousChildren = levelChildren(previousNodes, levelId) - const previousSlabs: SlabNodeType[] = previousChildren - .filter((node: any): node is SlabNodeType => node.type === 'slab') - .map((slab: SlabNodeType) => SlabNode.parse(slab)) - const previousLevelNode = previousNodes[levelId] - const previousStoreyHeight = - previousLevelNode?.type === 'level' - ? getStoredLevelHeight(previousLevelNode as LevelNode) - : DEFAULT_LEVEL_HEIGHT - const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room)) - const previousVerticalPlacements = autoRoomVerticalPlacements( - previousSpaces, - topologyDelta.previousWalls, - previousSlabs.filter((slab) => !slab.autoFromWalls), - previousNodes, - previousStoreyHeight, - ) - const placementFor = (polygon: Array<[number, number]>) => - verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - const previousPlacementFor = (polygon: Array<[number, number]>) => - previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) - - syncAutoSlabsForLevel( - levelId, - slabRooms.map((room) => room.polygon), - slabs, - sceneStore, - { - elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, - previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, - }, - allSlabs, - ) - syncAutoCeilingsForLevel( - levelId, - ceilingRooms.map((room) => room.polygon), - ceilings, - sceneStore, - { - storeyHeight, - ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), - heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, - previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, - childPosition: (childId) => { - const child = nodes[childId] - return child && Array.isArray(child.position) - ? [child.position[0], child.position[2]] - : undefined - }, - }, - allCeilings, - ) - } - - const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) - const zones: ZoneNodeType[] = levelChildren(nodes, levelId) - .filter((node: any): node is ZoneNodeType => node.type === 'zone') - .map((zone: ZoneNodeType) => ZoneNode.parse(zone)) - const zonePlan = planAutoZonesForLevel(spaces, zones) - if (zonePlan.update.length > 0) updateNodes(zonePlan.update) - - const existingSpaces = editorStore.getState().spaces as Record - const nextSpaces: Record = {} - for (const [spaceId, space] of Object.entries(existingSpaces)) { - if (space.levelId !== levelId) nextSpaces[spaceId] = space - } - for (const space of spaces) nextSpaces[space.id] = space - editorStore.getState().setSpaces(nextSpaces) -} - -// Refcount of outstanding pause requests, matching the pauseSceneHistory -// pattern. The community editor flips this off while the AI is actively -// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race -// `create_room`'s explicit slabs/ceilings (see plan -// `ai-pause-space-detection`). -let spaceDetectionPauseDepth = 0 - -/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */ -export function pauseSpaceDetection(): void { - spaceDetectionPauseDepth += 1 -} - -/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */ -export function resumeSpaceDetection(): void { - if (spaceDetectionPauseDepth === 0) return - spaceDetectionPauseDepth -= 1 -} - -/** True iff the wall-driven auto slab/ceiling sync is currently paused. */ -export function isSpaceDetectionPaused(): boolean { - return spaceDetectionPauseDepth > 0 -} - -export function initSpaceDetectionSync( - sceneStore: any, - editorStore: any, - options: SpaceDetectionSyncOptions = {}, -): () => void { - // Baseline from whatever is already in the store. Detection reacts to wall - // edits made IN-SESSION (create / move / delete); it must not re-litigate a - // scene that merely loaded — rerunning on hydration resurrected auto slabs - // the user had deleted in an earlier session. - const initialNodes = sceneStore.getState().nodes - const previousRoomsByLevel = new Map() - const topologyIndex = new RoomTopologyIndex({ - detectRooms: extractRooms, - sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple), - junctionTolerance: WALL_JUNCTION_TOLERANCE, - }) - let previousNodes = initialNodes - let isProcessing = false - - const adoptSceneBaseline = (nodes: SceneNodes) => { - topologyIndex.rebuild(nodes) - const roomsByLevel = detectedRoomsByLevel(nodes) - previousRoomsByLevel.clear() - const spaces: Record = {} - for (const [levelId, rooms] of roomsByLevel) { - previousRoomsByLevel.set(levelId, rooms) - for (const room of rooms) { - const space = buildSpace(levelId, room) - spaces[space.id] = space - } - } - editorStore.getState().setSpaces(spaces) - previousNodes = nodes - } - - adoptSceneBaseline(initialNodes) - - const unsubscribeCommits = subscribeSceneCommits((commit) => { - if (commit.origin === 'local') return - adoptSceneBaseline(commit.current.nodes) - }) - - const unsubscribe = sceneStore.subscribe((state: any) => { - if (isProcessing) return - if (getSceneHistoryPauseDepth() > 0) return - - const nodes = state.nodes - const candidateIds = activeSceneCommitNodeIds() - - // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) - // every paused change once detection resumes. Whatever the AI built while - // paused becomes the new baseline; only future changes will reconcile. - if (spaceDetectionPauseDepth > 0) { - adoptSceneBaseline(nodes) - return - } - - const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds) - if (candidateIds && changedWalls.size > 0) { - const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds) - for (const levelId of changedWalls.keys()) fallbackLevels.delete(levelId) - isProcessing = true - pauseSceneHistory(sceneStore) - try { - for (const [levelId, wallIds] of changedWalls) { - const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes) - runIndexedSpaceDetection( - levelId, - topologyDelta, - sceneStore, - editorStore, - nodes, - previousNodes, - ) - previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms) - options.onTopologyReconcile?.({ - levelId, - strategy: topologyDelta.strategy, - examinedWallIds: topologyDelta.examinedWallIds, - affectedBeforeRoomCount: topologyDelta.beforeRooms.length, - affectedCurrentRoomCount: topologyDelta.currentRooms.length, - }) - } - if (fallbackLevels.size > 0) { - runSpaceDetection( - [...fallbackLevels], - sceneStore, - editorStore, - sceneStore.getState().nodes, - previousNodes, - previousRoomsByLevel, - ) - const liveNodes = sceneStore.getState().nodes - for (const levelId of fallbackLevels) topologyIndex.rebuildLevel(levelId, liveNodes) - } - } finally { - resumeSceneHistory(sceneStore) - previousNodes = sceneStore.getState().nodes - isProcessing = false - } - return - } - - const levelsToUpdate = new Set() - if (candidateIds) { - for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) { - levelsToUpdate.add(levelId) - } - } else { - const previousSnapshots = levelStructureSnapshots(previousNodes) - const currentSnapshots = levelStructureSnapshots(nodes) - for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { - // First sight of a level is a hydration baseline, not a wall edit — - // `setScene` delivers a loaded scene as one atomic update, and a level's - // first wall can't close a room anyway. Record it (below) and only - // react to subsequent changes. - const previous = previousSnapshots.get(levelId) - if (previous === undefined) continue - if (previous !== (currentSnapshots.get(levelId) ?? '')) { - levelsToUpdate.add(levelId) - } - } - } - - if (levelsToUpdate.size === 0) { - if (candidateIds) { - previousNodes = nodes - return - } - const currentRoomsByLevel = detectedRoomsByLevel(nodes) - previousRoomsByLevel.clear() - for (const [levelId, rooms] of currentRoomsByLevel) { - previousRoomsByLevel.set(levelId, rooms) - } - previousNodes = nodes - return - } - - isProcessing = true - pauseSceneHistory(sceneStore) - try { - runSpaceDetection( - [...levelsToUpdate], - sceneStore, - editorStore, - nodes, - previousNodes, - previousRoomsByLevel, - ) - } finally { - resumeSceneHistory(sceneStore) - const liveNodes = sceneStore.getState().nodes - for (const levelId of levelsToUpdate) topologyIndex.rebuildLevel(levelId, liveNodes) - previousNodes = liveNodes - isProcessing = false - } - }) - - return () => { - unsubscribe() - unsubscribeCommits() - } -} - -export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { - const threshold = 0.1 - - for (const other of otherWalls) { - if (other.id === wall.id) continue - - if ( - distanceToSegment(wall.start, other.start, other.end) < threshold || - distanceToSegment(wall.end, other.start, other.end) < threshold || - distanceToSegment(other.start, wall.start, wall.end) < threshold || - distanceToSegment(other.end, wall.start, wall.end) < threshold - ) { - return true - } - } - - return false -} +import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/support-host-id' +import { + type AnyNodeId, + CeilingNode, + type CeilingNode as CeilingNodeType, + type LevelNode, + SlabNode, + type SlabNode as SlabNodeType, + type WallNode, + ZoneNode, + type ZoneNode as ZoneNodeType, +} from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' +import { + activeSceneCommitNodeIds, + getSceneHistoryPauseDepth, + pauseSceneHistory, + resumeSceneHistory, + subscribeSceneCommits, +} from '../store/history-control' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { + getClampedWallCurveOffset, + getWallCurveFrameAt, + isCurvedWall, +} from '../systems/wall/wall-curve' +import { resolveWallTop } from '../systems/wall/wall-top' +import { simplifyClosedPolygon } from './polygon-geometry' +import { + distanceToSegment, + type IndexedTopologyDelta, + RoomTopologyIndex, +} from './room-topology-index' +import { levelBaseElevationAt } from './terrain-support' + +type Point2D = { x: number; y: number } + +export type SpaceBoundaryFace = { + wallId: WallNode['id'] + face: 'front' | 'back' + points: Array<[number, number]> +} + +export type Space = { + id: string + levelId: string + polygon: Array<[number, number]> + wallIds: Array + boundaryFaces: SpaceBoundaryFace[] + isExterior: boolean +} + +export type SpaceTopologyReconcileEvent = { + levelId: string + strategy: 'indexed' | 'fallback' + examinedWallIds: string[] + affectedBeforeRoomCount: number + affectedCurrentRoomCount: number +} + +export type SpaceDetectionSyncOptions = { + onTopologyReconcile?: (event: SpaceTopologyReconcileEvent) => void +} + +type ExtractedRoom = { + polygon: Point2D[] + boundaryFaces: SpaceBoundaryFace[] +} + +type WallSideUpdate = { + wallId: string + frontSide: 'interior' | 'exterior' | 'unknown' + backSide: 'interior' | 'exterior' | 'unknown' +} + +type DetectedRoom = { + poly: Point2D[] + sig: string + centroid: Point2D + area: number + bbox: ReturnType +} + +export type AutoSlabSyncPlan = { + create: SlabNodeType[] + update: Array<{ id: SlabNodeType['id']; data: Partial }> + delete: Array +} + +export type AutoSlabPlanningContext = { + elevationForRoom?: (polygon: Array<[number, number]>) => number | undefined + previousElevationForRoom?: (polygon: Array<[number, number]>) => number | undefined +} + +export type AutoCeilingSyncPlan = { + create: CeilingNodeType[] + update: Array<{ id: CeilingNodeType['id']; data: Partial }> + delete: Array + reparent: Array<{ id: AnyNodeId; parentId: CeilingNodeType['id'] }> +} + +export type AutoZoneSyncPlan = { + update: Array<{ id: ZoneNodeType['id']; data: Partial }> +} + +const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 +const CEILING_HEIGHT_EPSILON = 1e-6 +const ROOM_CURVE_TOLERANCE = 0.04 +const MAX_CURVE_SUBDIVISION_DEPTH = 6 +const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 +const WALL_ROOM_BOUNDARY_TOLERANCE = 0.08 +// A wall endpoint within this distance of another wall's interior is treated as a +// T-junction and splits that wall (see `splitStraightWallAtVertices`). +const WALL_JUNCTION_TOLERANCE = 0.08 +// An unmatched auto slab/ceiling whose polygon is still substantially covered +// by a detected room was absorbed by a room merge — the surviving auto surface +// owns that area, so keeping it would z-fight and it is deleted. Below this +// coverage the room genuinely ceased to exist (e.g. an enclosing wall was +// deleted) and the node is demoted to manual so user data survives. +const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 +const COVERAGE_SAMPLE_STEPS = 12 +// Rewrite deadband for an existing auto surface's elevation/height: below this +// the derived plane is the same plane and writing it would churn history. +const ROOM_VERTICAL_PLANE_EPSILON = 1e-3 + +// Pure planner callers omit `heightForRoom`, so auto ceilings keep their +// height-less level-following behavior. The live room sync supplies a height +// derived from the enclosing walls' own bases and tops. +export type AutoCeilingPlanningContext = { + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number + /** + * Stage 3-B clamp-bound resolver for a polygon on the planned level: + * `min(storey plane, lowest covering-slab underside from the level + * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent + * (pure-planner callers without a nodes record), the bound degrades to + * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. + */ + ceilingClampBound?: (polygon: Array<[number, number]>) => number + heightForRoom?: (polygon: Array<[number, number]>) => number | undefined + previousHeightForRoom?: (polygon: Array<[number, number]>) => number | undefined + childPosition?: (childId: AnyNodeId) => [number, number] | undefined +} + +function pointFromTuple(point: [number, number]): Point2D { + return { x: point[0], y: point[1] } +} + +function pointToTuple(point: Point2D): [number, number] { + return [point.x, point.y] +} + +function pointKey(point: Point2D) { + return `${point.x.toFixed(3)},${point.y.toFixed(3)}` +} + +function polygonArea(points: Point2D[]) { + let area = 0 + for (let i = 0; i < points.length; i++) { + const a = points[i] + const b = points[(i + 1) % points.length] + if (!(a && b)) continue + area += a.x * b.y - b.x * a.y + } + return area / 2 +} + +function minRotationSignature(keys: string[]) { + if (keys.length === 0) return '' + let best = '' + for (let i = 0; i < keys.length; i++) { + const rotated = [...keys.slice(i), ...keys.slice(0, i)] + const value = rotated.join('|') + if (!best || value < best) best = value + } + return best +} + +function polygonSignature(points: Point2D[]) { + const keys = points.map(pointKey) + const forward = minRotationSignature(keys) + const reversed = minRotationSignature([...keys].reverse()) + return forward < reversed ? forward : reversed +} + +function samePointWithinTolerance(a: Point2D, b: Point2D, tolerance = 1e-4) { + return Math.hypot(a.x - b.x, a.y - b.y) <= tolerance +} + +function dedupeSequentialPoints(points: Point2D[], tolerance = 1e-4) { + const deduped: Point2D[] = [] + + for (const point of points) { + const previous = deduped[deduped.length - 1] + if (previous && samePointWithinTolerance(previous, point, tolerance)) { + continue + } + deduped.push(point) + } + + const firstPoint = deduped[0] + const lastPoint = deduped[deduped.length - 1] + if ( + deduped.length > 2 && + firstPoint && + lastPoint && + samePointWithinTolerance(firstPoint, lastPoint, tolerance) + ) { + deduped.pop() + } + + return deduped +} + +function pointInPolygon(point: Point2D, polygon: Point2D[]) { + if (polygon.length < 3) return false + + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i]?.x ?? 0 + const yi = polygon[i]?.y ?? 0 + const xj = polygon[j]?.x ?? 0 + const yj = polygon[j]?.y ?? 0 + + const intersect = + yi > point.y !== yj > point.y && + point.x < ((xj - xi) * (point.y - yi)) / (yj - yi + 1e-12) + xi + if (intersect) inside = !inside + } + + return inside +} + +function pointInAnyPolygon(point: Point2D, polygons: Point2D[][]) { + return polygons.some((polygon) => pointInPolygon(point, polygon)) +} + +function polygonCentroid(points: Point2D[]) { + const sum = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { + x: 0, + y: 0, + }) + + return { + x: sum.x / Math.max(points.length, 1), + y: sum.y / Math.max(points.length, 1), + } +} + +function bboxOf(points: Point2D[]) { + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + } + + return { minX, minY, maxX, maxY } +} + +function bboxOverlapArea(a: ReturnType, b: ReturnType) { + const ix = Math.max(0, Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX)) + const iy = Math.max(0, Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY)) + return ix * iy +} + +// Fraction of `subject`'s area lying inside any of `covers`, estimated by +// sampling a grid of cell centers over the subject's bbox. Cheap and robust +// enough for the merge-vs-demote decision; exact polygon clipping would be a +// heavy dependency for a 60% threshold. +function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) { + if (subject.length < 3 || covers.length === 0) return 0 + + const bbox = bboxOf(subject) + const width = bbox.maxX - bbox.minX + const height = bbox.maxY - bbox.minY + + let inside = 0 + let covered = 0 + for (let i = 0; i < COVERAGE_SAMPLE_STEPS; i += 1) { + for (let j = 0; j < COVERAGE_SAMPLE_STEPS; j += 1) { + const point = { + x: bbox.minX + ((i + 0.5) / COVERAGE_SAMPLE_STEPS) * width, + y: bbox.minY + ((j + 0.5) / COVERAGE_SAMPLE_STEPS) * height, + } + if (!pointInPolygon(point, subject)) continue + inside += 1 + if (pointInAnyPolygon(point, covers)) covered += 1 + } + } + + if (inside === 0) { + return pointInAnyPolygon(polygonCentroid(subject), covers) ? 1 : 0 + } + + return covered / inside +} + +// Demoted auto surfaces keep their polygon untouched, so a re-closed room +// usually hits the exact-signature manual check. Coverage handles the rest: +// a room split across multiple manual surfaces AND a single manual surface +// spanning multiple rooms both suppress a replacement auto surface — what +// matters is that the ROOM is already substantially covered, not that any +// one manual surface belongs to it (a per-surface "mostly inside the room" +// filter dropped multi-room slabs and resurrected deleted auto slabs). +function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { + return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD +} + +function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { + let minDistance = Number.POSITIVE_INFINITY + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + if (!(start && end)) continue + minDistance = Math.min( + minDistance, + distanceToSegment(pointToTuple(point), pointToTuple(start), pointToTuple(end)), + ) + } + return minDistance +} + +function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { + const sampled = sampleWallPointsForRoomDetection(wall) + if (sampled.length === 0) return false + + const candidates = + sampled.length === 2 + ? [ + sampled[0]!, + { + x: (sampled[0]!.x + sampled[1]!.x) / 2, + y: (sampled[0]!.y + sampled[1]!.y) / 2, + }, + sampled[1]!, + ] + : sampled + + const matchingPoints = candidates.filter( + (point) => pointDistanceToPolygonBoundary(point, roomPolygon) <= WALL_ROOM_BOUNDARY_TOLERANCE, + ) + + return matchingPoints.length >= 2 +} + +/** + * The clamp bound for a ceiling polygon under this planning context — + * the context's cross-level resolver when provided, else the plane-only + * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. + */ +function resolveCeilingClampBound( + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, +) { + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN +} + +/** + * The base a boundary wall actually stands on, in level-local metres. + * + * Resolved the same way the wall renderer resolves it — the level base under + * the wall's own start point (sculpted ground or the flat plane), the slab + * election on top of that, plus the wall's stored `supportOffset`. Reading the + * ground for `GROUND_SUPPORT_ID` walls only is what left stamped room presets + * flat: `resolveWallSupportSlabPatch` writes no host at all for a wall on bare + * terrain, so the sentinel is a hint about pointer intent, never a precondition + * for standing on the ground. + */ +function boundaryWallBase( + wall: WallNode, + walls: WallNode[], + supportSlabs: readonly SlabNodeType[], + nodes: Record, + levelId: string, +): number { + const levelBase = levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]) + const offset = wall.supportOffset ?? 0 + if (wall.supportSlabId === GROUND_SUPPORT_ID) return levelBase + offset + return ( + computeWallSlabSupport(wall, supportSlabs, walls, wall.supportSlabId ?? null, null, levelBase) + .elevation + offset + ) +} + +/** + * The plane an auto floor/ceiling takes when its enclosing walls disagree. + * + * `floor` takes the HIGHEST wall base and `ceiling` the LOWEST wall top — + * the only pair that cannot open a hole: a floor at the lowest base would + * leave daylight under every wall standing higher, and a ceiling at the + * highest top would poke out through the shortest wall. Both surfaces stay + * flat (a slab is one scalar elevation by schema; see `vertical-model.md`), + * so a room on a slope is a level room cut into the hillside — the walls on + * the low side extend down to meet it, which is what their `baseSegments` + * fill-down already does. + * + * Non-finite inputs are the one abstain: a broken graph should keep the + * existing placement rather than move a surface to NaN. + */ +function roomFloorPlane(wallBases: number[]): number | undefined { + if (wallBases.length === 0 || wallBases.some((value) => !Number.isFinite(value))) return undefined + return Math.max(...wallBases) +} + +function roomCeilingPlane(wallTops: number[]): number | undefined { + if (wallTops.length === 0 || wallTops.some((value) => !Number.isFinite(value))) return undefined + return Math.min(...wallTops) +} + +function autoRoomVerticalPlacements( + spaces: readonly Space[], + walls: WallNode[], + supportSlabs: readonly SlabNodeType[], + nodes: Record, + storeyHeight: number, +) { + const wallsById = new Map(walls.map((wall) => [wall.id, wall])) + const placements = new Map() + + for (const space of spaces) { + const boundaryWalls = space.wallIds.flatMap((id) => { + const wall = wallsById.get(id) + return wall ? [wall] : [] + }) + if (boundaryWalls.length !== space.wallIds.length) continue + + const wallBases = boundaryWalls.map((wall) => + boundaryWallBase(wall, walls, supportSlabs, nodes, space.levelId), + ) + const base = roomFloorPlane(wallBases) + if (base === undefined) continue + + const wallTops = boundaryWalls.flatMap((wall, index) => { + const b = wallBases[index] ?? base + return [ + resolveWallTop(wall, storeyHeight, b, 0), + resolveWallTop(wall, storeyHeight, b, 1), + ] + }) + const top = roomCeilingPlane(wallTops) + if (top === undefined) continue + + placements.set(polygonSignature(space.polygon.map(pointFromTuple)), { + slabElevation: base + DEFAULT_AUTO_SLAB_ELEVATION, + ceilingHeight: top - CEILING_CLAMP_MARGIN, + }) + } + + return placements +} + +function getWallDirection(wall: Pick) { + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-9) { + return { + point: pointFromTuple(wall.start), + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + const tangent = { x: dx / length, y: dy / length } + return { + point: { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + }, + tangent, + normal: { x: -tangent.y, y: tangent.x }, + } +} + +function pointLineDistance(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + + if (lengthSquared < 1e-9) { + return Math.hypot(point.x - start.x, point.y - start.y) + } + + const cross = (point.x - start.x) * dy - (point.y - start.y) * dx + return Math.abs(cross) / Math.sqrt(lengthSquared) +} + +function sampleWallPointsForRoomDetection( + wall: Pick, + tolerance = ROOM_CURVE_TOLERANCE, +) { + const start = { x: wall.start[0], y: wall.start[1] } + const end = { x: wall.end[0], y: wall.end[1] } + + if (!isCurvedWall(wall)) { + return [start, end] + } + + const subdivide = ( + t0: number, + p0: Point2D, + t1: number, + p1: Point2D, + depth: number, + ): Point2D[] => { + const midT = (t0 + t1) / 2 + const midPoint = getWallCurveFrameAt(wall, midT).point + const deviation = pointLineDistance(midPoint, p0, p1) + + if (depth >= MAX_CURVE_SUBDIVISION_DEPTH || deviation <= tolerance) { + return [p0, p1] + } + + const left = subdivide(t0, p0, midT, midPoint, depth + 1) + const right = subdivide(midT, midPoint, t1, p1, depth + 1) + return [...left.slice(0, -1), ...right] + } + + return subdivide(0, start, 1, end, 0) +} + +function segmentProjection(point: Point2D, start: Point2D, end: Point2D) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared < 1e-12) { + return { t: 0, distance: Math.hypot(point.x - start.x, point.y - start.y) } + } + const t = ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared + const clampedT = Math.max(0, Math.min(1, t)) + const projX = start.x + clampedT * dx + const projY = start.y + clampedT * dy + return { t, distance: Math.hypot(point.x - projX, point.y - projY) } +} + +// Break a straight wall at any junction vertex (another wall's endpoint) that +// lands on its interior, returning the ordered polyline [start, …splits, end]. +// Splitting at the *vertex* position (not the projection) keeps the split node's +// key identical to the touching wall's endpoint so the two share a graph node. +function splitStraightWallAtVertices(start: Point2D, end: Point2D, vertices: Point2D[]) { + const length = Math.hypot(end.x - start.x, end.y - start.y) + if (length < 1e-9) return [start, end] + + const interior: Array<{ point: Point2D; t: number }> = [] + for (const vertex of vertices) { + const { t, distance } = segmentProjection(vertex, start, end) + if (distance > WALL_JUNCTION_TOLERANCE) continue + const along = t * length + if (along <= WALL_JUNCTION_TOLERANCE || along >= length - WALL_JUNCTION_TOLERANCE) continue + interior.push({ point: vertex, t }) + } + interior.sort((a, b) => a.t - b.t) + + const ordered: Point2D[] = [start] + let lastKey = pointKey(start) + for (const { point } of interior) { + const key = pointKey(point) + if (key === lastKey) continue + ordered.push(point) + lastKey = key + } + if (lastKey !== pointKey(end)) ordered.push(end) + return ordered +} + +function extractRooms(walls: WallNode[]): ExtractedRoom[] { + if (walls.length < 3) return [] + + type HalfEdge = { + id: string + reverseId: string + fromKey: string + toKey: string + angle: number + points: Point2D[] + wallId: WallNode['id'] + face: 'front' | 'back' + } + type Node = { point: Point2D; outgoing: string[] } + + const graph = new Map() + const halfEdges = new Map() + + const upsertNode = (point: Point2D) => { + const key = pointKey(point) + if (!graph.has(key)) { + graph.set(key, { point: { ...point }, outgoing: [] }) + } + return key + } + + // Planarize first: collect every wall endpoint as a candidate graph vertex so + // straight walls can be split at T-junctions where another wall ends mid-span. + // Without this the touching wall's endpoint is a dangling degree-1 node and the + // enclosed area (e.g. a room added against the middle of an existing wall) + // never forms a cycle. + const vertexByKey = new Map() + for (const wall of walls) { + for (const tuple of [wall.start, wall.end]) { + const point = pointFromTuple(tuple) + const key = pointKey(point) + if (!vertexByKey.has(key)) vertexByKey.set(key, point) + } + } + const vertices = [...vertexByKey.values()] + + for (const wall of walls) { + const start = pointFromTuple(wall.start) + const end = pointFromTuple(wall.end) + if (samePointWithinTolerance(start, end)) continue + + // Curved walls keep their sampled polyline as one edge; straight walls split + // into consecutive sub-edges at their interior junction vertices. + const subPolylines: Point2D[][] = isCurvedWall(wall) + ? [sampleWallPointsForRoomDetection(wall)] + : (() => { + const ordered = splitStraightWallAtVertices(start, end, vertices) + const parts: Point2D[][] = [] + for (let index = 0; index < ordered.length - 1; index += 1) { + parts.push([ordered[index]!, ordered[index + 1]!]) + } + return parts + })() + + subPolylines.forEach((points, subIndex) => { + const from = points[0]! + const to = points[points.length - 1]! + const fromKey = upsertNode(from) + const toKey = upsertNode(to) + if (fromKey === toKey) return + + const reversePoints = [...points].reverse() + const forwardId = `${wall.id}#${subIndex}:f` + const reverseId = `${wall.id}#${subIndex}:r` + + halfEdges.set(forwardId, { + id: forwardId, + reverseId, + fromKey, + toKey, + angle: Math.atan2(points[1]!.y - from.y, points[1]!.x - from.x), + points, + wallId: wall.id, + face: 'front', + }) + halfEdges.set(reverseId, { + id: reverseId, + reverseId: forwardId, + fromKey: toKey, + toKey: fromKey, + angle: Math.atan2(reversePoints[1]!.y - to.y, reversePoints[1]!.x - to.x), + points: reversePoints, + wallId: wall.id, + face: 'back', + }) + + graph.get(fromKey)?.outgoing.push(forwardId) + graph.get(toKey)?.outgoing.push(reverseId) + }) + } + + const sortedOutgoing = new Map() + for (const [key, node] of graph.entries()) { + const outgoing = [...node.outgoing] + outgoing.sort((a, b) => (halfEdges.get(a)?.angle ?? 0) - (halfEdges.get(b)?.angle ?? 0)) + sortedOutgoing.set(key, outgoing) + } + + const nextEdge = (edgeId: string) => { + const edge = halfEdges.get(edgeId) + if (!edge) return null + + const outgoing = sortedOutgoing.get(edge.toKey) + if (!outgoing || outgoing.length === 0) return null + + const idx = outgoing.indexOf(edge.reverseId) + if (idx === -1) return null + + const nextIdx = (idx - 1 + outgoing.length) % outgoing.length + return outgoing[nextIdx] ?? null + } + + const splitIntoSimpleCycles = (walkEdgeIds: string[]) => { + const cycles: string[][] = [] + const firstEdge = halfEdges.get(walkEdgeIds[0] ?? '') + if (!firstEdge) return cycles + + const pathEdges: string[] = [] + const pathVertices = [firstEdge.fromKey] + const vertexIndex = new Map([[firstEdge.fromKey, 0]]) + + for (const edgeId of walkEdgeIds) { + const edge = halfEdges.get(edgeId) + if (!edge || edge.fromKey !== pathVertices[pathVertices.length - 1]) return [] + + pathEdges.push(edgeId) + const repeatedIndex = vertexIndex.get(edge.toKey) + if (repeatedIndex === undefined) { + pathVertices.push(edge.toKey) + vertexIndex.set(edge.toKey, pathVertices.length - 1) + continue + } + + const cycle = pathEdges.slice(repeatedIndex) + if (cycle.length >= 3) cycles.push(cycle) + + for (let index = repeatedIndex + 1; index < pathVertices.length; index += 1) { + vertexIndex.delete(pathVertices[index]!) + } + pathVertices.length = repeatedIndex + 1 + pathEdges.length = repeatedIndex + } + + return pathEdges.length === 0 && pathVertices.length === 1 ? cycles : [] + } + + const visitedDirected = new Set() + const rooms: ExtractedRoom[] = [] + // A face walk cannot revisit a half-edge, so the half-edge count bounds its + // length. It can revisit a vertex when dangling walls or other graph bridges + // are traced out and back; those excursions are removed below. + const maxSteps = Math.min(2000, halfEdges.size + 10) + + for (const edgeId of halfEdges.keys()) { + if (visitedDirected.has(edgeId)) continue + + const cycleEdgeIds: string[] = [] + let currentEdgeId = edgeId + let valid = true + let closed = false + + for (let step = 0; step < maxSteps; step += 1) { + const currentEdge = halfEdges.get(currentEdgeId) + if (!currentEdge) { + valid = false + break + } + + visitedDirected.add(currentEdgeId) + cycleEdgeIds.push(currentEdgeId) + + const next = nextEdge(currentEdgeId) + if (!next) { + valid = false + break + } + + currentEdgeId = next + if (currentEdgeId === edgeId) { + closed = true + break + } + } + + if (!(valid && closed) || cycleEdgeIds.length < 3) continue + + for (const simpleCycleEdgeIds of splitIntoSimpleCycles(cycleEdgeIds)) { + const polygon = dedupeSequentialPoints( + simpleCycleEdgeIds.flatMap((id, index) => { + const points = halfEdges.get(id)?.points ?? [] + return index === simpleCycleEdgeIds.length - 1 ? points : points.slice(0, -1) + }), + ) + + if (polygon.length < 3) continue + + const signedArea = polygonArea(polygon) + if (signedArea <= 0) continue + if (signedArea < 0.5 || signedArea > 10_000) continue + + const signature = polygonSignature(polygon) + if (rooms.some((room) => polygonSignature(room.polygon) === signature)) continue + + rooms.push({ + polygon, + boundaryFaces: simpleCycleEdgeIds.flatMap((id) => { + const edge = halfEdges.get(id) + if (!edge) return [] + return [ + { + wallId: edge.wallId, + face: edge.face, + points: edge.points.map(pointToTuple), + }, + ] + }), + }) + } + } + + rooms.sort((a, b) => Math.abs(polygonArea(b.polygon)) - Math.abs(polygonArea(a.polygon))) + return rooms +} + +function extractRoomPolygons(walls: WallNode[]): Point2D[][] { + return extractRooms(walls).map((room) => room.polygon) +} + +/** + * True when `wall` lies on the boundary of a room enclosed by `walls`, using the + * same planar room graph the auto slab/ceiling sync uses. The wall builder's + * "Room (auto-close)" mode calls this so drafting stops the moment a segment + * closes a room — whether the chain loops back to its own start or seals a bay + * against the middle of an existing wall (a T-junction). Sharing one graph means + * auto-close and auto-slab detection can never disagree about what is "closed". + */ +export function wallClosesRoom(walls: WallNode[], wall: WallNode): boolean { + const roomPolygons = extractRoomPolygons(walls) + if (roomPolygons.length === 0) return false + return roomPolygons.some((polygon) => wallBoundsRoom(wall, polygon)) +} + +export function resolveWallSurfaceSides( + wall: Pick, + roomPolygons: Point2D[][], +): Pick { + if (roomPolygons.length === 0) { + return { + frontSide: 'unknown' as const, + backSide: 'unknown' as const, + } + } + + const frame = getWallDirection(wall) + const normalLength = Math.hypot(frame.normal.x, frame.normal.y) + if (normalLength < 1e-9) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + const normalX = frame.normal.x / normalLength + const normalY = frame.normal.y / normalLength + const sampleDistance = Math.max((wall.thickness ?? 0.2) / 2 + 0.08, 0.16) + + const frontPoint = { + x: frame.point.x + normalX * sampleDistance, + y: frame.point.y + normalY * sampleDistance, + } + const backPoint = { + x: frame.point.x - normalX * sampleDistance, + y: frame.point.y - normalY * sampleDistance, + } + + const frontInside = pointInAnyPolygon(frontPoint, roomPolygons) + const backInside = pointInAnyPolygon(backPoint, roomPolygons) + + if (frontInside === backInside) { + return { + frontSide: wall.frontSide, + backSide: wall.backSide, + } + } + + return { + frontSide: frontInside ? 'interior' : 'exterior', + backSide: backInside ? 'interior' : 'exterior', + } +} + +function nextAutoRoomName( + nodes: Array<{ + name?: string + }>, + suffix: 'Slab' | 'Ceiling', +) { + let maxIndex = 0 + + for (const node of nodes) { + const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim()) + if (!match) continue + const index = Number(match[1]) + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + } + + return `Room ${maxIndex + 1} ${suffix}` +} + +function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) { + return ( + current.length === next.length && + current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) + ) +} + +function sameTuplePolygons( + current: Array>, + next: Array>, +) { + return ( + current.length === next.length && + current.every((polygon, index) => { + const nextPolygon = next[index] + return nextPolygon ? sameTuplePolygon(polygon, nextPolygon) : false + }) + ) +} + +type SurfaceWithOpenings = { + holes: Array> + holeMetadata: SlabNodeType['holeMetadata'] +} + +function crossProduct(a: Point2D, b: Point2D, c: Point2D) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) +} + +function lineIntersection(start: Point2D, end: Point2D, clipStart: Point2D, clipEnd: Point2D) { + const segment = { x: end.x - start.x, y: end.y - start.y } + const clip = { x: clipEnd.x - clipStart.x, y: clipEnd.y - clipStart.y } + const denominator = segment.x * clip.y - segment.y * clip.x + if (Math.abs(denominator) < 1e-9) return end + const offset = { x: clipStart.x - start.x, y: clipStart.y - start.y } + const t = (offset.x * clip.y - offset.y * clip.x) / denominator + return { x: start.x + segment.x * t, y: start.y + segment.y * t } +} + +function clipPolygonToConvex(subject: Point2D[], clipPolygon: Point2D[]) { + if (subject.length < 3 || clipPolygon.length < 3) return [] + const orientation = polygonArea(clipPolygon) >= 0 ? 1 : -1 + let output = [...subject] + + for (let index = 0; index < clipPolygon.length; index += 1) { + const clipStart = clipPolygon[index]! + const clipEnd = clipPolygon[(index + 1) % clipPolygon.length]! + const input = output + output = [] + if (input.length === 0) break + + let previous = input[input.length - 1]! + let previousInside = orientation * crossProduct(clipStart, clipEnd, previous) >= -1e-8 + for (const current of input) { + const currentInside = orientation * crossProduct(clipStart, clipEnd, current) >= -1e-8 + if (currentInside !== previousInside) { + output.push(lineIntersection(previous, current, clipStart, clipEnd)) + } + if (currentInside) output.push(current) + previous = current + previousInside = currentInside + } + output = dedupeSequentialPoints(output, 1e-7) + } + + return output.length >= 3 && Math.abs(polygonArea(output)) > 1e-8 ? output : [] +} + +function isConvexPolygon(polygon: Point2D[]) { + let direction = 0 + for (let index = 0; index < polygon.length; index += 1) { + const cross = crossProduct( + polygon[index]!, + polygon[(index + 1) % polygon.length]!, + polygon[(index + 2) % polygon.length]!, + ) + if (Math.abs(cross) < 1e-8) continue + const nextDirection = Math.sign(cross) + if (direction !== 0 && nextDirection !== direction) return false + direction = nextDirection + } + return true +} + +function pointInTriangle(point: Point2D, a: Point2D, b: Point2D, c: Point2D) { + return ( + crossProduct(a, b, point) >= -1e-8 && + crossProduct(b, c, point) >= -1e-8 && + crossProduct(c, a, point) >= -1e-8 + ) +} + +function triangulatePolygon(polygon: Point2D[]) { + const points = polygonArea(polygon) >= 0 ? [...polygon] : [...polygon].reverse() + const indices = points.map((_, index) => index) + const triangles: Point2D[][] = [] + let attempts = 0 + + while (indices.length > 3 && attempts < points.length * points.length) { + let clippedEar = false + for (let index = 0; index < indices.length; index += 1) { + const previousIndex = indices[(index - 1 + indices.length) % indices.length]! + const currentIndex = indices[index]! + const nextIndex = indices[(index + 1) % indices.length]! + const previous = points[previousIndex]! + const current = points[currentIndex]! + const next = points[nextIndex]! + if (crossProduct(previous, current, next) <= 1e-8) continue + if ( + indices.some( + (candidateIndex) => + candidateIndex !== previousIndex && + candidateIndex !== currentIndex && + candidateIndex !== nextIndex && + pointInTriangle(points[candidateIndex]!, previous, current, next), + ) + ) { + continue + } + triangles.push([previous, current, next]) + indices.splice(index, 1) + clippedEar = true + break + } + if (!clippedEar) break + attempts += 1 + } + + if (indices.length === 3) triangles.push(indices.map((index) => points[index]!)) + return triangles +} + +function clipOpeningToRoom(opening: Point2D[], room: Point2D[]) { + const openingIsInside = opening.every( + (point) => pointInPolygon(point, room) || pointDistanceToPolygonBoundary(point, room) <= 1e-7, + ) + if (openingIsInside) return [opening] + + const clipRegions = isConvexPolygon(room) ? [room] : triangulatePolygon(room) + return clipRegions + .map((region) => clipPolygonToConvex(opening, region)) + .filter((polygon) => polygon.length >= 3) +} + +function partitionSurfaceOpenings( + surface: SurfaceWithOpenings, + roomIndices: number[], + detected: DetectedRoom[], +) { + const assignments = new Map< + number, + { holes: Array>; holeMetadata: SlabNodeType['holeMetadata'] } + >() + for (const roomIndex of roomIndices) { + assignments.set(roomIndex, { holes: [], holeMetadata: [] }) + } + + surface.holes.forEach((hole, holeIndex) => { + const holePolygon = hole.map(pointFromTuple) + for (const roomIndex of roomIndices) { + const room = detected[roomIndex] + const assignment = assignments.get(roomIndex) + if (!(room && assignment)) continue + for (const clipped of clipOpeningToRoom(holePolygon, room.poly)) { + assignment.holes.push(clipped.map(pointToTuple)) + assignment.holeMetadata.push(surface.holeMetadata[holeIndex] ?? { source: 'manual' }) + } + } + }) + + return assignments +} + +function partitionCeilingChildren( + ceiling: CeilingNodeType, + roomIndices: number[], + detected: DetectedRoom[], + fallbackRoomIndex: number | undefined, + childPosition: AutoCeilingPlanningContext['childPosition'], +) { + const assignments = new Map() + for (const roomIndex of roomIndices) assignments.set(roomIndex, []) + + for (const childId of ceiling.children) { + const tuple = childPosition?.(childId) + const point = tuple ? pointFromTuple(tuple) : undefined + const boundaryRoomIndices = point + ? roomIndices.filter((roomIndex) => { + const room = detected[roomIndex] + return room ? pointDistanceToPolygonBoundary(point, room.poly) <= 1e-7 : false + }) + : [] + const interiorRoomIndex = + point && boundaryRoomIndices.length === 0 + ? roomIndices.find((roomIndex) => { + const room = detected[roomIndex] + return room ? pointInPolygon(point, room.poly) : false + }) + : undefined + const roomIndex = + interiorRoomIndex ?? + (fallbackRoomIndex !== undefined && boundaryRoomIndices.includes(fallbackRoomIndex) + ? fallbackRoomIndex + : boundaryRoomIndices[0]) ?? + fallbackRoomIndex ?? + roomIndices[0] + if (roomIndex !== undefined) assignments.get(roomIndex)?.push(childId) + } + + return assignments +} + +function sameHoleMetadata( + current: SlabNodeType['holeMetadata'], + next: SlabNodeType['holeMetadata'], +) { + return ( + current.length === next.length && + current.every((metadata, index) => { + const candidate = next[index] + return ( + candidate?.source === metadata.source && + candidate.stairId === metadata.stairId && + candidate.elevatorId === metadata.elevatorId + ) + }) + ) +} + +function mergedSurfaceOpenings(surfaces: SurfaceWithOpenings[]) { + const holes: Array> = [] + const holeMetadata: SlabNodeType['holeMetadata'] = [] + const seen = new Set() + + for (const surface of surfaces) { + surface.holes.forEach((hole, index) => { + const metadata = surface.holeMetadata[index] ?? { source: 'manual' } + const key = JSON.stringify([hole, metadata]) + if (seen.has(key)) return + seen.add(key) + holes.push(hole) + holeMetadata.push(metadata) + }) + } + + return { holes, holeMetadata } +} + +function ceilingMergeSettingsSignature(ceiling: CeilingNodeType) { + return JSON.stringify([ + ceiling.height ?? null, + ceiling.material ?? null, + ceiling.materialPreset ?? null, + ceiling.slots ?? null, + ceiling.visible, + ]) +} + +function slabMergeSettingsSignature(slab: SlabNodeType) { + return JSON.stringify([ + slab.elevation, + slab.thickness, + slab.recessed, + slab.recessedRimElevation ?? null, + slab.fillToTerrain ?? null, + slab.material ?? null, + slab.materialPreset ?? null, + slab.slots ?? null, + slab.visible, + ]) +} + +function slabElevationForReconciledRoom( + source: SlabNodeType, + polygon: Array<[number, number]>, + context: AutoSlabPlanningContext, +) { + const currentDerived = context.elevationForRoom?.(polygon) + if (!context.previousElevationForRoom) return currentDerived ?? source.elevation + + const previousDerived = context.previousElevationForRoom(source.polygon) + const sourceWasDerived = + previousDerived !== undefined && + Math.abs(source.elevation - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON + return sourceWasDerived ? (currentDerived ?? source.elevation) : source.elevation +} + +function ceilingHeightForReconciledRoom( + source: CeilingNodeType, + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, +) { + if (source.height === undefined) return undefined + + const currentDerived = context.heightForRoom?.(polygon) + if (!context.previousHeightForRoom) return currentDerived ?? source.height + + const previousDerived = context.previousHeightForRoom(source.polygon) + const sourceWasDerived = + previousDerived !== undefined && + Math.abs(source.height - previousDerived) <= ROOM_VERTICAL_PLANE_EPSILON + return sourceWasDerived ? (currentDerived ?? source.height) : source.height +} + +function wallGeometrySignature(wall: WallNode, nodes: Record, levelId: string) { + return [ + wall.id, + wall.start[0].toFixed(4), + wall.start[1].toFixed(4), + wall.end[0].toFixed(4), + wall.end[1].toFixed(4), + (wall.thickness ?? 0.2).toFixed(4), + // Plane-bound (no stored height) is a distinct state, not a default + // value: it resolves to the storey plane, so it must not alias an + // explicit height of the same magnitude in the trigger signature. + wall.height == null ? 'plane' : wall.height.toFixed(4), + (wall.endHeightOffset ?? 0).toFixed(4), + wall.supportSlabId ?? 'elected', + (wall.supportOffset ?? 0).toFixed(4), + getClampedWallCurveOffset(wall).toFixed(4), + // The ground under this wall, sampled at the SAME point + // `boundaryWallBase` samples it. Sculpting changes only `site.terrain`, + // so without a terrain term here every signature stays byte-identical + // and the sync early-exits — a room's floor and ceiling could never + // follow ground that moved beneath its walls. + // + // The sample, not the field, and not the resolved base: hashing the + // heightfield would re-trigger every level for a stroke on the far side + // of the lot, and resolving the full slab election would fold slab + // POLYGONS into the signature, which is exactly the delete/recreate + // feedback the comment below is about. Sampling where the placement + // samples means the two cannot disagree in either direction — no missed + // re-run, no spurious one. + // + // Granularity is per stroke, not per dab: live dabs publish to + // `useLiveTerrain` and never touch the scene store, so this runs once on + // release — inside the stroke's own `runAsSingleSceneHistoryStep`, which + // is what puts the moved floor and the terrain that moved it in the same + // undo step. Mid-drag the ground-hosted walls follow the brush while the + // floor waits for release; re-deriving per dab would mean a scene write + // per dab and a floor that jitters under the cursor. + levelBaseElevationAt(nodes, levelId, wall.start[0], wall.start[1]).toFixed(4), + ].join('|') +} + +function levelWallSnapshot(walls: WallNode[], nodes: Record, levelId: string) { + return walls + .map((wall) => wallGeometrySignature(wall, nodes, levelId)) + .sort() + .join('||') +} + +function zoneGeometrySignature(zone: ZoneNodeType) { + return [ + zone.id, + zone.autoFromWalls ? 'auto' : 'manual', + zone.boundaryWallIds.slice().sort().join(','), + zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'), + ].join('|') +} + +// Slab/ceiling POLYGONS stay out of the trigger signature: including +// generated footprints caused delete/recreate feedback. Zones are included +// only so a newly traced room footprint can adopt its enclosing walls +// without waiting for the next remodel. Slab ELEVATIONS and the level's +// stored storey height ARE included — both feed the explicit-ceiling +// re-clamp bound (the storey plane), and neither is rewritten by +// the sync, so regeneration triggers when they change without feedback. +// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − +// thickness, recessed pools excluded): a deck created, lowered, or +// thickened above must re-run the sync below so ceilings re-clamp under +// it. Same polygon exclusion applies — the level-above's own auto sync +// rewrites its slab footprints, and hashing them here would re-trigger +// this level on every remodel above. +function levelStructureSnapshots(nodes: Record) { + const wallsByLevel = new Map() + const zonesByLevel = new Map() + const slabElevationsByLevel = new Map() + const coveringUndersidesByLevel = new Map() + + for (const node of Object.values(nodes)) { + if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue + const levelId = (node as any).parentId as string + if ((node as any).type === 'wall') { + const walls = wallsByLevel.get(levelId) ?? [] + walls.push(node as WallNode) + wallsByLevel.set(levelId, walls) + } else if ((node as any).type === 'zone') { + const zones = zonesByLevel.get(levelId) ?? [] + zones.push(ZoneNode.parse(node)) + zonesByLevel.set(levelId, zones) + } else if ((node as any).type === 'slab') { + const elevations = slabElevationsByLevel.get(levelId) ?? [] + elevations.push( + `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, + ) + slabElevationsByLevel.set(levelId, elevations) + if ((node as any).recessed !== true) { + const undersides = coveringUndersidesByLevel.get(levelId) ?? [] + const elevation = ((node as any).elevation as number | undefined) ?? 0.05 + const thickness = ((node as any).thickness as number | undefined) ?? 0.05 + undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) + coveringUndersidesByLevel.set(levelId, undersides) + } + } + } + + const levelElevations = getLevelElevations(nodes as Record) + const snapshots = new Map() + const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) + for (const levelId of levelIds) { + const walls = wallsByLevel.get(levelId) ?? [] + const zones = zonesByLevel.get(levelId) ?? [] + const level = nodes[levelId] + const storeyKey = + level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' + const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') + const aboveId = findLevelAboveId(levelId, levelElevations) + const aboveSlabKey = aboveId + ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') + : '' + snapshots.set( + levelId, + `${storeyKey}#${levelWallSnapshot(walls, nodes, levelId)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, + ) + } + + return snapshots +} + +function buildSpace(levelId: string, room: ExtractedRoom): Space { + const signature = polygonSignature(room.polygon) + return { + id: `space-${levelId}-${signature.slice(0, 12)}`, + levelId, + polygon: room.polygon.map(pointToTuple), + wallIds: [...new Set(room.boundaryFaces.map((boundary) => boundary.wallId))], + boundaryFaces: room.boundaryFaces, + isExterior: false, + } +} + +type RoomSurface = SlabNodeType | CeilingNodeType + +function surfaceTouchesRooms(surface: RoomSurface, rooms: ExtractedRoom[]) { + const polygon = surface.polygon.map(pointFromTuple) + return rooms.some( + (room) => + polygonCoverageRatio(polygon, [room.polygon]) > 0 || + polygonCoverageRatio(room.polygon, [polygon]) > 0, + ) +} + +function roomsAreRelated(beforeRoom: ExtractedRoom, currentRoom: ExtractedRoom) { + const beforeIds = new Set(beforeRoom.boundaryFaces.map((boundary) => boundary.wallId)) + const currentIds = new Set(currentRoom.boundaryFaces.map((boundary) => boundary.wallId)) + const sharedWallCount = [...currentIds].filter((wallId) => beforeIds.has(wallId)).length + const smallerBoundarySize = Math.min(beforeIds.size, currentIds.size) + if (sharedWallCount >= 2 && sharedWallCount >= Math.ceil(smallerBoundarySize / 2)) return true + if (bboxOverlapArea(bboxOf(beforeRoom.polygon), bboxOf(currentRoom.polygon)) <= 1e-6) return false + return ( + polygonCoverageRatio(beforeRoom.polygon, [currentRoom.polygon]) > 0 || + polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]) > 0 + ) +} + +function roomHasAutoSurface(room: ExtractedRoom, surfaces: RoomSurface[]) { + return matchesManualFootprint( + room.polygon, + surfaces + .filter((surface) => surface.autoFromWalls) + .map((surface) => surface.polygon.map(pointFromTuple)), + ) +} + +function roomsEligibleForAutoSurface( + beforeRooms: ExtractedRoom[], + currentRooms: ExtractedRoom[], + currentSurfaces: RoomSurface[], +) { + return currentRooms.filter((currentRoom) => { + const related = beforeRooms.flatMap((beforeRoom) => { + if (!roomsAreRelated(beforeRoom, currentRoom)) return [] + return [ + { + room: beforeRoom, + coverage: polygonCoverageRatio(currentRoom.polygon, [beforeRoom.polygon]), + }, + ] + }) + const maxCoverage = Math.max(0, ...related.map(({ coverage }) => coverage)) + const predecessors = + currentRooms.length >= beforeRooms.length && maxCoverage > 0 + ? related.filter(({ coverage }) => coverage >= maxCoverage - 1e-6).map(({ room }) => room) + : related.map(({ room }) => room) + if (predecessors.length === 0) return true + return predecessors.every((beforeRoom) => roomHasAutoSurface(beforeRoom, currentSurfaces)) + }) +} + +function detectedRoomsByLevel(nodes: Record) { + const wallsByLevel = new Map() + for (const node of Object.values(nodes)) { + if (node?.type !== 'wall' || !node.parentId) continue + const walls = wallsByLevel.get(node.parentId) ?? [] + walls.push(node) + wallsByLevel.set(node.parentId, walls) + } + return new Map( + [...wallsByLevel].map(([levelId, walls]) => [levelId, extractRooms(walls)] as const), + ) +} + +type SceneNodes = Record + +function levelChildren(nodes: SceneNodes, levelId: string) { + const level = nodes[levelId] + if (level?.type !== 'level') return [] + return level.children.flatMap((id: string) => { + const node = nodes[id] + return node ? [node] : [] + }) +} + +function changedWallIdsByLevel( + before: SceneNodes, + current: SceneNodes, + candidateIds?: ReadonlySet, +) { + const changes = new Map>() + const wallIds = new Set(candidateIds) + if (!candidateIds) { + for (const node of Object.values(before)) { + if (node?.type === 'wall') wallIds.add(node.id) + } + for (const node of Object.values(current)) { + if (node?.type === 'wall') wallIds.add(node.id) + } + } + + const markChanged = (levelId: string | null | undefined, wallId: string) => { + if (!levelId) return + const ids = changes.get(levelId) ?? new Set() + ids.add(wallId) + changes.set(levelId, ids) + } + + for (const wallId of wallIds) { + const previous = before[wallId]?.type === 'wall' ? (before[wallId] as WallNode) : null + const next = current[wallId]?.type === 'wall' ? (current[wallId] as WallNode) : null + if (previous === next) continue + markChanged(previous?.parentId, wallId) + markChanged(next?.parentId, wallId) + } + + return changes +} + +function descendantLevelIds(nodes: SceneNodes, rootId: string) { + const levelIds = new Set() + const queue = [rootId] + const visited = new Set() + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const node = nodes[id] + if (!node) continue + if (node.type === 'level') levelIds.add(node.id) + if ('children' in node && Array.isArray(node.children)) queue.push(...node.children) + } + return levelIds +} + +function fallbackLevelIdsForCandidates( + before: SceneNodes, + current: SceneNodes, + candidateIds: ReadonlySet, +) { + const levelIds = new Set() + const addLevelAndLower = (levelId: string | null | undefined, nodes: SceneNodes) => { + if (!levelId) return + levelIds.add(levelId) + const lower = getLevelBelow(levelId, nodes) + if (lower) levelIds.add(lower.id) + } + + for (const id of candidateIds) { + for (const nodes of [before, current]) { + const node = nodes[id] + if (!node) continue + if (node.type === 'level' || node.type === 'building' || node.type === 'site') { + for (const levelId of descendantLevelIds(nodes, node.id)) levelIds.add(levelId) + } else if (node.type === 'slab') { + addLevelAndLower(node.parentId, nodes) + } else if (node.type === 'zone') { + if (node.parentId) levelIds.add(node.parentId) + } + } + } + return levelIds +} + +function sameStringSet(a: readonly string[], b: readonly string[]) { + if (a.length !== b.length) return false + const right = new Set(b) + return a.every((value) => right.has(value)) +} + +type AutoSurfaceMatch = { + detectedAll: DetectedRoom[] + detected: DetectedRoom[] + existingAuto: TSurface[] + compatibleMergesByRoomIndex: Map + matchedDetectedIndices: Set + roomIndexBySurfaceId: Map + sourceSurfaceIdByRoomIndex: Map + polygonBySurfaceId: Map> + delete: Array + demote: Array<{ id: TSurface['id']; data: Partial }> +} + +function matchAutoSurfaces( + roomPolygons: Point2D[][], + existingSurfaces: TSurface[], + mergeSettingsSignature: (surface: TSurface) => string, +): AutoSurfaceMatch { + const manualSurfaces = existingSurfaces.filter((surface) => !surface.autoFromWalls) + const manualSignatures = new Set( + manualSurfaces.map((surface) => polygonSignature(surface.polygon.map(pointFromTuple))), + ) + const manualPolygons = manualSurfaces.map((surface) => surface.polygon.map(pointFromTuple)) + const detectedAll: DetectedRoom[] = roomPolygons + .map((poly) => ({ + poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( + pointFromTuple, + ), + sig: '', + centroid: { x: 0, y: 0 }, + area: 0, + bbox: bboxOf([]), + })) + .map((room) => ({ + ...room, + sig: polygonSignature(room.poly), + centroid: polygonCentroid(room.poly), + area: Math.abs(polygonArea(room.poly)), + bbox: bboxOf(room.poly), + })) + const detected = detectedAll.filter( + ({ sig, poly }) => !manualSignatures.has(sig) && !matchesManualFootprint(poly, manualPolygons), + ) + const existingAuto = existingSurfaces.filter((surface) => surface.autoFromWalls) + const metadata = existingAuto.map((surface) => { + const poly = surface.polygon.map(pointFromTuple) + return { + surface, + sig: polygonSignature(poly), + centroid: polygonCentroid(poly), + area: Math.abs(polygonArea(poly)), + bbox: bboxOf(poly), + } + }) + + const conflictingSurfaceIds = new Set() + const conflictingRoomIndices = new Set() + const compatibleMergesByRoomIndex = new Map() + detected.forEach((room, roomIndex) => { + const contributors = existingAuto.filter( + (surface) => + polygonCoverageRatio(surface.polygon.map(pointFromTuple), [room.poly]) >= + ORPHAN_MERGE_COVERAGE_THRESHOLD, + ) + if (contributors.length < 2) return + if (new Set(contributors.map(mergeSettingsSignature)).size > 1) { + conflictingRoomIndices.add(roomIndex) + for (const surface of contributors) conflictingSurfaceIds.add(surface.id) + return + } + compatibleMergesByRoomIndex.set(roomIndex, contributors) + }) + + const matchedSurfaceIds = new Set() + const matchedDetectedIndices = new Set() + const roomIndexBySurfaceId = new Map() + const sourceSurfaceIdByRoomIndex = new Map() + const polygonBySurfaceId = new Map>() + const autoBySignature = new Map>() + for (const entry of metadata) { + const bucket = autoBySignature.get(entry.sig) ?? [] + bucket.push(entry) + autoBySignature.set(entry.sig, bucket) + } + + detected.forEach((room, index) => { + if (conflictingRoomIndices.has(index)) { + matchedDetectedIndices.add(index) + return + } + const existing = autoBySignature.get(room.sig)?.shift() + if (!existing) return + matchedDetectedIndices.add(index) + matchedSurfaceIds.add(existing.surface.id) + roomIndexBySurfaceId.set(existing.surface.id, index) + sourceSurfaceIdByRoomIndex.set(index, existing.surface.id) + polygonBySurfaceId.set(existing.surface.id, room.poly.map(pointToTuple)) + }) + + const remainingDetected = detected + .map((room, index) => ({ room, index })) + .filter(({ index }) => !matchedDetectedIndices.has(index)) + .sort((left, right) => right.room.area - left.room.area) + const remainingAuto = metadata.filter((entry) => !matchedSurfaceIds.has(entry.surface.id)) + + for (const { room, index } of remainingDetected) { + let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null + for (const entry of remainingAuto) { + if (matchedSurfaceIds.has(entry.surface.id)) continue + const distance = Math.hypot( + room.centroid.x - entry.centroid.x, + room.centroid.y - entry.centroid.y, + ) + const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999 + const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio))) + if (bboxOverlapArea(room.bbox, entry.bbox) <= 0.0001 && distance > 1.5) continue + const score = distance + areaPenalty * 0.35 + if (!bestMatch || score < bestMatch.score) bestMatch = { entry, score } + } + if (!bestMatch) continue + matchedDetectedIndices.add(index) + matchedSurfaceIds.add(bestMatch.entry.surface.id) + roomIndexBySurfaceId.set(bestMatch.entry.surface.id, index) + sourceSurfaceIdByRoomIndex.set(index, bestMatch.entry.surface.id) + polygonBySurfaceId.set(bestMatch.entry.surface.id, room.poly.map(pointToTuple)) + } + + detected.forEach((room, index) => { + if (sourceSurfaceIdByRoomIndex.has(index)) return + let bestSource: { id: string; coverage: number } | null = null + for (const entry of metadata) { + const coverage = polygonCoverageRatio(room.poly, [entry.surface.polygon.map(pointFromTuple)]) + if (coverage <= 0 || (bestSource && coverage <= bestSource.coverage)) continue + bestSource = { id: entry.surface.id, coverage } + } + if (bestSource) sourceSurfaceIdByRoomIndex.set(index, bestSource.id) + }) + + const detectedRoomPolygons = detectedAll.map((room) => room.poly) + const deleted: Array = [] + const demote: AutoSurfaceMatch['demote'] = [] + for (const surface of existingAuto) { + if (polygonBySurfaceId.has(surface.id)) continue + if (conflictingSurfaceIds.has(surface.id)) { + demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) + continue + } + const coverage = polygonCoverageRatio(surface.polygon.map(pointFromTuple), detectedRoomPolygons) + if (coverage >= ORPHAN_MERGE_COVERAGE_THRESHOLD) deleted.push(surface.id) + else demote.push({ id: surface.id, data: { autoFromWalls: false } as Partial }) + } + + return { + detectedAll, + detected, + existingAuto, + compatibleMergesByRoomIndex, + matchedDetectedIndices, + roomIndexBySurfaceId, + sourceSurfaceIdByRoomIndex, + polygonBySurfaceId, + delete: deleted, + demote, + } +} + +export function planAutoZonesForLevel( + spaces: readonly Space[], + existingZones: readonly ZoneNodeType[], +): AutoZoneSyncPlan { + const update: AutoZoneSyncPlan['update'] = [] + + for (const zone of existingZones) { + const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple)) + const matchingSpace = + zone.autoFromWalls && zone.boundaryWallIds.length >= 3 + ? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds)) + : spaces.find( + (space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature, + ) + if (!matchingSpace) continue + + const data: Partial = {} + if (!zone.autoFromWalls) data.autoFromWalls = true + if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) { + data.boundaryWallIds = matchingSpace.wallIds + } + if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) { + data.polygon = matchingSpace.polygon + } + if (Object.keys(data).length > 0) update.push({ id: zone.id, data }) + } + + return { update } +} + +export function resolveAutoZonePolygon( + zone: Pick, + resolve: (id: AnyNodeId) => unknown, +): ZoneNodeType['polygon'] { + if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3) return zone.polygon + const walls = zone.boundaryWallIds.flatMap((id) => { + const node = resolve(id) + return node && typeof node === 'object' && 'type' in node && node.type === 'wall' + ? [node as WallNode] + : [] + }) + if (walls.length !== zone.boundaryWallIds.length) return zone.polygon + const room = extractRooms(walls).find((candidate) => + sameStringSet( + [...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))], + zone.boundaryWallIds, + ), + ) + return room ? room.polygon.map(pointToTuple) : zone.polygon +} + +export function planAutoSlabsForLevel( + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, +): AutoSlabSyncPlan { + const match = matchAutoSurfaces(roomPolygons, existingSlabs, slabMergeSettingsSignature) + const { + detected, + existingAuto, + compatibleMergesByRoomIndex: compatibleMergeSlabsByRoomIndex, + matchedDetectedIndices: matchedDetectedIdx, + roomIndexBySurfaceId: roomIndexBySlabId, + sourceSurfaceIdByRoomIndex: sourceSlabIdByRoomIndex, + delete: slabsToDelete, + demote: slabDemotions, + } = match + const updatesById = new Map< + string, + { polygon: [number, number][]; elevation: number | undefined } + >() + for (const slab of existingAuto) { + const polygon = match.polygonBySurfaceId.get(slab.id) + if (!polygon) continue + updatesById.set(slab.id, { + polygon, + elevation: slabElevationForReconciledRoom(slab, polygon, context), + }) + } + + const openingAssignmentsBySlabId = new Map>() + for (const slab of existingAuto) { + const roomIndices = [...sourceSlabIdByRoomIndex.entries()] + .filter(([, slabId]) => slabId === slab.id) + .map(([roomIndex]) => roomIndex) + if (roomIndices.length === 0) continue + openingAssignmentsBySlabId.set(slab.id, partitionSurfaceOpenings(slab, roomIndices, detected)) + } + + const slabsToUpdate = [ + ...existingAuto + .filter((slab) => updatesById.has(slab.id)) + .flatMap((slab) => { + const update = updatesById.get(slab.id) + if (!update) return [] + const roomIndex = roomIndexBySlabId.get(slab.id) + const openings = + roomIndex == null + ? { holes: slab.holes, holeMetadata: slab.holeMetadata } + : compatibleMergeSlabsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeSlabsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsBySlabId.get(slab.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) + const data: Partial = {} + if (!sameTuplePolygon(slab.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygons(slab.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(slab.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + if ( + update.elevation !== undefined && + Math.abs(slab.elevation - update.elevation) > ROOM_VERTICAL_PLANE_EPSILON + ) { + data.elevation = update.elevation + } + return Object.keys(data).length > 0 ? [{ id: slab.id, data }] : [] + }), + ...slabDemotions, + ] + + const plannedSlabsForNaming: Array<{ name?: string }> = [...namingSlabs] + const slabsToCreate: SlabNodeType[] = [] + for (let index = 0; index < detected.length; index += 1) { + if (matchedDetectedIdx.has(index)) continue + + const room = detected[index] + if (!room) continue + + const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') + plannedSlabsForNaming.push({ name }) + + const polygon = room.poly.map(pointToTuple) + const sourceId = sourceSlabIdByRoomIndex.get(index) + const source = sourceId ? existingAuto.find((slab) => slab.id === sourceId) : undefined + const openings = sourceId ? openingAssignmentsBySlabId.get(sourceId)?.get(index) : undefined + const elevation = source + ? slabElevationForReconciledRoom(source, polygon, context) + : context.elevationForRoom?.(polygon) + slabsToCreate.push( + SlabNode.parse({ + name, + polygon, + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + elevation: + elevation !== undefined && Number.isFinite(elevation) + ? elevation + : DEFAULT_AUTO_SLAB_ELEVATION, + thickness: source?.thickness, + recessed: source?.recessed, + recessedRimElevation: source?.recessedRimElevation, + fillToTerrain: source?.fillToTerrain, + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, + autoFromWalls: true, + }), + ) + } + + return { + create: slabsToCreate, + update: slabsToUpdate, + delete: slabsToDelete, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, + context: AutoSlabPlanningContext = {}, + namingSlabs: Array<{ name?: string }> = existingSlabs, +) { + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs, context, namingSlabs) + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) + } + + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) + } + + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) + } + + return plan +} + +export function planAutoCeilingsForLevel( + roomPolygons: Point2D[][], + existingCeilings: CeilingNodeType[], + context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, +): AutoCeilingSyncPlan { + const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls) + const match = matchAutoSurfaces(roomPolygons, existingCeilings, ceilingMergeSettingsSignature) + const { + detected, + existingAuto, + compatibleMergesByRoomIndex: compatibleMergeCeilingsByRoomIndex, + matchedDetectedIndices: matchedDetectedIdx, + roomIndexBySurfaceId: roomIndexByCeilingId, + sourceSurfaceIdByRoomIndex: sourceCeilingIdByRoomIndex, + delete: ceilingsToDelete, + demote: ceilingDemotions, + } = match + const updatesById = new Map() + for (const ceiling of existingAuto) { + const polygon = match.polygonBySurfaceId.get(ceiling.id) + if (!polygon) continue + updatesById.set(ceiling.id, { + polygon, + height: ceilingHeightForReconciledRoom(ceiling, polygon, context), + }) + } + + // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab + // created, moved, or thickened on the level above can leave an EXISTING + // manual explicit-height ceiling poking into its solid. Clamp explicit + // heights down to the bound; never raise them — a user-lowered ceiling + // is intent, only an over-bound one is a conflict. Follows-mode + // ceilings (absent height) derive under the bound by construction and + // are skipped, so the clamp can never convert one to an explicit + // height. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + if (ceiling.height == null) return [] + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + return ceiling.height > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + + const openingAssignmentsByCeilingId = new Map< + string, + ReturnType + >() + const childAssignmentsByCeilingId = new Map>() + for (const ceiling of existingAuto) { + const roomIndices = [...sourceCeilingIdByRoomIndex.entries()] + .filter(([, ceilingId]) => ceilingId === ceiling.id) + .map(([roomIndex]) => roomIndex) + if (roomIndices.length === 0) continue + openingAssignmentsByCeilingId.set( + ceiling.id, + partitionSurfaceOpenings(ceiling, roomIndices, detected), + ) + childAssignmentsByCeilingId.set( + ceiling.id, + partitionCeilingChildren( + ceiling, + roomIndices, + detected, + roomIndexByCeilingId.get(ceiling.id), + context.childPosition, + ), + ) + } + + const childReparents: AutoCeilingSyncPlan['reparent'] = [] + const ceilingsToUpdate = [ + ...existingAuto + .filter((ceiling) => updatesById.has(ceiling.id)) + .flatMap((ceiling) => { + const update = updatesById.get(ceiling.id) + if (!update) return [] + const roomIndex = roomIndexByCeilingId.get(ceiling.id) + const openings = + roomIndex == null + ? { holes: ceiling.holes, holeMetadata: ceiling.holeMetadata } + : compatibleMergeCeilingsByRoomIndex.has(roomIndex) + ? mergedSurfaceOpenings(compatibleMergeCeilingsByRoomIndex.get(roomIndex) ?? []) + : (openingAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? { + holes: [], + holeMetadata: [], + }) + const data: Partial = {} + if (!sameTuplePolygon(ceiling.polygon, update.polygon)) data.polygon = update.polygon + if (!sameTuplePolygons(ceiling.holes, openings.holes)) data.holes = openings.holes + if (!sameHoleMetadata(ceiling.holeMetadata, openings.holeMetadata)) { + data.holeMetadata = openings.holeMetadata + } + const mergeContributors = + roomIndex == null ? undefined : compatibleMergeCeilingsByRoomIndex.get(roomIndex) + const children = mergeContributors + ? ([ + ...new Set(mergeContributors.flatMap((contributor) => contributor.children)), + ] as CeilingNodeType['children']) + : roomIndex == null + ? ceiling.children + : (childAssignmentsByCeilingId.get(ceiling.id)?.get(roomIndex) ?? ceiling.children) + for (const contributor of mergeContributors ?? []) { + if (contributor.id === ceiling.id) continue + for (const childId of contributor.children) { + childReparents.push({ id: childId, parentId: ceiling.id }) + } + } + if (!sameStringSet(ceiling.children, children)) data.children = children + if ( + update.height !== undefined && + (ceiling.height === undefined || + Math.abs(ceiling.height - update.height) > ROOM_VERTICAL_PLANE_EPSILON) + ) { + data.height = update.height + } + return Object.keys(data).length > 0 ? [{ id: ceiling.id, data }] : [] + }), + ...ceilingDemotions, + ...manualClamps, + ] + + const plannedCeilingsForNaming: Array<{ name?: string }> = [...namingCeilings] + const ceilingsToCreate: CeilingNodeType[] = [] + for (let index = 0; index < detected.length; index += 1) { + if (matchedDetectedIdx.has(index)) continue + + const room = detected[index] + if (!room) continue + + const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') + plannedCeilingsForNaming.push({ name }) + + const polygon = room.poly.map(pointToTuple) + const sourceId = sourceCeilingIdByRoomIndex.get(index) + const source = sourceId ? existingAuto.find((ceiling) => ceiling.id === sourceId) : undefined + const openings = sourceId ? openingAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined + const children = sourceId ? childAssignmentsByCeilingId.get(sourceId)?.get(index) : undefined + const height = source + ? ceilingHeightForReconciledRoom(source, polygon, context) + : context.heightForRoom?.(polygon) + const created = CeilingNode.parse({ + name, + polygon, + children: children ?? [], + holes: openings?.holes ?? [], + holeMetadata: openings?.holeMetadata ?? [], + material: source?.material, + materialPreset: source?.materialPreset, + slots: source?.slots, + visible: source?.visible, + ...(height !== undefined && Number.isFinite(height) ? { height } : {}), + autoFromWalls: true, + }) + ceilingsToCreate.push(created) + for (const childId of children ?? []) { + childReparents.push({ id: childId, parentId: created.id }) + } + } + + return { + create: ceilingsToCreate, + update: ceilingsToUpdate, + delete: ceilingsToDelete, + reparent: childReparents, + } +} + +function syncAutoCeilingsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingCeilings: CeilingNodeType[], + sceneStore: any, + context: AutoCeilingPlanningContext = {}, + namingCeilings: Array<{ name?: string }> = existingCeilings, +) { + const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings, context, namingCeilings) + + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) + } + + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) + } + + if (plan.reparent.length > 0) { + sceneStore + .getState() + .updateNodes(plan.reparent.map(({ id, parentId }) => ({ id, data: { parentId } }))) + } + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) + } +} + +function detectSpacesFromWalls(levelId: string, walls: WallNode[]) { + const rooms = extractRooms(walls) + const roomPolygons = rooms.map((room) => room.polygon) + const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ + wallId: wall.id, + ...(resolveWallSurfaceSides(wall, roomPolygons) satisfies Pick< + WallSideUpdate, + 'frontSide' | 'backSide' + >), + })) + + return { + rooms, + roomPolygons, + spaces: rooms.map((room) => buildSpace(levelId, room)), + wallUpdates, + } +} + +export function detectSpacesForLevel(levelId: string, walls: WallNode[]) { + return detectSpacesFromWalls(levelId, walls) +} + +function runSpaceDetection( + levelIds: string[], + sceneStore: any, + editorStore: any, + nodes: any, + previousNodes: any, + previousRoomsByLevel: Map, +): void { + const { updateNodes } = sceneStore.getState() + const existingSpaces = editorStore.getState().spaces as Record + const nextSpaces: Record = {} + + for (const [spaceId, space] of Object.entries(existingSpaces)) { + if (!levelIds.includes(space.levelId)) { + nextSpaces[spaceId] = space + } + } + + for (const levelId of levelIds) { + const children = levelChildren(nodes, levelId) + const walls = children.filter( + (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + const slabs = children.filter((node: any) => node?.type === 'slab') + const ceilings = children.filter((node: any) => node?.type === 'ceiling') + const zones = children.filter((node: any) => node?.type === 'zone') + + const { wallUpdates, spaces, rooms } = detectSpacesFromWalls(levelId, walls) + + const changedWallUpdates = wallUpdates.filter((update) => { + const wall = nodes[update.wallId] + return wall && (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) + }) + + if (changedWallUpdates.length > 0) { + updateNodes( + changedWallUpdates.map((update) => ({ + id: update.wallId, + data: { + frontSide: update.frontSide, + backSide: update.backSide, + }, + })), + ) + } + + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const parsedSlabs: SlabNodeType[] = slabs.map((slab: any) => SlabNode.parse(slab)) + const parsedCeilings: CeilingNodeType[] = ceilings.map((ceiling: any) => + CeilingNode.parse(ceiling), + ) + const previousRooms = previousRoomsByLevel.get(levelId) ?? [] + const slabRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedSlabs) + const ceilingRooms = roomsEligibleForAutoSurface(previousRooms, rooms, parsedCeilings) + const verticalPlacements = autoRoomVerticalPlacements( + spaces, + walls, + // A derived floor cannot be evidence for its own next elevation: that + // would lift unpinned walls, then lift the floor again on every pass. + parsedSlabs.filter((slab) => !slab.autoFromWalls), + nodes, + storeyHeight, + ) + const previousChildren = levelChildren(previousNodes, levelId) + const previousWalls = previousChildren.filter( + (node: any): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + const previousSlabs: SlabNodeType[] = previousChildren + .filter((node: any) => node?.type === 'slab') + .map((slab: any) => SlabNode.parse(slab)) + const previousLevelNode = previousNodes[levelId] + const previousStoreyHeight = + previousLevelNode?.type === 'level' + ? getStoredLevelHeight(previousLevelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const previousSpaces = detectSpacesFromWalls(levelId, previousWalls).spaces + const previousVerticalPlacements = autoRoomVerticalPlacements( + previousSpaces, + previousWalls, + previousSlabs.filter((slab) => !slab.autoFromWalls), + previousNodes, + previousStoreyHeight, + ) + const placementFor = (polygon: Array<[number, number]>) => + verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + const previousPlacementFor = (polygon: Array<[number, number]>) => + previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + syncAutoSlabsForLevel( + levelId, + slabRooms.map((room) => room.polygon), + parsedSlabs, + sceneStore, + { + elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, + previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, + }, + ) + syncAutoCeilingsForLevel( + levelId, + ceilingRooms.map((room) => room.polygon), + parsedCeilings, + sceneStore, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, + previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, + childPosition: (childId) => { + const child = nodes[childId] + return child && Array.isArray(child.position) + ? [child.position[0], child.position[2]] + : undefined + }, + }, + ) + const zonePlan = planAutoZonesForLevel( + spaces, + zones.map((zone: any) => ZoneNode.parse(zone)), + ) + if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + + for (const space of spaces) { + nextSpaces[space.id] = space + } + previousRoomsByLevel.set(levelId, rooms) + } + + editorStore.getState().setSpaces(nextSpaces) +} + +function runIndexedSpaceDetection( + levelId: string, + topologyDelta: IndexedTopologyDelta, + sceneStore: any, + editorStore: any, + nodes: SceneNodes, + previousNodes: SceneNodes, +) { + const { updateNodes } = sceneStore.getState() + const allRoomPolygons = topologyDelta.allCurrentRooms.map((room) => room.polygon) + const changedWallUpdates = topologyDelta.currentWalls + .map((wall) => ({ + wallId: wall.id, + ...resolveWallSurfaceSides(wall, allRoomPolygons), + })) + .filter((update) => { + const wall = nodes[update.wallId] + return ( + wall?.type === 'wall' && + (wall.frontSide !== update.frontSide || wall.backSide !== update.backSide) + ) + }) + if (changedWallUpdates.length > 0) { + updateNodes( + changedWallUpdates.map((update) => ({ + id: update.wallId, + data: { frontSide: update.frontSide, backSide: update.backSide }, + })), + ) + } + + const scopedRooms = [...topologyDelta.beforeRooms, ...topologyDelta.currentRooms] + if (scopedRooms.length > 0) { + const unaffectedRooms = topologyDelta.allCurrentRooms.filter( + (room) => !topologyDelta.currentRooms.includes(room), + ) + const currentChildren = levelChildren(nodes, levelId) + const allSlabs: SlabNodeType[] = currentChildren + .filter((node: any): node is SlabNodeType => node.type === 'slab') + .map((slab: SlabNodeType) => SlabNode.parse(slab)) + const allCeilings: CeilingNodeType[] = currentChildren + .filter((node: any): node is CeilingNodeType => node.type === 'ceiling') + .map((ceiling: CeilingNodeType) => CeilingNode.parse(ceiling)) + const slabs = allSlabs.filter( + (slab) => + surfaceTouchesRooms(slab, scopedRooms) && + (!slab.autoFromWalls || !surfaceTouchesRooms(slab, unaffectedRooms)), + ) + const ceilings = allCeilings.filter( + (ceiling) => + surfaceTouchesRooms(ceiling, scopedRooms) && + (!ceiling.autoFromWalls || !surfaceTouchesRooms(ceiling, unaffectedRooms)), + ) + const slabRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + slabs, + ) + const ceilingRooms = roomsEligibleForAutoSurface( + topologyDelta.beforeRooms, + topologyDelta.currentRooms, + ceilings, + ) + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const currentSpaces = topologyDelta.currentRooms.map((room) => buildSpace(levelId, room)) + const verticalPlacements = autoRoomVerticalPlacements( + currentSpaces, + topologyDelta.currentWalls, + allSlabs.filter((slab) => !slab.autoFromWalls), + nodes, + storeyHeight, + ) + const previousChildren = levelChildren(previousNodes, levelId) + const previousSlabs: SlabNodeType[] = previousChildren + .filter((node: any): node is SlabNodeType => node.type === 'slab') + .map((slab: SlabNodeType) => SlabNode.parse(slab)) + const previousLevelNode = previousNodes[levelId] + const previousStoreyHeight = + previousLevelNode?.type === 'level' + ? getStoredLevelHeight(previousLevelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT + const previousSpaces = topologyDelta.beforeRooms.map((room) => buildSpace(levelId, room)) + const previousVerticalPlacements = autoRoomVerticalPlacements( + previousSpaces, + topologyDelta.previousWalls, + previousSlabs.filter((slab) => !slab.autoFromWalls), + previousNodes, + previousStoreyHeight, + ) + const placementFor = (polygon: Array<[number, number]>) => + verticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + const previousPlacementFor = (polygon: Array<[number, number]>) => + previousVerticalPlacements.get(polygonSignature(polygon.map(pointFromTuple))) + + syncAutoSlabsForLevel( + levelId, + slabRooms.map((room) => room.polygon), + slabs, + sceneStore, + { + elevationForRoom: (polygon) => placementFor(polygon)?.slabElevation, + previousElevationForRoom: (polygon) => previousPlacementFor(polygon)?.slabElevation, + }, + allSlabs, + ) + syncAutoCeilingsForLevel( + levelId, + ceilingRooms.map((room) => room.polygon), + ceilings, + sceneStore, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + heightForRoom: (polygon) => placementFor(polygon)?.ceilingHeight, + previousHeightForRoom: (polygon) => previousPlacementFor(polygon)?.ceilingHeight, + childPosition: (childId) => { + const child = nodes[childId] + return child && Array.isArray(child.position) + ? [child.position[0], child.position[2]] + : undefined + }, + }, + allCeilings, + ) + } + + const spaces = topologyDelta.allCurrentRooms.map((room) => buildSpace(levelId, room)) + const zones: ZoneNodeType[] = levelChildren(nodes, levelId) + .filter((node: any): node is ZoneNodeType => node.type === 'zone') + .map((zone: ZoneNodeType) => ZoneNode.parse(zone)) + const zonePlan = planAutoZonesForLevel(spaces, zones) + if (zonePlan.update.length > 0) updateNodes(zonePlan.update) + + const existingSpaces = editorStore.getState().spaces as Record + const nextSpaces: Record = {} + for (const [spaceId, space] of Object.entries(existingSpaces)) { + if (space.levelId !== levelId) nextSpaces[spaceId] = space + } + for (const space of spaces) nextSpaces[space.id] = space + editorStore.getState().setSpaces(nextSpaces) +} + +// Refcount of outstanding pause requests, matching the pauseSceneHistory +// pattern. The community editor flips this off while the AI is actively +// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race +// `create_room`'s explicit slabs/ceilings (see plan +// `ai-pause-space-detection`). +let spaceDetectionPauseDepth = 0 + +/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */ +export function pauseSpaceDetection(): void { + spaceDetectionPauseDepth += 1 +} + +/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */ +export function resumeSpaceDetection(): void { + if (spaceDetectionPauseDepth === 0) return + spaceDetectionPauseDepth -= 1 +} + +/** True iff the wall-driven auto slab/ceiling sync is currently paused. */ +export function isSpaceDetectionPaused(): boolean { + return spaceDetectionPauseDepth > 0 +} + +export function initSpaceDetectionSync( + sceneStore: any, + editorStore: any, + options: SpaceDetectionSyncOptions = {}, +): () => void { + // Baseline from whatever is already in the store. Detection reacts to wall + // edits made IN-SESSION (create / move / delete); it must not re-litigate a + // scene that merely loaded — rerunning on hydration resurrected auto slabs + // the user had deleted in an earlier session. + const initialNodes = sceneStore.getState().nodes + const previousRoomsByLevel = new Map() + const topologyIndex = new RoomTopologyIndex({ + detectRooms: extractRooms, + sampleWall: (wall) => sampleWallPointsForRoomDetection(wall).map(pointToTuple), + junctionTolerance: WALL_JUNCTION_TOLERANCE, + }) + let previousNodes = initialNodes + let isProcessing = false + + const adoptSceneBaseline = (nodes: SceneNodes) => { + topologyIndex.rebuild(nodes) + const roomsByLevel = detectedRoomsByLevel(nodes) + previousRoomsByLevel.clear() + const spaces: Record = {} + for (const [levelId, rooms] of roomsByLevel) { + previousRoomsByLevel.set(levelId, rooms) + for (const room of rooms) { + const space = buildSpace(levelId, room) + spaces[space.id] = space + } + } + editorStore.getState().setSpaces(spaces) + previousNodes = nodes + } + + adoptSceneBaseline(initialNodes) + + const unsubscribeCommits = subscribeSceneCommits((commit) => { + if (commit.origin === 'local') return + adoptSceneBaseline(commit.current.nodes) + }) + + const unsubscribe = sceneStore.subscribe((state: any) => { + if (isProcessing) return + if (getSceneHistoryPauseDepth() > 0) return + + const nodes = state.nodes + const candidateIds = activeSceneCommitNodeIds() + + // Paused: roll the snapshot forward so we don't backfill (and re-duplicate) + // every paused change once detection resumes. Whatever the AI built while + // paused becomes the new baseline; only future changes will reconcile. + if (spaceDetectionPauseDepth > 0) { + adoptSceneBaseline(nodes) + return + } + + const changedWalls = changedWallIdsByLevel(previousNodes, nodes, candidateIds) + if (candidateIds && changedWalls.size > 0) { + const fallbackLevels = fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds) + for (const levelId of changedWalls.keys()) fallbackLevels.delete(levelId) + isProcessing = true + pauseSceneHistory(sceneStore) + try { + for (const [levelId, wallIds] of changedWalls) { + const topologyDelta = topologyIndex.applyWallDelta(levelId, wallIds, previousNodes, nodes) + runIndexedSpaceDetection( + levelId, + topologyDelta, + sceneStore, + editorStore, + nodes, + previousNodes, + ) + previousRoomsByLevel.set(levelId, topologyDelta.allCurrentRooms) + options.onTopologyReconcile?.({ + levelId, + strategy: topologyDelta.strategy, + examinedWallIds: topologyDelta.examinedWallIds, + affectedBeforeRoomCount: topologyDelta.beforeRooms.length, + affectedCurrentRoomCount: topologyDelta.currentRooms.length, + }) + } + if (fallbackLevels.size > 0) { + runSpaceDetection( + [...fallbackLevels], + sceneStore, + editorStore, + sceneStore.getState().nodes, + previousNodes, + previousRoomsByLevel, + ) + const liveNodes = sceneStore.getState().nodes + for (const levelId of fallbackLevels) topologyIndex.rebuildLevel(levelId, liveNodes) + } + } finally { + resumeSceneHistory(sceneStore) + previousNodes = sceneStore.getState().nodes + isProcessing = false + } + return + } + + const levelsToUpdate = new Set() + if (candidateIds) { + for (const levelId of fallbackLevelIdsForCandidates(previousNodes, nodes, candidateIds)) { + levelsToUpdate.add(levelId) + } + } else { + const previousSnapshots = levelStructureSnapshots(previousNodes) + const currentSnapshots = levelStructureSnapshots(nodes) + for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { + // First sight of a level is a hydration baseline, not a wall edit — + // `setScene` delivers a loaded scene as one atomic update, and a level's + // first wall can't close a room anyway. Record it (below) and only + // react to subsequent changes. + const previous = previousSnapshots.get(levelId) + if (previous === undefined) continue + if (previous !== (currentSnapshots.get(levelId) ?? '')) { + levelsToUpdate.add(levelId) + } + } + } + + if (levelsToUpdate.size === 0) { + if (candidateIds) { + previousNodes = nodes + return + } + const currentRoomsByLevel = detectedRoomsByLevel(nodes) + previousRoomsByLevel.clear() + for (const [levelId, rooms] of currentRoomsByLevel) { + previousRoomsByLevel.set(levelId, rooms) + } + previousNodes = nodes + return + } + + isProcessing = true + pauseSceneHistory(sceneStore) + try { + runSpaceDetection( + [...levelsToUpdate], + sceneStore, + editorStore, + nodes, + previousNodes, + previousRoomsByLevel, + ) + } finally { + resumeSceneHistory(sceneStore) + const liveNodes = sceneStore.getState().nodes + for (const levelId of levelsToUpdate) topologyIndex.rebuildLevel(levelId, liveNodes) + previousNodes = liveNodes + isProcessing = false + } + }) + + return () => { + unsubscribe() + unsubscribeCommits() + } +} + +export function wallTouchesOthers(wall: WallNode, otherWalls: WallNode[]): boolean { + const threshold = 0.1 + + for (const other of otherWalls) { + if (other.id === wall.id) continue + + if ( + distanceToSegment(wall.start, other.start, other.end) < threshold || + distanceToSegment(wall.end, other.start, other.end) < threshold || + distanceToSegment(other.start, wall.start, wall.end) < threshold || + distanceToSegment(other.end, wall.start, wall.end) < threshold + ) { + return true + } + } + + return false +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index d4afd49ffd..0c96096059 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -150,6 +150,11 @@ 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. + /** 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(), @@ -174,6 +179,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/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 871303c41b..a38c255062 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,118 +1,120 @@ -import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' -import type { AnyNode, AnyNodeId } from '../schema/types' -import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' -import { resolveWallTop } from '../systems/wall/wall-top' -// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: -// both sides only reference the other inside function bodies. -import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' - -export const DEFAULT_LEVEL_HEIGHT = 2.5 - -/** - * Effective ceiling height in level-local meters. An explicit stored - * `height` wins; absent height means the ceiling follows the level top — - * the same bound its write-clamp uses: min(storey plane, lowest - * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see - * {@link getCeilingClampBound}). Falls back to the default plane minus - * the same margin when the owning level is unresolvable. - */ -export function resolveCeilingHeight( - ceiling: Pick, - nodes: Record, -): number { - if (ceiling.height != null) return ceiling.height - const bound = - typeof ceiling.parentId === 'string' - ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) - : Number.POSITIVE_INFINITY - return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN -} - -export function deriveLegacyLevelHeight( - levelId: string, - nodes: Record, -): number { - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return DEFAULT_LEVEL_HEIGHT - - const levelChildren = level.children - .map((childId) => nodes[childId as keyof typeof nodes]) - .filter((child): child is AnyNode => child !== undefined) - const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') - const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') - - let maxTop = 0 - - for (const child of levelChildren) { - if (child.type === 'ceiling') { - // Absence here is the PRE-migration legacy schema default (2.5), not - // follows-mode — this derivation runs before the level has a height - // for a follows-mode bound to track. - const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } else if (child.type === 'wall') { - const wall = child as WallNode - const electedElevation = computeWallSlabSupport( - { - start: wall.start, - end: wall.end, - curveOffset: wall.curveOffset, - thickness: wall.thickness, - }, - slabs, - walls, - ).elevation - const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) - if (top > maxTop) maxTop = top - } - } - - return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT -} - -/** - * The ceiling covering level-local point `[x, z]`, or `null` when none - * sits over it. Points inside a ceiling's hole are treated as uncovered. - * When ceilings overlap, the lowest one wins — that's the surface a duct - * would actually hang from. - */ -export function getCeilingAt( - levelId: string, - nodes: Record, - x: number, - z: number, -): CeilingNode | null { - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return null - - let best: CeilingNode | null = null - let bestHeight = Number.POSITIVE_INFINITY - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (child?.type !== 'ceiling') continue - const ceiling = child as CeilingNode - if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue - if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue - const h = resolveCeilingHeight(ceiling, nodes) - if (best === null || h < bestHeight) { - best = ceiling - bestHeight = h - } - } - return best -} - -/** - * Underside elevation (meters above the level floor) of the ceiling - * covering level-local point `[x, z]`, or `null` when no ceiling sits - * over that point. See {@link getCeilingAt}. - */ -export function getCeilingHeightAt( - levelId: string, - nodes: Record, - x: number, - z: number, -): number | null { - const ceiling = getCeilingAt(levelId, nodes, x, z) - return ceiling ? resolveCeilingHeight(ceiling, nodes) : null -} +import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: +// both sides only reference the other inside function bodies. +import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' + +export const DEFAULT_LEVEL_HEIGHT = 2.5 + +/** + * Effective ceiling height in level-local meters. An explicit stored + * `height` wins; absent height means the ceiling follows the level top — + * the same bound its write-clamp uses: min(storey plane, lowest + * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see + * {@link getCeilingClampBound}). Falls back to the default plane minus + * the same margin when the owning level is unresolvable. + */ +export function resolveCeilingHeight( + ceiling: Pick, + nodes: Record, +): number { + if (ceiling.height != null) return ceiling.height + const bound = + typeof ceiling.parentId === 'string' + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN +} + +export function deriveLegacyLevelHeight( + levelId: string, + nodes: Record, +): number { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return DEFAULT_LEVEL_HEIGHT + + const levelChildren = level.children + .map((childId) => nodes[childId as keyof typeof nodes]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') + const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') + + let maxTop = 0 + + for (const child of levelChildren) { + if (child.type === 'ceiling') { + // Absence here is the PRE-migration legacy schema default (2.5), not + // follows-mode — this derivation runs before the level has a height + // for a follows-mode bound to track. + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height + } else if (child.type === 'wall') { + const wall = child as WallNode + const electedElevation = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const topStart = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 0) + const topEnd = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation, 1) + const top = Math.max(topStart, topEnd) + if (top > maxTop) maxTop = top + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + let bestHeight = Number.POSITIVE_INFINITY + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = resolveCeilingHeight(ceiling, nodes) + if (best === null || h < bestHeight) { + best = ceiling + bestHeight = h + } + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? resolveCeilingHeight(ceiling, nodes) : null +} diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 2bbf475da7..104b0d4a86 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -1,56 +1,70 @@ -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) { + 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 +} + +/** + * 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/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index fb84eb4e0e..dd7fc9f68f 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,565 +1,569 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - calculateLevelMiters, - getWallCurveLength, - getWallEffectiveHeightForNodes, - getWallMiterBoundaryPoints, - getWallPlanFootprint, - getWallSurfacePolygon, - type ItemNode, - isCurvedWall, - type Point2D, - pointToKey, - sampleWallCenterline, - sceneRegistry, - useScene, - type WallMiterData, - type WallNode, -} from '@pascal-app/core' -import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useMemo, useState } from 'react' -import * as THREE from 'three' -import { formatLinearMeasurement } from '../../lib/measurements' - -const GUIDE_Y_OFFSET = 0.08 -const LABEL_LIFT = 0.08 -const BAR_THICKNESS = 0.012 -const LINE_OPACITY = 0.95 -const HEIGHT_TICK_HALF_LENGTH = 0.14 -const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 - -const BAR_AXIS = new THREE.Vector3(0, 1, 0) -// Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length -// × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame. -// Per-frame `` triggers R3F to -// rebuild the geometry whenever the wall moves, and the WebGPU backend -// flags the in-flight buffer churn as "Vertex buffer slot N ... was not set". -const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) - -type Vec3 = [number, number, number] - -type MeasurementGuide = { - guidePath: Vec3[] - extStartStart: Vec3 - extStartEnd: Vec3 - extEndStart: Vec3 - extEndEnd: Vec3 - labelPosition: Vec3 - heightStart: Vec3 - heightEnd: Vec3 - heightBottomTickStart: Vec3 - heightBottomTickEnd: Vec3 - heightTopTickStart: Vec3 - heightTopTickEnd: Vec3 - heightLabelPosition: Vec3 -} - -type WallFaceLine = { - start: Point2D - end: Point2D -} - -export function WallMeasurementLabel() { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const nodes = useScene((state) => state.nodes) - - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null - const measurableNode = selectedNode?.type === 'item' ? selectedNode : null - - const [objectState, setObjectState] = useState<{ - id: AnyNodeId - object: THREE.Object3D - } | null>(null) - const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null - - useFrame(() => { - if (!selectedId || selectedObject) return - - const nextObject = sceneRegistry.nodes.get(selectedId) - if (nextObject) { - setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) - } - }) - - if (!(measurableNode && selectedObject)) return null - - return createPortal(, selectedObject) -} - -function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { - if (!wall.parentId) return [wall] - - const levelNode = nodes[wall.parentId as AnyNodeId] - if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) { - return [wall] - } - - return levelNode.children - .map((childId) => nodes[childId as AnyNodeId]) - .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) -} - -function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { - if (!point) return false - - return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 -} - -function getWallFaceLines( - wall: WallNode, - miterData: WallMiterData, -): { left: WallFaceLine; right: WallFaceLine } | null { - if (isCurvedWall(wall)) return null - - const footprint = getWallPlanFootprint(wall, miterData) - if (footprint.length < 4) return null - - const startRight = footprint[0] - const endRight = footprint[1] - const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) - const endLeft = footprint[hasEndCenterPoint ? 3 : 2] - const lastPoint = footprint[footprint.length - 1] - const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) - const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] - - if (!(startRight && endRight && endLeft && startLeft)) return null - - return { - left: { - start: startLeft, - end: endLeft, - }, - right: { - start: startRight, - end: endRight, - }, - } -} - -function getLineMidpoint(line: WallFaceLine): Point2D { - return { - x: (line.start.x + line.end.x) / 2, - y: (line.start.y + line.end.y) / 2, - } -} - -function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const candidateWall of levelWalls) { - minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) - maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) - minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) - maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) - } - - return { - x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, - y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, - } -} - -function getWallOuterFaceLine( - wall: WallNode, - miterData: WallMiterData, - levelWalls: WallNode[], -): WallFaceLine | null { - const faceLines = getWallFaceLines(wall, miterData) - if (!faceLines) return null - - if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { - return faceLines.left - } - - if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { - return faceLines.right - } - - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - if (length < 1e-6) return null - - const wallMidpoint = { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - } - const levelCenter = getLevelWallsCenter(levelWalls) - const normal = { x: -dy / length, y: dx / length } - const fromCenter = { - x: wallMidpoint.x - levelCenter.x, - y: wallMidpoint.y - levelCenter.y, - } - const outwardNormal = - fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } - const rightMidpoint = getLineMidpoint(faceLines.right) - const leftMidpoint = getLineMidpoint(faceLines.left) - const rightScore = - (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y - const leftScore = - (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + - (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y - - return rightScore >= leftScore ? faceLines.right : faceLines.left -} - -function getWallMiddlePoints( - wall: WallNode, - miterData: WallMiterData, -): { start: Point2D; end: Point2D } | null { - const footprint = getWallPlanFootprint(wall, miterData) - if (footprint.length < 4) return null - - const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) - const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) - - const rightStart = footprint[0] - const rightEnd = footprint[1] - const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2] - const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1] - - if (!(leftStart && leftEnd && rightStart && rightEnd)) return null - - return { - start: { - x: (leftStart.x + rightStart.x) / 2, - y: (leftStart.y + rightStart.y) / 2, - }, - end: { - x: (leftEnd.x + rightEnd.x) / 2, - y: (leftEnd.y + rightEnd.y) / 2, - }, - } -} - -function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { - const dx = point.x - wall.start[0] - const dz = point.y - wall.start[1] - const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) - const cosA = Math.cos(-angle) - const sinA = Math.sin(-angle) - - return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] -} - -function getWallExteriorOffsetSign( - wall: Pick, - levelWalls: WallNode[], -) { - if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { - return 1 - } - - if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { - return -1 - } - - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dy) - - if (length < 1e-6) return 1 - - const wallMidpoint = { - x: (wall.start[0] + wall.end[0]) / 2, - y: (wall.start[1] + wall.end[1]) / 2, - } - const levelCenter = getLevelWallsCenter(levelWalls) - const normal = { x: -dy / length, y: dx / length } - const fromCenter = { - x: wallMidpoint.x - levelCenter.x, - y: wallMidpoint.y - levelCenter.y, - } - - return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1 -} - -function getCurvedWallMeasurementPath( - wall: WallNode, - miterData: WallMiterData, - levelWalls: WallNode[], -): Point2D[] | null { - const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) - if (!boundaryPoints) return null - - const surface = getWallSurfacePolygon(wall, 24, boundaryPoints) - const sidePointCount = 25 - if (surface.length < sidePointCount * 2) return null - - const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) - if (offsetSign >= 0) { - return surface.slice(sidePointCount).reverse() - } - - return surface.slice(0, sidePointCount) -} - -function buildMeasurementGuide( - wall: WallNode, - nodes: Record, -): MeasurementGuide | null { - const levelWalls = getLevelWalls(wall, nodes) - const miterData = calculateLevelMiters(levelWalls) - const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) - const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) - const measurementPoints = measurementLine ?? fallbackMiddlePoints - if (!measurementPoints) return null - - const height = getWallEffectiveHeightForNodes(wall, nodes) - const startLocal = worldPointToWallLocal(wall, measurementPoints.start) - const endLocal = worldPointToWallLocal(wall, measurementPoints.end) - const curvedMeasurementPath = isCurvedWall(wall) - ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) - : null - const guidePath: Vec3[] = curvedMeasurementPath - ? curvedMeasurementPath.map((point) => { - const localPoint = worldPointToWallLocal(wall, point) - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] - }) - : isCurvedWall(wall) - ? sampleWallCenterline(wall, 24).map((point, index, points) => { - const localPoint = - index === 0 - ? startLocal - : index === points.length - 1 - ? endLocal - : worldPointToWallLocal(wall, point) - - return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]] - }) - : [ - [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]], - [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]], - ] - - if (guidePath.length < 2) return null - - let guideLength = 0 - for (let index = 1; index < guidePath.length; index += 1) { - const prev = guidePath[index - 1]! - const next = guidePath[index]! - guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2]) - } - - if (!Number.isFinite(guideLength) || guideLength < 0.001) return null - - // Extension lines coming out of the extremity markers of the wall - const extOvershoot = 0.04 - const guideStart = guidePath[0]! - const guideEnd = guidePath[guidePath.length - 1]! - const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal - const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal - const midpoint = curvedMeasurementPath - ? guidePath[Math.floor(guidePath.length / 2)]! - : ([ - (guideStart[0] + guideEnd[0]) / 2, - guideStart[1], - (guideStart[2] + guideEnd[2]) / 2, - ] as Vec3) - const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 - const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart - const tickDx = guideEnd[0] - beforeGuideEnd[0] - const tickDz = guideEnd[2] - beforeGuideEnd[2] - const tickLength = Math.hypot(tickDx, tickDz) - const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 - const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 - const tickUnitX = -tangentZ - const tickUnitZ = tangentX - const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) - const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] - const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] - const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 - const heightGuidePosition = [ - rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, - 0, - rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, - ] as Vec3 - const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ - start: [ - heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, - y, - heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, - ], - end: [ - heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, - y, - heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, - ], - }) - const bottomHeightTick = getHorizontalHeightTick(0) - const topHeightTick = getHorizontalHeightTick(height) - - return { - guidePath, - extStartStart: [extensionStartBase[0], height, extensionStartBase[2]], - extStartEnd: [ - extensionStartBase[0], - height + GUIDE_Y_OFFSET + extOvershoot, - extensionStartBase[2], - ], - extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], - extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], - labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], - heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], - heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], - heightBottomTickStart: bottomHeightTick.start, - heightBottomTickEnd: bottomHeightTick.end, - heightTopTickStart: topHeightTick.start, - heightTopTickEnd: topHeightTick.end, - heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], - } -} - -function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { - const segment = useMemo(() => { - const startVector = new THREE.Vector3(...start) - const endVector = new THREE.Vector3(...end) - const direction = endVector.clone().sub(startVector) - const length = direction.length() - - if (!Number.isFinite(length) || length < 0.0001) return null - - return { - length, - position: startVector.clone().add(endVector).multiplyScalar(0.5), - quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()), - } - }, [end, start]) - - if (!segment) return null - - return ( - - - - ) -} - -function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { - return ( - <> - {path.slice(1).map((point, index) => ( - - ))} - - ) -} - -function MeasurementLabel({ - label, - position, - color, - shadowColor, -}: { - label: string - position: Vec3 - color: string - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - -function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { - if (node.type === 'wall') { - return - } - - return null -} - -function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { - const nodes = useScene((state) => state.nodes) - const unit = useViewer((state) => state.unit) - const metricNotation = useViewer((state) => state.metricNotation) - const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') - const color = isNight ? '#ffffff' : '#111111' - const shadowColor = isNight ? '#111111' : '#ffffff' - - const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) - const length = useMemo(() => { - if (!guide?.guidePath?.length || guide.guidePath.length < 2) { - return getWallCurveLength(wall) - } - - let total = 0 - for (let index = 1; index < guide.guidePath.length; index += 1) { - const prev = guide.guidePath[index - 1]! - const next = guide.guidePath[index]! - total += Math.hypot(next[0] - prev[0], next[2] - prev[2]) - } - return total - }, [guide, wall]) - const label = formatLinearMeasurement(length, unit, metricNotation) - const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) - const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}` - - if (!(guide && Number.isFinite(length) && length >= 0.01)) return null - - return ( - - - - - - - - - - - - ) -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + calculateLevelMiters, + getWallCurveLength, + getWallEffectiveHeightForNodes, + getWallMiterBoundaryPoints, + getWallPlanFootprint, + getWallSurfacePolygon, + type ItemNode, + isCurvedWall, + type Point2D, + pointToKey, + sampleWallCenterline, + sceneRegistry, + useScene, + type WallMiterData, + type WallNode, +} from '@pascal-app/core' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { createPortal, useFrame } from '@react-three/fiber' +import { useMemo, useState } from 'react' +import * as THREE from 'three' +import { formatLinearMeasurement } from '../../lib/measurements' + +const GUIDE_Y_OFFSET = 0.08 +const LABEL_LIFT = 0.08 +const BAR_THICKNESS = 0.012 +const LINE_OPACITY = 0.95 +const HEIGHT_TICK_HALF_LENGTH = 0.14 +const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16 + +const BAR_AXIS = new THREE.Vector3(0, 1, 0) +// Shared unit cube — each MeasurementBar scales it to BAR_THICKNESS × length +// × BAR_THICKNESS instead of constructing a fresh BoxGeometry every frame. +// Per-frame `` triggers R3F to +// rebuild the geometry whenever the wall moves, and the WebGPU backend +// flags the in-flight buffer churn as "Vertex buffer slot N ... was not set". +const SHARED_BAR_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) + +type Vec3 = [number, number, number] + +type MeasurementGuide = { + guidePath: Vec3[] + extStartStart: Vec3 + extStartEnd: Vec3 + extEndStart: Vec3 + extEndEnd: Vec3 + labelPosition: Vec3 + heightStart: Vec3 + heightEnd: Vec3 + heightBottomTickStart: Vec3 + heightBottomTickEnd: Vec3 + heightTopTickStart: Vec3 + heightTopTickEnd: Vec3 + heightLabelPosition: Vec3 +} + +type WallFaceLine = { + start: Point2D + end: Point2D +} + +export function WallMeasurementLabel() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const nodes = useScene((state) => state.nodes) + + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null + const measurableNode = selectedNode?.type === 'item' ? selectedNode : null + + const [objectState, setObjectState] = useState<{ + id: AnyNodeId + object: THREE.Object3D + } | null>(null) + const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null + + useFrame(() => { + if (!selectedId || selectedObject) return + + const nextObject = sceneRegistry.nodes.get(selectedId) + if (nextObject) { + setObjectState({ id: selectedId as AnyNodeId, object: nextObject }) + } + }) + + if (!(measurableNode && selectedObject)) return null + + return createPortal(, selectedObject) +} + +function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { + if (!wall.parentId) return [wall] + + const levelNode = nodes[wall.parentId as AnyNodeId] + if (!(levelNode && levelNode.type === 'level' && Array.isArray(levelNode.children))) { + return [wall] + } + + return levelNode.children + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) +} + +function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) { + if (!point) return false + + return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6 +} + +function getWallFaceLines( + wall: WallNode, + miterData: WallMiterData, +): { left: WallFaceLine; right: WallFaceLine } | null { + if (isCurvedWall(wall)) return null + + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startRight = footprint[0] + const endRight = footprint[1] + const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end) + const endLeft = footprint[hasEndCenterPoint ? 3 : 2] + const lastPoint = footprint[footprint.length - 1] + const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start) + const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1] + + if (!(startRight && endRight && endLeft && startLeft)) return null + + return { + left: { + start: startLeft, + end: endLeft, + }, + right: { + start: startRight, + end: endRight, + }, + } +} + +function getLineMidpoint(line: WallFaceLine): Point2D { + return { + x: (line.start.x + line.end.x) / 2, + y: (line.start.y + line.end.y) / 2, + } +} + +function getLevelWallsCenter(levelWalls: WallNode[]): Point2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const candidateWall of levelWalls) { + minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0]) + maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0]) + minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1]) + maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1]) + } + + return { + x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2, + y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2, + } +} + +function getWallOuterFaceLine( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): WallFaceLine | null { + const faceLines = getWallFaceLines(wall, miterData) + if (!faceLines) return null + + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return faceLines.left + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return faceLines.right + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + const outwardNormal = + fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y } + const rightMidpoint = getLineMidpoint(faceLines.right) + const leftMidpoint = getLineMidpoint(faceLines.left) + const rightScore = + (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y + const leftScore = + (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x + + (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y + + return rightScore >= leftScore ? faceLines.right : faceLines.left +} + +function getWallMiddlePoints( + wall: WallNode, + miterData: WallMiterData, +): { start: Point2D; end: Point2D } | null { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 4) return null + + const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] }) + const startJunction = miterData.junctionData.get(startKey)?.get(wall.id) + + const rightStart = footprint[0] + const rightEnd = footprint[1] + const leftEnd = footprint[startJunction ? footprint.length - 3 : footprint.length - 2] + const leftStart = footprint[startJunction ? footprint.length - 2 : footprint.length - 1] + + if (!(leftStart && leftEnd && rightStart && rightEnd)) return null + + return { + start: { + x: (leftStart.x + rightStart.x) / 2, + y: (leftStart.y + rightStart.y) / 2, + }, + end: { + x: (leftEnd.x + rightEnd.x) / 2, + y: (leftEnd.y + rightEnd.y) / 2, + }, + } +} + +function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 { + const dx = point.x - wall.start[0] + const dz = point.y - wall.start[1] + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cosA = Math.cos(-angle) + const sinA = Math.sin(-angle) + + return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] +} + +function getWallExteriorOffsetSign( + wall: Pick, + levelWalls: WallNode[], +) { + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') { + return 1 + } + + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') { + return -1 + } + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-6) return 1 + + const wallMidpoint = { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const levelCenter = getLevelWallsCenter(levelWalls) + const normal = { x: -dy / length, y: dx / length } + const fromCenter = { + x: wallMidpoint.x - levelCenter.x, + y: wallMidpoint.y - levelCenter.y, + } + + return fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? 1 : -1 +} + +function getCurvedWallMeasurementPath( + wall: WallNode, + miterData: WallMiterData, + levelWalls: WallNode[], +): Point2D[] | null { + const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) + if (!boundaryPoints) return null + + const surface = getWallSurfacePolygon(wall, 24, boundaryPoints) + const sidePointCount = 25 + if (surface.length < sidePointCount * 2) return null + + const offsetSign = getWallExteriorOffsetSign(wall, levelWalls) + if (offsetSign >= 0) { + return surface.slice(sidePointCount).reverse() + } + + return surface.slice(0, sidePointCount) +} + +function buildMeasurementGuide( + wall: WallNode, + nodes: Record, +): MeasurementGuide | null { + const levelWalls = getLevelWalls(wall, nodes) + const miterData = calculateLevelMiters(levelWalls) + const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls) + const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData) + const measurementPoints = measurementLine ?? fallbackMiddlePoints + if (!measurementPoints) return null + + const height = getWallEffectiveHeightForNodes(wall, nodes) + const startLocal = worldPointToWallLocal(wall, measurementPoints.start) + const endLocal = worldPointToWallLocal(wall, measurementPoints.end) + const curvedMeasurementPath = isCurvedWall(wall) + ? getCurvedWallMeasurementPath(wall, miterData, levelWalls) + : null + const guidePath: Vec3[] = curvedMeasurementPath + ? curvedMeasurementPath.map((point, index, points) => { + const localPoint = worldPointToWallLocal(wall, point) + const t = points.length > 1 ? index / (points.length - 1) : 0 + const h = getWallEffectiveHeightForNodes(wall, nodes, t) + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] + }) + : isCurvedWall(wall) + ? sampleWallCenterline(wall, 24).map((point, index, points) => { + const localPoint = + index === 0 + ? startLocal + : index === points.length - 1 + ? endLocal + : worldPointToWallLocal(wall, point) + const t = points.length > 1 ? index / (points.length - 1) : 0 + const h = getWallEffectiveHeightForNodes(wall, nodes, t) + + return [localPoint[0], h + GUIDE_Y_OFFSET, localPoint[2]] + }) + : [ + [startLocal[0], getWallEffectiveHeightForNodes(wall, nodes, 0) + GUIDE_Y_OFFSET, startLocal[2]], + [endLocal[0], getWallEffectiveHeightForNodes(wall, nodes, 1) + GUIDE_Y_OFFSET, endLocal[2]], + ] + + if (guidePath.length < 2) return null + + let guideLength = 0 + for (let index = 1; index < guidePath.length; index += 1) { + const prev = guidePath[index - 1]! + const next = guidePath[index]! + guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2]) + } + + if (!Number.isFinite(guideLength) || guideLength < 0.001) return null + + // Extension lines coming out of the extremity markers of the wall + const extOvershoot = 0.04 + const guideStart = guidePath[0]! + const guideEnd = guidePath[guidePath.length - 1]! + const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal + const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal + const midpoint = curvedMeasurementPath + ? guidePath[Math.floor(guidePath.length / 2)]! + : ([ + (guideStart[0] + guideEnd[0]) / 2, + guideStart[1], + (guideStart[2] + guideEnd[2]) / 2, + ] as Vec3) + const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3 + const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart + const tickDx = guideEnd[0] - beforeGuideEnd[0] + const tickDz = guideEnd[2] - beforeGuideEnd[2] + const tickLength = Math.hypot(tickDx, tickDz) + const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1 + const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0 + const tickUnitX = -tangentZ + const tickUnitZ = tangentX + const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] }) + const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0] + const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2] + const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1 + const heightGuidePosition = [ + rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + 0, + rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET, + ] as Vec3 + const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({ + start: [ + heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + end: [ + heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH, + y, + heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH, + ], + }) + const bottomHeightTick = getHorizontalHeightTick(0) + const topHeightTick = getHorizontalHeightTick(height) + + return { + guidePath, + extStartStart: [extensionStartBase[0], height, extensionStartBase[2]], + extStartEnd: [ + extensionStartBase[0], + height + GUIDE_Y_OFFSET + extOvershoot, + extensionStartBase[2], + ], + extEndStart: [extensionEndBase[0], height, extensionEndBase[2]], + extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]], + labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]], + heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]], + heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]], + heightBottomTickStart: bottomHeightTick.start, + heightBottomTickEnd: bottomHeightTick.end, + heightTopTickStart: topHeightTick.start, + heightTopTickEnd: topHeightTick.end, + heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]], + } +} + +function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { + const segment = useMemo(() => { + const startVector = new THREE.Vector3(...start) + const endVector = new THREE.Vector3(...end) + const direction = endVector.clone().sub(startVector) + const length = direction.length() + + if (!Number.isFinite(length) || length < 0.0001) return null + + return { + length, + position: startVector.clone().add(endVector).multiplyScalar(0.5), + quaternion: new THREE.Quaternion().setFromUnitVectors(BAR_AXIS, direction.normalize()), + } + }, [end, start]) + + if (!segment) return null + + return ( + + + + ) +} + +function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) { + return ( + <> + {path.slice(1).map((point, index) => ( + + ))} + + ) +} + +function MeasurementLabel({ + label, + position, + color, + shadowColor, +}: { + label: string + position: Vec3 + color: string + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} + +function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) { + if (node.type === 'wall') { + return + } + + return null +} + +function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { + const nodes = useScene((state) => state.nodes) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const color = isNight ? '#ffffff' : '#111111' + const shadowColor = isNight ? '#111111' : '#ffffff' + + const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) + const length = useMemo(() => { + if (!guide?.guidePath?.length || guide.guidePath.length < 2) { + return getWallCurveLength(wall) + } + + let total = 0 + for (let index = 1; index < guide.guidePath.length; index += 1) { + const prev = guide.guidePath[index - 1]! + const next = guide.guidePath[index]! + total += Math.hypot(next[0] - prev[0], next[2] - prev[2]) + } + return total + }, [guide, wall]) + const label = formatLinearMeasurement(length, unit, metricNotation) + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}` + + if (!(guide && Number.isFinite(length) && length >= 0.01)) return null + + return ( + + + + + + + + + + + + ) +} diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 6e6165d33f..06cbafc2f8 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -1,1045 +1,1049 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - type FenceNode, - getWallBaseElevationForNodes, - getWallCurveFrameAt, - getWallEffectiveHeightForNodes, - getWallThickness, - isCurvedWall, - MIN_WALL_HEIGHT, - sceneRegistry, - useLiveNodeOverrides, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef, useState } from 'react' -import { - BufferGeometry, - Color, - CylinderGeometry, - DoubleSide, - ExtrudeGeometry, - type Group, - type Object3D, - OrthographicCamera, - Plane, - Quaternion, - Shape, - Vector2, - Vector3, -} from 'three' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' -import { MeshBasicNodeMaterial } from 'three/webgpu' -import { - clearStructuralElevationGuide, - publishStructuralElevationGuide, - resolveStructuralElevationSnap, -} from '../../lib/elevation-guides' -import { isHistoryShortcut } from '../../lib/history' -import { endpointReshapeScope } from '../../lib/interaction/scope' -import { sfxEmitter } from '../../lib/sfx-bus' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' -import useInteractionScope, { - useEndpointReshape, - useIsCurveReshape, - useMovingNode, -} from '../../store/use-interaction-scope' -import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' -import { resolveResizeSnapValue } from './handles/resize-snap' -import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' -import { - createArrowHitAreaGeometry, - createEndpointHitAreaGeometry, - HandleArrow, - InvisibleHandleHitArea, - NO_RAYCAST, - useInvisibleHitAreaMaterial, -} from './node-arrow-handles' - -const HANDLE_OFFSET = 0.27 -const HANDLE_MIN_OFFSET = 0.33 -const HANDLE_MIN_HEIGHT = 0.4 -const HANDLE_TOP_INSET = 0.08 -const HEIGHT_HANDLE_OFFSET = 0.26 -const ARROW_COLOR = '#8381ed' -const ARROW_HOVER_COLOR = '#a5b4fc' -// Match the door arrows: scale the rendered chevron down to ~two-thirds -// so the in-world handles read as a single UI family. -const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.11 -const CORNER_DASH_SIZE = 0.1 -const CORNER_GAP_SIZE = 0.07 -const CORNER_DASH_THICKNESS = 0.006 -const CORNER_FLOOR_OFFSET = 0.01 - -type WallMoveHandle = { - key: string - position: [number, number, number] - rotationY: number -} - -// Pre-empt the synthetic `click` the browser fires immediately after a -// drag's pointerup. Without this, PointerMissedHandler treats the click -// as "missed" and deselects the wall when the height arrow drag commits. -function swallowNextClick() { - const swallow = (clickEvent: Event) => { - clickEvent.stopPropagation() - clickEvent.preventDefault() - } - window.addEventListener('click', swallow, { capture: true, once: true }) - setTimeout(() => { - window.removeEventListener('click', swallow, { capture: true }) - }, 300) -} - -function createArrowHandleGeometry() { - // Classic arrow silhouette — chevron head + rectangular shaft — extruded - // slightly so the handle reads as a 3D plate but stays visually light. - const shape = new Shape() - shape.moveTo(0.22, 0) - shape.lineTo(-0.04, 0.12) - shape.lineTo(-0.04, 0.035) - shape.lineTo(-0.2, 0.035) - shape.lineTo(-0.2, -0.035) - shape.lineTo(-0.04, -0.035) - shape.lineTo(-0.04, -0.12) - shape.lineTo(0.22, 0) - - const geometry = new ExtrudeGeometry(shape, { - depth: 0.045, - bevelEnabled: true, - bevelThickness: 0.018, - bevelSize: 0.02, - bevelOffset: 0, - bevelSegments: 8, - curveSegments: 16, - steps: 1, - }) - - // Centre the extruded plate around y=0 and re-orient it so the depth - // axis points up: the chevron lies flat in the XZ plane, tip along +X, - // wings spread across ±Z. - geometry.translate(0, 0, -0.0225) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - geometry.computeBoundingSphere() - return geometry -} - -export function WallMoveSideHandles() { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const mode = useEditor((state) => state.mode) - const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) - const movingNode = useMovingNode() - const endpointReshape = useEndpointReshape() - const isCurveReshape = useIsCurveReshape() - - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - // Fence side-move / height / corner-pickers now flow through the - // registry handle path (see packages/nodes/src/fence/definition.ts). - // Only walls still need the legacy renderer here — the registry path - // didn't render correctly for walls specifically and was reverted in - // commit 0e207a7f; revisit once that's diagnosed. - const selectedNode = useScene((state) => { - const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null - return node?.type === 'wall' ? node : null - }) - - const shouldRender = - Boolean(selectedNode) && - !isFloorplanHovered && - mode !== 'delete' && - !movingNode && - !endpointReshape && - !isCurveReshape - - if (!shouldRender || !selectedNode) return null - - return -} - -function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { - const nodes = useScene((state) => state.nodes) - // Merge the in-flight drag override so every handle (side-move arrows, - // height arrow, corner leaders) tracks the live height in real time - // during a height drag — the scene store stays at the pre-drag value - // until commit, so reading `wall` alone would freeze them. Same pattern - // as node-arrow-handles. - const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) - const effectiveWall = useMemo( - () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), - [wall, liveOverride], - ) - - const [levelObject, setLevelObject] = useState(() => - wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, - ) - - useEffect(() => { - let frameId = 0 - - const resolveLevelObject = () => { - const nextLevelObject = wall.parentId - ? (sceneRegistry.nodes.get(wall.parentId) ?? null) - : null - setLevelObject((currentLevelObject) => { - if (currentLevelObject === nextLevelObject) { - return currentLevelObject - } - return nextLevelObject - }) - - if (!nextLevelObject) { - frameId = window.requestAnimationFrame(resolveLevelObject) - } - } - - resolveLevelObject() - - return () => { - if (frameId) { - window.cancelAnimationFrame(frameId) - } - } - }, [wall.parentId]) - - const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) - const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes]) - - if (!levelObject || handles.length === 0) return null - - return createPortal( - <> - - {handles.map((handle) => ( - - ))} - - - - - - , - levelObject, - ) -} - -function buildDashedVerticalGeometry(height: number) { - if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry() - - // Build each dash as a thin cylinder section so thickness is - // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU. - const dashes: BufferGeometry[] = [] - let y = 0 - while (y < height) { - const end = Math.min(y + CORNER_DASH_SIZE, height) - const length = end - y - const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8) - cylinder.translate(0, y + length / 2, 0) - dashes.push(cylinder) - y = end + CORNER_GAP_SIZE - } - const merged = - dashes.length > 0 - ? (mergeGeometries(dashes, false) ?? new BufferGeometry()) - : new BufferGeometry() - for (const dash of dashes) dash.dispose() - return merged -} - -function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) { - const [isHovered, setIsHovered] = useState(false) - const { camera } = useThree() - const billboardRef = useRef(null) - const parentWorldQuaternionRef = useRef(new Quaternion()) - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom - const visualScale = isHovered ? 1.25 : 1 - - const corner = endpoint === 'start' ? wall.start : wall.end - const x = corner[0] - const z = corner[1] - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - - const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) - const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) - const hitMaterial = useInvisibleHitAreaMaterial() - useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - - // Node materials matched to the rest of the file — mixing plain - // `meshBasicMaterial` with WebGPU node materials trips - // "Color target has no corresponding fragment stage output". - const dashMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - transparent: true, - opacity: 0.85, - depthTest: false, - depthWrite: false, - }), - [], - ) - const hexMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 0.95, - depthTest: false, - depthWrite: false, - }), - [], - ) - const ringMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 1, - depthTest: false, - depthWrite: false, - }), - [], - ) - - useEffect(() => { - const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR - dashMaterial.color.set(next) - hexMaterial.color.set(next) - ringMaterial.color.set(next) - }, [dashMaterial, hexMaterial, ringMaterial, isHovered]) - - useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - useEffect(() => () => hexMaterial.dispose(), [hexMaterial]) - useEffect(() => () => ringMaterial.dispose(), [ringMaterial]) - - // Billboard the hex disc to the camera so the picker is always - // recognisable regardless of viewing angle. - // - // Why parent-aware: the disc lives under a `createPortal` into the - // level object, which itself sits under a building. Both can have - // non-identity world rotations. `quaternion.copy(camera.quaternion)` - // alone sets the LOCAL quaternion, so any ancestor rotation rotates - // the disc away from the camera. We instead solve for a local - // quaternion whose composition with the parent world quaternion - // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`. - useFrame(() => { - const billboard = billboardRef.current - if (!billboard) return - billboard.quaternion.copy(camera.quaternion) - const parent = billboard.parent - if (parent) { - parent.getWorldQuaternion(parentWorldQuaternionRef.current) - billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert()) - } - }) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - const activateEndpointMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - sfxEmitter.emit('sfx:item-pick') - document.body.style.cursor = 'grabbing' - useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint)) - } - - return ( - <> - - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={1} - /> - - - - - - - - - - - ) -} - -function WallBaseElevationHandle({ - wall, - baseElevation, - levelObject, -}: { - wall: WallNode - baseElevation: number - levelObject: Object3D -}) { - const [isHovered, setIsHovered] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const { camera } = useThree() - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const midpoint: [number, number] = curveFrame - ? [curveFrame.point.x, curveFrame.point.y] - : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const elevationGuideSource = { - nodeId: wall.id, - levelId: wall.parentId, - anchor: midpoint, - } - const leaderBottom = Math.min(0, baseElevation) - const leaderHeight = Math.abs(baseElevation) - const dashedGeometry = useMemo( - () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null), - [leaderHeight], - ) - const dashMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - transparent: true, - opacity: 0.85, - depthTest: false, - depthWrite: false, - }), - [], - ) - - const isActive = isHovered || isDragging - useEffect(() => { - dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [dashMaterial, isActive]) - useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) - useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - const dragControls = useMemo( - () => ({ - onStart: () => {}, - onEnd: () => {}, - }), - [], - ) - - const activateElevationMove = useHandleDrag({ - kind: 'drag', - cursor: 'ns-resize', - dragControls, - handleIndex: 0, - node: wall, - rideObject: levelObject, - setIsDragging, - onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => { - if (initialNode.type !== 'wall') return null - - levelObject.updateWorldMatrix(true, false) - const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes()) - const supportBase = initialBase - (initialNode.supportOffset ?? 0) - const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes()) - const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( - levelObject.matrixWorld, - ) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) - if (planeNormal.lengthSq() === 0) return null - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) - const initialHit = new Vector3() - if ( - !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit) - ) { - return null - } - const initialPointerY = levelObject.worldToLocal(initialHit).y - - return { - onBegin: () => { - useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' }) - }, - onEnd: () => { - useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') - clearStructuralElevationGuide(initialNode.id) - }, - move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { - const hit = new Vector3() - if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null - const pointerY = levelObject.worldToLocal(hit).y - const nextBase = resolveResizeSnapValue({ - rawValue: initialBase + pointerY - initialPointerY, - gridSnapEnabled: true, - gridSnapActive: isGridSnapActive(), - gridSnapStep: useEditor.getState().gridSnapStep, - magneticSnapActive: isMagneticSnapActive(), - magneticSnap: (value) => - resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()), - }) - publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes()) - if (Math.abs(nextBase - initialBase) < 1e-6) { - return { - supportOffset: initialNode.supportOffset, - height: initialNode.height, - } - } - const nextOffset = nextBase - supportBase - return { - supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, - height: initialHeight, - } - }, - } - }, - }) - - return ( - <> - {dashedGeometry ? ( - - ) : null} - - - ) -} - -function WallHeightArrowHandle({ wall }: { wall: WallNode }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera, raycaster, gl } = useThree() - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - const dragCleanupRef = useRef<(() => void) | null>(null) - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - dragCleanupRef.current?.() - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - // Sit on the visual centre of the wall — for curved walls that's the - // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for - // the yaw so the arrow's local frame matches the wall direction at the - // apex, consistent with `getWallMoveHandles`. - const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2 - const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2 - const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] - const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] - const wallAngle = Math.atan2(-dirZ, dirX) - // `wall` is the override-merged effective wall (see - // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - const handleY = wallHeight + HEIGHT_HANDLE_OFFSET - - const activateHeightResize = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null - if (!levelObject) return - - // Vertical plane through the wall midpoint whose normal points toward - // the camera (projected to horizontal). Raycasting against it converts - // pointer movement into a world-space Y value. - const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) - if (planeNormal.lengthSq() === 0) return - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) - - const ndc = new Vector2() - const setNDC = (clientX: number, clientY: number) => { - const rect = gl.domElement.getBoundingClientRect() - ndc.set( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ) - } - - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return - - // Dragging the top makes the wall custom-height; seed from the resolved - // effective height so a plane-bound wall's drag starts at its real top. - const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - const initialY = hit.y - const wallId = wall.id as AnyNodeId - let pendingHeight = initialHeight - - document.body.style.cursor = 'ns-resize' - sfxEmitter.emit('sfx:item-pick') - useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' }) - // Suppress R3F node pointer events until pointerup completes so the - // synthesized click doesn't reroute selection to whatever mesh sits - // under the cursor at release. - useViewer.getState().setInputDragging(true) - useScene.temporal.getState().pause() - - // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks - // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against - // the override-merged value (via `getEffectiveWall`). Zustand stays - // at the pre-drag height until pointerup commits one tracked write. - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - const intersection = new Vector3() - if (!raycaster.ray.intersectPlane(plane, intersection)) return - const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) - pendingHeight = newHeight - useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) - useScene.getState().markDirty(wallId) - } - - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onCancel) - window.removeEventListener('keydown', onKeyDown, true) - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - useScene.temporal.getState().resume() - useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') - useViewer.getState().setInputDragging(false) - dragCleanupRef.current = null - } - const onUp = () => { - swallowNextClick() - sfxEmitter.emit('sfx:item-place') - // Commit: write the final override-merged value to zustand once - // (tracked, undoable), then drop the override so the renderer - // falls back to the scene store. - if (pendingHeight !== initialHeight) { - useScene.getState().updateNode(wallId, { height: pendingHeight }) - } - useLiveNodeOverrides.getState().clear(wallId) - useScene.getState().markDirty(wallId) - cleanup() - } - const onCancel = () => { - // Revert: drop the override, mark dirty so the geometry rebuilds - // against the original scene height. - useLiveNodeOverrides.getState().clear(wallId) - useScene.getState().markDirty(wallId) - cleanup() - } - - // Escape / ⌘Z abort the drag — capture phase so they win over the global - // use-keyboard arms (⌘Z must never history-jump under a live pointer). - const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape' && !isHistoryShortcut(e)) return - e.preventDefault() - e.stopPropagation() - swallowNextClick() - onCancel() - } - - dragCleanupRef.current = cleanup - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) - window.addEventListener('keydown', onKeyDown, true) - } - - return ( - - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'ns-resize' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'ns-resize') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - - - - ) -} - -function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera } = useThree() - - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const activateWallMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - document.body.style.cursor = 'grabbing' - - sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(wall) - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') - // Keep the wall selected so it stays the active item once the move - // commits; the `!movingNode` guard on the handles hides them mid-drag. - } - - return ( - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - `) - // so the mesh is never rendered with R3F's default empty - // `BufferGeometry`. Combined with `frustumCulled={false}`, the - // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first - // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". - frustumCulled={false} - geometry={arrowGeometry} - material={arrowMaterial} - raycast={NO_RAYCAST} - renderOrder={1002} - scale={scale} - /> - - ) -} - -function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { - const [isHovered, setIsHovered] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) - const hitMaterial = useInvisibleHitAreaMaterial() - const arrowMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - depthTest: false, - depthWrite: false, - transparent: true, - opacity: 1, - }), - [], - ) - const { camera } = useThree() - - const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const baseScale = zoom * ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * baseScale - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - - useEffect(() => { - return () => { - if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { - document.body.style.cursor = '' - } - } - }, []) - - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const activateFenceMove = (event: ThreeEvent) => { - if (event.button !== 0) return - event.stopPropagation() - suppressBoxSelectForPointer(event) - document.body.style.cursor = 'grabbing' - - sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(fence) - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') - useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') - // Keep the fence selected so it stays active once the move commits. - } - - return ( - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = 'grab' - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === 'grab') { - document.body.style.cursor = '' - } - }} - scale={baseScale} - /> - - - ) -} - -function getWallMoveHandles(wall: WallNode, nodes: Record): WallMoveHandle[] { - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) - - if (length < 1e-6) { - return [] - } - - const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null - const normal: [number, number] = frame - ? [frame.normal.x, frame.normal.y] - : [-dz / length, dx / length] - const midpoint: [number, number] = frame - ? [frame.point.x, frame.point.y] - : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = getWallEffectiveHeightForNodes(wall, nodes) - const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) - const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) - - return [ - buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), - buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), - ] -} - -function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { - const [levelObject, setLevelObject] = useState(() => - fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, - ) - - useEffect(() => { - let frameId = 0 - - const resolveLevelObject = () => { - const nextLevelObject = fence.parentId - ? (sceneRegistry.nodes.get(fence.parentId) ?? null) - : null - setLevelObject((currentLevelObject) => { - if (currentLevelObject === nextLevelObject) { - return currentLevelObject - } - return nextLevelObject - }) - - if (!nextLevelObject) { - frameId = window.requestAnimationFrame(resolveLevelObject) - } - } - - resolveLevelObject() - - return () => { - if (frameId) { - window.cancelAnimationFrame(frameId) - } - } - }, [fence.parentId]) - - const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) - - if (!levelObject || handles.length === 0) return null - - return createPortal( - - {handles.map((handle) => ( - - ))} - , - levelObject, - ) -} - -function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { - const dx = fence.end[0] - fence.start[0] - const dz = fence.end[1] - fence.start[1] - const length = Math.hypot(dx, dz) - - if (length < 1e-6) { - return [] - } - - const midpoint: [number, number] = [ - (fence.start[0] + fence.end[0]) / 2, - (fence.start[1] + fence.end[1]) / 2, - ] - const normal: [number, number] = [-dz / length, dx / length] - const fenceHeight = fence.height ?? 1.8 - const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) - const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) - - return [ - buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), - buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), - ] -} - -function buildWallMoveHandle( - key: string, - midpoint: [number, number], - direction: [number, number], - offset: number, - height: number, -): WallMoveHandle { - return { - key, - position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], - rotationY: Math.atan2(-direction[1], direction[0]), - } -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + type FenceNode, + getWallBaseElevationForNodes, + getWallCurveFrameAt, + getWallEffectiveHeightForNodes, + getWallThickness, + isCurvedWall, + MIN_WALL_HEIGHT, + sceneRegistry, + useLiveNodeOverrides, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + BufferGeometry, + Color, + CylinderGeometry, + DoubleSide, + ExtrudeGeometry, + type Group, + type Object3D, + OrthographicCamera, + Plane, + Quaternion, + Shape, + Vector2, + Vector3, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { + clearStructuralElevationGuide, + publishStructuralElevationGuide, + resolveStructuralElevationSnap, +} from '../../lib/elevation-guides' +import { isHistoryShortcut } from '../../lib/history' +import { endpointReshapeScope } from '../../lib/interaction/scope' +import { sfxEmitter } from '../../lib/sfx-bus' +import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' +import useInteractionScope, { + useEndpointReshape, + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' +import { resolveResizeSnapValue } from './handles/resize-snap' +import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' +import { + createArrowHitAreaGeometry, + createEndpointHitAreaGeometry, + HandleArrow, + InvisibleHandleHitArea, + NO_RAYCAST, + useInvisibleHitAreaMaterial, +} from './node-arrow-handles' + +const HANDLE_OFFSET = 0.27 +const HANDLE_MIN_OFFSET = 0.33 +const HANDLE_MIN_HEIGHT = 0.4 +const HANDLE_TOP_INSET = 0.08 +const HEIGHT_HANDLE_OFFSET = 0.26 +const ARROW_COLOR = '#8381ed' +const ARROW_HOVER_COLOR = '#a5b4fc' +// Match the door arrows: scale the rendered chevron down to ~two-thirds +// so the in-world handles read as a single UI family. +const ARROW_SCALE = 0.65 +const CORNER_HEX_RADIUS = 0.11 +const CORNER_DASH_SIZE = 0.1 +const CORNER_GAP_SIZE = 0.07 +const CORNER_DASH_THICKNESS = 0.006 +const CORNER_FLOOR_OFFSET = 0.01 + +type WallMoveHandle = { + key: string + position: [number, number, number] + rotationY: number +} + +// Pre-empt the synthetic `click` the browser fires immediately after a +// drag's pointerup. Without this, PointerMissedHandler treats the click +// as "missed" and deselects the wall when the height arrow drag commits. +function swallowNextClick() { + const swallow = (clickEvent: Event) => { + clickEvent.stopPropagation() + clickEvent.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => { + window.removeEventListener('click', swallow, { capture: true }) + }, 300) +} + +function createArrowHandleGeometry() { + // Classic arrow silhouette — chevron head + rectangular shaft — extruded + // slightly so the handle reads as a 3D plate but stays visually light. + const shape = new Shape() + shape.moveTo(0.22, 0) + shape.lineTo(-0.04, 0.12) + shape.lineTo(-0.04, 0.035) + shape.lineTo(-0.2, 0.035) + shape.lineTo(-0.2, -0.035) + shape.lineTo(-0.04, -0.035) + shape.lineTo(-0.04, -0.12) + shape.lineTo(0.22, 0) + + const geometry = new ExtrudeGeometry(shape, { + depth: 0.045, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.02, + bevelOffset: 0, + bevelSegments: 8, + curveSegments: 16, + steps: 1, + }) + + // Centre the extruded plate around y=0 and re-orient it so the depth + // axis points up: the chevron lies flat in the XZ plane, tip along +X, + // wings spread across ±Z. + geometry.translate(0, 0, -0.0225) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +export function WallMoveSideHandles() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const mode = useEditor((state) => state.mode) + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) + const movingNode = useMovingNode() + const endpointReshape = useEndpointReshape() + const isCurveReshape = useIsCurveReshape() + + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + // Fence side-move / height / corner-pickers now flow through the + // registry handle path (see packages/nodes/src/fence/definition.ts). + // Only walls still need the legacy renderer here — the registry path + // didn't render correctly for walls specifically and was reverted in + // commit 0e207a7f; revisit once that's diagnosed. + const selectedNode = useScene((state) => { + const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null + return node?.type === 'wall' ? node : null + }) + + const shouldRender = + Boolean(selectedNode) && + !isFloorplanHovered && + mode !== 'delete' && + !movingNode && + !endpointReshape && + !isCurveReshape + + if (!shouldRender || !selectedNode) return null + + return +} + +function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + const nodes = useScene((state) => state.nodes) + // Merge the in-flight drag override so every handle (side-move arrows, + // height arrow, corner leaders) tracks the live height in real time + // during a height drag — the scene store stays at the pre-drag value + // until commit, so reading `wall` alone would freeze them. Same pattern + // as node-arrow-handles. + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) + const effectiveWall = useMemo( + () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), + [wall, liveOverride], + ) + + const [levelObject, setLevelObject] = useState(() => + wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = wall.parentId + ? (sceneRegistry.nodes.get(wall.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [wall.parentId]) + + const baseElevation = getWallBaseElevationForNodes(effectiveWall, nodes) + const handles = useMemo(() => getWallMoveHandles(effectiveWall, nodes), [effectiveWall, nodes]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + <> + + {handles.map((handle) => ( + + ))} + + + + + + , + levelObject, + ) +} + +function buildDashedVerticalGeometry(height: number) { + if (!(Number.isFinite(height) && height > 0)) return new BufferGeometry() + + // Build each dash as a thin cylinder section so thickness is + // controllable — native `lineSegments` lock to 1px on WebGL/WebGPU. + const dashes: BufferGeometry[] = [] + let y = 0 + while (y < height) { + const end = Math.min(y + CORNER_DASH_SIZE, height) + const length = end - y + const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8) + cylinder.translate(0, y + length / 2, 0) + dashes.push(cylinder) + y = end + CORNER_GAP_SIZE + } + const merged = + dashes.length > 0 + ? (mergeGeometries(dashes, false) ?? new BufferGeometry()) + : new BufferGeometry() + for (const dash of dashes) dash.dispose() + return merged +} + +function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: 'start' | 'end' }) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const billboardRef = useRef(null) + const parentWorldQuaternionRef = useRef(new Quaternion()) + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + const visualScale = isHovered ? 1.25 : 1 + + const corner = endpoint === 'start' ? wall.start : wall.end + const x = corner[0] + const z = corner[1] + const wallHeight = getWallEffectiveHeightForNodes( + wall, + useScene.getState().nodes, + endpoint === 'start' ? 0 : 1, + ) + + const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) + const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) + const hitMaterial = useInvisibleHitAreaMaterial() + useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + + // Node materials matched to the rest of the file — mixing plain + // `meshBasicMaterial` with WebGPU node materials trips + // "Color target has no corresponding fragment stage output". + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + const hexMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 0.95, + depthTest: false, + depthWrite: false, + }), + [], + ) + const ringMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + + useEffect(() => { + const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + dashMaterial.color.set(next) + hexMaterial.color.set(next) + ringMaterial.color.set(next) + }, [dashMaterial, hexMaterial, ringMaterial, isHovered]) + + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + useEffect(() => () => hexMaterial.dispose(), [hexMaterial]) + useEffect(() => () => ringMaterial.dispose(), [ringMaterial]) + + // Billboard the hex disc to the camera so the picker is always + // recognisable regardless of viewing angle. + // + // Why parent-aware: the disc lives under a `createPortal` into the + // level object, which itself sits under a building. Both can have + // non-identity world rotations. `quaternion.copy(camera.quaternion)` + // alone sets the LOCAL quaternion, so any ancestor rotation rotates + // the disc away from the camera. We instead solve for a local + // quaternion whose composition with the parent world quaternion + // equals the camera's: `local = parentWorld⁻¹ · cameraWorld`. + useFrame(() => { + const billboard = billboardRef.current + if (!billboard) return + billboard.quaternion.copy(camera.quaternion) + const parent = billboard.parent + if (parent) { + parent.getWorldQuaternion(parentWorldQuaternionRef.current) + billboard.quaternion.premultiply(parentWorldQuaternionRef.current.invert()) + } + }) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + const activateEndpointMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + sfxEmitter.emit('sfx:item-pick') + document.body.style.cursor = 'grabbing' + useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint)) + } + + return ( + <> + + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={1} + /> + + + + + + + + + + + ) +} + +function WallBaseElevationHandle({ + wall, + baseElevation, + levelObject, +}: { + wall: WallNode + baseElevation: number + levelObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midpoint: [number, number] = curveFrame + ? [curveFrame.point.x, curveFrame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const elevationGuideSource = { + nodeId: wall.id, + levelId: wall.parentId, + anchor: midpoint, + } + const leaderBottom = Math.min(0, baseElevation) + const leaderHeight = Math.abs(baseElevation) + const dashedGeometry = useMemo( + () => (leaderHeight > 0.0001 ? buildDashedVerticalGeometry(leaderHeight) : null), + [leaderHeight], + ) + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + + const isActive = isHovered || isDragging + useEffect(() => { + dashMaterial.color.set(isActive ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [dashMaterial, isActive]) + useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + const dragControls = useMemo( + () => ({ + onStart: () => {}, + onEnd: () => {}, + }), + [], + ) + + const activateElevationMove = useHandleDrag({ + kind: 'drag', + cursor: 'ns-resize', + dragControls, + handleIndex: 0, + node: wall, + rideObject: levelObject, + setIsDragging, + onStart: ({ event, initialNode, intersectPlane, nodeId, sceneApi }) => { + if (initialNode.type !== 'wall') return null + + levelObject.updateWorldMatrix(true, false) + const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes()) + const supportBase = initialBase - (initialNode.supportOffset ?? 0) + const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes()) + const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( + levelObject.matrixWorld, + ) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + if (planeNormal.lengthSq() === 0) return null + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) + const initialHit = new Vector3() + if ( + !intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, initialHit) + ) { + return null + } + const initialPointerY = levelObject.worldToLocal(initialHit).y + + return { + onBegin: () => { + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId, handle: 'elevation' }) + }, + onEnd: () => { + useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag') + clearStructuralElevationGuide(initialNode.id) + }, + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const hit = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null + const pointerY = levelObject.worldToLocal(hit).y + const nextBase = resolveResizeSnapValue({ + rawValue: initialBase + pointerY - initialPointerY, + gridSnapEnabled: true, + gridSnapActive: isGridSnapActive(), + gridSnapStep: useEditor.getState().gridSnapStep, + magneticSnapActive: isMagneticSnapActive(), + magneticSnap: (value) => + resolveStructuralElevationSnap(elevationGuideSource, value, sceneApi.nodes()), + }) + publishStructuralElevationGuide(elevationGuideSource, nextBase, sceneApi.nodes()) + if (Math.abs(nextBase - initialBase) < 1e-6) { + return { + supportOffset: initialNode.supportOffset, + height: initialNode.height, + } + } + const nextOffset = nextBase - supportBase + return { + supportOffset: Math.abs(nextOffset) < 1e-6 ? undefined : nextOffset, + height: initialHeight, + } + }, + } + }, + }) + + return ( + <> + {dashedGeometry ? ( + + ) : null} + + + ) +} + +function WallHeightArrowHandle({ wall }: { wall: WallNode }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera, raycaster, gl } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + dragCleanupRef.current?.() + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + // Sit on the visual centre of the wall — for curved walls that's the + // arc apex at t=0.5, not the chord midpoint. Use the curve tangent for + // the yaw so the arrow's local frame matches the wall direction at the + // apex, consistent with `getWallMoveHandles`. + const curveFrame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midX = curveFrame ? curveFrame.point.x : (wall.start[0] + wall.end[0]) / 2 + const midZ = curveFrame ? curveFrame.point.y : (wall.start[1] + wall.end[1]) / 2 + const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] + const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] + const wallAngle = Math.atan2(-dirZ, dirX) + // `wall` is the override-merged effective wall (see + // WallMoveSideHandlesForWall), so this height is already live during a drag. + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0.5) + const handleY = wallHeight + HEIGHT_HANDLE_OFFSET + + const activateHeightResize = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null + if (!levelObject) return + + // Vertical plane through the wall midpoint whose normal points toward + // the camera (projected to horizontal). Raycasting against it converts + // pointer movement into a world-space Y value. + const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) + const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + if (planeNormal.lengthSq() === 0) return + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) + + const ndc = new Vector2() + const setNDC = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) return + + // Dragging the top makes the wall custom-height; seed from the resolved + // effective height so a plane-bound wall's drag starts at its real top. + const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) + const initialY = hit.y + const wallId = wall.id as AnyNodeId + let pendingHeight = initialHeight + + document.body.style.cursor = 'ns-resize' + sfxEmitter.emit('sfx:item-pick') + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' }) + // Suppress R3F node pointer events until pointerup completes so the + // synthesized click doesn't reroute selection to whatever mesh sits + // under the cursor at release. + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + + // Drag publishes `{ height }` to `useLiveNodeOverrides` and marks + // the wall dirty so `WallSystem.updateWallGeometry` rebuilds against + // the override-merged value (via `getEffectiveWall`). Zustand stays + // at the pre-drag height until pointerup commits one tracked write. + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + const intersection = new Vector3() + if (!raycaster.ray.intersectPlane(plane, intersection)) return + const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) + pendingHeight = newHeight + useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) + useScene.getState().markDirty(wallId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + window.removeEventListener('keydown', onKeyDown, true) + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') + useViewer.getState().setInputDragging(false) + dragCleanupRef.current = null + } + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + // Commit: write the final override-merged value to zustand once + // (tracked, undoable), then drop the override so the renderer + // falls back to the scene store. + if (pendingHeight !== initialHeight) { + useScene.getState().updateNode(wallId, { height: pendingHeight }) + } + useLiveNodeOverrides.getState().clear(wallId) + useScene.getState().markDirty(wallId) + cleanup() + } + const onCancel = () => { + // Revert: drop the override, mark dirty so the geometry rebuilds + // against the original scene height. + useLiveNodeOverrides.getState().clear(wallId) + useScene.getState().markDirty(wallId) + cleanup() + } + + // Escape / ⌘Z abort the drag — capture phase so they win over the global + // use-keyboard arms (⌘Z must never history-jump under a live pointer). + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' && !isHistoryShortcut(e)) return + e.preventDefault() + e.stopPropagation() + swallowNextClick() + onCancel() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + window.addEventListener('keydown', onKeyDown, true) + } + + return ( + + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'ns-resize' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'ns-resize') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + + + + ) +} + +function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + const activateWallMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(wall) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') + // Keep the wall selected so it stays the active item once the move + // commits; the `!movingNode` guard on the handles hides them mid-drag. + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + `) + // so the mesh is never rendered with R3F's default empty + // `BufferGeometry`. Combined with `frustumCulled={false}`, the + // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first + // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". + frustumCulled={false} + geometry={arrowGeometry} + material={arrowMaterial} + raycast={NO_RAYCAST} + renderOrder={1002} + scale={scale} + /> + + ) +} + +function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { + const [isHovered, setIsHovered] = useState(false) + const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() + const arrowMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1, + }), + [], + ) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + + const activateFenceMove = (event: ThreeEvent) => { + if (event.button !== 0) return + event.stopPropagation() + suppressBoxSelectForPointer(event) + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(fence) + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint') + useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve') + // Keep the fence selected so it stays active once the move commits. + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + scale={baseScale} + /> + + + ) +} + +function getWallMoveHandles(wall: WallNode, nodes: Record): WallMoveHandle[] { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const normal: [number, number] = frame + ? [frame.normal.x, frame.normal.y] + : [-dz / length, dx / length] + const midpoint: [number, number] = frame + ? [frame.point.x, frame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] + const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5) + const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + +function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { + const [levelObject, setLevelObject] = useState(() => + fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = fence.parentId + ? (sceneRegistry.nodes.get(fence.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [fence.parentId]) + + const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + + {handles.map((handle) => ( + + ))} + , + levelObject, + ) +} + +function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { + const dx = fence.end[0] - fence.start[0] + const dz = fence.end[1] - fence.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const midpoint: [number, number] = [ + (fence.start[0] + fence.end[0]) / 2, + (fence.start[1] + fence.end[1]) / 2, + ] + const normal: [number, number] = [-dz / length, dx / length] + const fenceHeight = fence.height ?? 1.8 + const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + +function buildWallMoveHandle( + key: string, + midpoint: [number, number], + direction: [number, number], + offset: number, + height: number, +): WallMoveHandle { + return { + key, + position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], + rotationY: Math.atan2(-direction[1], direction[0]), + } +} diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 7ef0ee9b1d..7bdd827077 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -1,306 +1,315 @@ -'use client' - -import { - type AnyNode, - type AnyNodeId, - getWallCurveFrameAt, - getWallCurveLength, - getWallPlaneTop, - getWallThickness, - isCurvedWall, - resolveLevelId, - resolveWallTop, - sceneRegistry, - spatialGridManager, - useScene, - type WallNode, -} from '@pascal-app/core' -import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useFrame } from '@react-three/fiber' -import { memo, useMemo, useRef } from 'react' -import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' -import { MeshBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../lib/constants' - -/** - * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that - * stands at the draft / move endpoint while it's locked onto existing wall - * geometry. It's the spatial cue from the design reference: a standing pillar - * so you can see *where* the snap caught even at an angle, plus a floor marker - * whose shape tells you *what* it caught (CAD-style osnap glyphs): - * - * endpoint (corner) → square midpoint → triangle - * intersection → ✕ cross wall body (edge) → circle - * - * Subscribes to the shared `useWallSnapIndicator` store (published by the wall - * draft + endpoint-move tools). The vertical mouse pillar and the corner - * (endpoint) square are green; the other floor glyphs are indigo. - * - * The point carries only XZ (building-local plan coords); like the alignment - * guides it's lifted to the active level's building-local Y each frame so it - * stands on the floor being edited when floors are stacked. Mounted inside - * ToolManager's building-local group. - */ - -const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent -const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph -const BEACON_HEIGHT = 2.5 // world-meter height of the pillar -const BEACON_RADIUS = 0.018 // world-meter radius of the pillar -const MARKER = 0.13 // world-meter base size of the floor glyph -const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid -const WALL_TOP_HIGHLIGHT_LIFT = 0.035 -const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 -const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 -const WALL_TOP_GLOW_HEIGHT = 0.026 -const WALL_TOP_GLOW_OVERHANG = 0.36 -const WALL_TOP_END_OVERHANG = 0.08 -const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 -const NO_RAYCAST = () => null - -// Shared resources — one material + unit geometries, so snap churn during a -// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). -const beaconMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.9, -}) -const greenMarkerMaterial = new MeshBasicNodeMaterial({ - color: MARKER_GREEN, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.9, -}) -const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.88, -}) -const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ - color: BEACON_COLOR, - depthTest: false, - depthWrite: false, - toneMapped: false, - transparent: true, - opacity: 0.26, -}) -const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) -// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as -// a flat plate; circles/triangles lie flat via an X rotation at the mesh. -const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) -const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) -const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) -const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) - -export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { - const point = useWallSnapIndicator((s) => s.point) - const levelId = useViewer((s) => s.selection.levelId) - const nodes = useScene((s) => s.nodes) - const groupRef = useRef(null) - const highlightedWalls = useMemo(() => { - if (!point?.wallIds?.length) return [] - return point.wallIds - .map((wallId) => nodes[wallId as AnyNodeId]) - .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) - }, [nodes, point?.wallIds]) - - // Track the active level's building-local Y each frame so the beacon stands - // on the floor being edited, not the building base — same source the - // alignment guide layer and `grid.tsx` read. - useFrame(() => { - const group = groupRef.current - if (!group) return - const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null - group.position.y = levelMesh ? levelMesh.position.y : 0 - }) - - if (!point) return null - return ( - - {highlightedWalls.map((wall) => ( - - ))} - - - - ) -}) - -type WallTopHighlightSegment = { - angle: number - center: [number, number] - length: number -} - -function getWallTopY(wall: WallNode, nodes: Readonly>) { - const levelId = resolveLevelId(wall, nodes as Record) - const support = spatialGridManager.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId, - ) - const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) - return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT -} - -function buildHighlightSegment(start: [number, number], end: [number, number]) { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length < 1e-6) return null - - return { - angle: -Math.atan2(dz, dx), - center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], - length, - } -} - -function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { - if (!isCurvedWall(wall)) { - const segment = buildHighlightSegment(wall.start, wall.end) - return segment ? [segment] : [] - } - - const sampleCount = Math.max( - 8, - Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), - ) - const segments: WallTopHighlightSegment[] = [] - let previous = getWallCurveFrameAt(wall, 0).point - for (let index = 1; index <= sampleCount; index += 1) { - const current = getWallCurveFrameAt(wall, index / sampleCount).point - const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y]) - if (segment) segments.push(segment) - previous = current - } - return segments -} - -function WallTopHighlight({ - nodes, - wall, -}: { - nodes: Readonly> - wall: WallNode -}) { - const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) - const y = getWallTopY(wall, nodes) - const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) - const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) - - return ( - <> - {segments.map((segment, index) => ( - - - - - ))} - - ) -} - -/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ -function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { - const y = FLOOR_LIFT - if (kind === 'endpoint') { - return ( - - ) - } - if (kind === 'midpoint') { - return ( - - ) - } - if (kind === 'intersection') { - // Two crossed bars → an ✕, the universal "crossing" glyph. - return ( - <> - - - - ) - } - // 'wall' (edge / along-wall) → circle - return ( - - ) -} +'use client' + +import { + type AnyNode, + type AnyNodeId, + getWallCurveFrameAt, + getWallCurveLength, + getWallPlaneTop, + getWallThickness, + isCurvedWall, + resolveLevelId, + resolveWallTop, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { memo, useMemo, useRef } from 'react' +import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +/** + * "Magnetic" wall-snap beacon for the 3D editor — a vertical marker that + * stands at the draft / move endpoint while it's locked onto existing wall + * geometry. It's the spatial cue from the design reference: a standing pillar + * so you can see *where* the snap caught even at an angle, plus a floor marker + * whose shape tells you *what* it caught (CAD-style osnap glyphs): + * + * endpoint (corner) → square midpoint → triangle + * intersection → ✕ cross wall body (edge) → circle + * + * Subscribes to the shared `useWallSnapIndicator` store (published by the wall + * draft + endpoint-move tools). The vertical mouse pillar and the corner + * (endpoint) square are green; the other floor glyphs are indigo. + * + * The point carries only XZ (building-local plan coords); like the alignment + * guides it's lifted to the active level's building-local Y each frame so it + * stands on the floor being edited when floors are stacked. Mounted inside + * ToolManager's building-local group. + */ + +const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent +const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph +const BEACON_HEIGHT = 2.5 // world-meter height of the pillar +const BEACON_RADIUS = 0.018 // world-meter radius of the pillar +const MARKER = 0.13 // world-meter base size of the floor glyph +const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid +const WALL_TOP_HIGHLIGHT_LIFT = 0.035 +const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 +const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 +const WALL_TOP_GLOW_HEIGHT = 0.026 +const WALL_TOP_GLOW_OVERHANG = 0.36 +const WALL_TOP_END_OVERHANG = 0.08 +const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 +const NO_RAYCAST = () => null + +// Shared resources — one material + unit geometries, so snap churn during a +// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). +const beaconMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) +const greenMarkerMaterial = new MeshBasicNodeMaterial({ + color: MARKER_GREEN, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) +const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.88, +}) +const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.26, +}) +const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) +// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as +// a flat plate; circles/triangles lie flat via an X rotation at the mesh. +const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) +const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) +const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) +const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) + +export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { + const point = useWallSnapIndicator((s) => s.point) + const levelId = useViewer((s) => s.selection.levelId) + const nodes = useScene((s) => s.nodes) + const groupRef = useRef(null) + const highlightedWalls = useMemo(() => { + if (!point?.wallIds?.length) return [] + return point.wallIds + .map((wallId) => nodes[wallId as AnyNodeId]) + .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) + }, [nodes, point?.wallIds]) + + // Track the active level's building-local Y each frame so the beacon stands + // on the floor being edited, not the building base — same source the + // alignment guide layer and `grid.tsx` read. + useFrame(() => { + const group = groupRef.current + if (!group) return + const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null + group.position.y = levelMesh ? levelMesh.position.y : 0 + }) + + if (!point) return null + return ( + + {highlightedWalls.map((wall) => ( + + ))} + + + + ) +}) + +type WallTopHighlightSegment = { + angle: number + center: [number, number] + length: number + tCenter: number +} + +function getWallTopY(wall: WallNode, nodes: Readonly>, t = 0.5): number { + const levelId = resolveLevelId(wall, nodes as Record) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallTop(wall, planeTop, support.elevation, t) + WALL_TOP_HIGHLIGHT_LIFT +} + +function buildHighlightSegment( + start: [number, number], + end: [number, number], + tCenter = 0.5, +): WallTopHighlightSegment | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + + return { + angle: -Math.atan2(dz, dx), + center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], + length, + tCenter, + } +} + +function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { + if (!isCurvedWall(wall)) { + const segment = buildHighlightSegment(wall.start, wall.end, 0.5) + return segment ? [segment] : [] + } + + const sampleCount = Math.max( + 8, + Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), + ) + const segments: WallTopHighlightSegment[] = [] + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const tCenter = (index - 0.5) / sampleCount + const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y], tCenter) + if (segment) segments.push(segment) + previous = current + } + return segments +} + +function WallTopHighlight({ + nodes, + wall, +}: { + nodes: Readonly> + wall: WallNode +}) { + const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) + const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) + const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) + + return ( + <> + {segments.map((segment, index) => { + const y = getWallTopY(wall, nodes, segment.tCenter) + return ( + + + + + ) + })} + + ) +} + +/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ +function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { + const y = FLOOR_LIFT + if (kind === 'endpoint') { + return ( + + ) + } + if (kind === 'midpoint') { + return ( + + ) + } + if (kind === 'intersection') { + // Two crossed bars → an ✕, the universal "crossing" glyph. + return ( + <> + + + + ) + } + // 'wall' (edge / along-wall) → circle + return ( + + ) +} diff --git a/packages/editor/src/lib/elevation-guides.ts b/packages/editor/src/lib/elevation-guides.ts index b828be9ba7..c204efeb73 100644 --- a/packages/editor/src/lib/elevation-guides.ts +++ b/packages/editor/src/lib/elevation-guides.ts @@ -1,265 +1,280 @@ -import { - type AnyNode, - type AnyNodeId, - findLevelAncestorId, - getWallBaseElevationForNodes, - getWallEffectiveHeightForNodes, - levelBaseElevationAt, - resolveCeilingHeight, -} from '@pascal-app/core' -import useElevationGuides from '../store/use-elevation-guides' - -export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08 -const GUIDE_MATCH_EPSILON_M = 1e-4 - -export type ElevationGuideSource = { - nodeId: string - levelId: string | null | undefined - anchor: readonly [number, number] -} - -export type ElevationSnapTarget = { - id: string - elevation: number - anchor: readonly [number, number] - label: string -} - -export type ElevationSnapMatch = { - target: ElevationSnapTarget - elevation: number -} - -function polygonCenter(polygon: ReadonlyArray): [number, number] { - if (polygon.length === 0) return [0, 0] - let x = 0 - let z = 0 - for (const point of polygon) { - x += point[0] - z += point[1] - } - return [x / polygon.length, z / polygon.length] -} - -function segmentCenter( - start: readonly [number, number], - end: readonly [number, number], -): [number, number] { - return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] -} - -// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this -// package cannot import (the fence definition imports the guides from here). -// A stale host resolves to the level base — the sculpted ground under the -// fence's start point, sampled where the builder samples it, so the guide line -// lands on the rail it claims to describe. -function fenceBaseElevation(node: AnyNode, nodes: Record): number { - if (node.type !== 'fence') return 0 - const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined - const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) - const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) - const support = hosted - ? (host.elevation ?? 0) - : levelId - ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) - : 0 - return support + (node.supportOffset ?? 0) -} - -/** - * Structural Y datums on the source node's level. This is editor-runtime - * collection over authoritative vertical resolvers; matching remains a pure - * scalar operation in {@link resolveElevationSnapMatch}. - */ -export function collectElevationSnapTargets( - source: ElevationGuideSource, - nodes: Record, -): ElevationSnapTarget[] { - if (!source.levelId) return [] - - const targets: ElevationSnapTarget[] = [ - { - id: `${source.levelId}:level`, - elevation: 0, - anchor: source.anchor, - label: 'Level', - }, - ] - // Sculpted ground under the thing being dragged. A separate target rather than - // a redefinition of `Level`: the storey plane is still a real datum a user may - // want (a fence sunk to the building's floor line), and on a hillside the - // ground is a second, different one. Emitted only when they actually differ, - // so a flat scene keeps exactly one target at 0. - const groundElevation = levelBaseElevationAt( - nodes, - source.levelId, - source.anchor[0], - source.anchor[1], - ) - if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) { - targets.push({ - id: `${source.levelId}:ground`, - elevation: groundElevation, - anchor: source.anchor, - label: 'Ground', - }) - } - const level = nodes[source.levelId as AnyNodeId] - if (level?.type !== 'level') return targets - - for (const childId of level.children) { - if (childId === source.nodeId) continue - const node = nodes[childId as AnyNodeId] - if (!node) continue - - if (node.type === 'slab') { - const center = polygonCenter(node.polygon) - const top = node.elevation ?? 0.05 - targets.push({ - id: `${node.id}:top`, - elevation: top, - anchor: center, - label: 'Slab top', - }) - if (!node.recessed) { - targets.push({ - id: `${node.id}:base`, - elevation: top - (node.thickness ?? 0.05), - anchor: center, - label: 'Slab underside', - }) - } - continue - } - - if (node.type === 'ceiling') { - targets.push({ - id: `${node.id}:ceiling`, - elevation: resolveCeilingHeight(node, nodes as Record), - anchor: polygonCenter(node.polygon), - label: 'Ceiling', - }) - continue - } - - if (node.type === 'wall') { - const base = getWallBaseElevationForNodes(node, nodes) - const center = segmentCenter(node.start, node.end) - targets.push({ - id: `${node.id}:base`, - elevation: base, - anchor: center, - label: 'Wall base', - }) - targets.push({ - id: `${node.id}:top`, - elevation: base + getWallEffectiveHeightForNodes(node, nodes), - anchor: center, - label: 'Wall top', - }) - continue - } - - if (node.type === 'fence') { - const base = fenceBaseElevation(node, nodes) - const center = segmentCenter(node.start, node.end) - targets.push({ - id: `${node.id}:base`, - elevation: base, - anchor: center, - label: 'Fence base', - }) - targets.push({ - id: `${node.id}:top`, - elevation: base + (node.height ?? 1.8), - anchor: center, - label: 'Fence top', - }) - } - } - - return targets -} - -export function resolveElevationSnapMatch( - proposedElevation: number, - sourceAnchor: readonly [number, number], - targets: readonly ElevationSnapTarget[], - threshold = ELEVATION_ALIGNMENT_THRESHOLD_M, -): ElevationSnapMatch | null { - let best: ElevationSnapTarget | null = null - let bestDelta = Number.POSITIVE_INFINITY - let bestPlanDistance = Number.POSITIVE_INFINITY - - for (const target of targets) { - const delta = Math.abs(target.elevation - proposedElevation) - if (delta > threshold) continue - const dx = target.anchor[0] - sourceAnchor[0] - const dz = target.anchor[1] - sourceAnchor[1] - const planDistance = dx * dx + dz * dz - if ( - delta < bestDelta - 1e-9 || - (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance) - ) { - best = target - bestDelta = delta - bestPlanDistance = planDistance - } - } - - return best ? { target: best, elevation: best.elevation } : null -} - -export function resolveStructuralElevationSnap( - source: ElevationGuideSource, - proposedElevation: number, - nodes: Record, -): number { - return ( - resolveElevationSnapMatch( - proposedElevation, - source.anchor, - collectElevationSnapTargets(source, nodes), - )?.elevation ?? proposedElevation - ) -} - -export function publishStructuralElevationGuide( - source: ElevationGuideSource, - elevation: number, - nodes: Record, -): void { - if (!source.levelId) { - clearStructuralElevationGuide(source.nodeId) - return - } - - const match = resolveElevationSnapMatch( - elevation, - source.anchor, - collectElevationSnapTargets(source, nodes), - GUIDE_MATCH_EPSILON_M, - ) - if (!match) { - clearStructuralElevationGuide(source.nodeId) - return - } - - const dx = match.target.anchor[0] - source.anchor[0] - const dz = match.target.anchor[1] - source.anchor[1] - const length = Math.hypot(dx, dz) - const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] - - useElevationGuides.getState().publish({ - ownerId: source.nodeId, - levelId: source.levelId, - center: source.anchor, - direction, - elevation: match.elevation, - label: match.target.label, - }) -} - -export function clearStructuralElevationGuide(ownerId: string): void { - useElevationGuides.getState().clear(ownerId) -} +import { + type AnyNode, + type AnyNodeId, + findLevelAncestorId, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, + levelBaseElevationAt, + resolveCeilingHeight, +} from '@pascal-app/core' +import useElevationGuides from '../store/use-elevation-guides' + +export const ELEVATION_ALIGNMENT_THRESHOLD_M = 0.08 +const GUIDE_MATCH_EPSILON_M = 1e-4 + +export type ElevationGuideSource = { + nodeId: string + levelId: string | null | undefined + anchor: readonly [number, number] +} + +export type ElevationSnapTarget = { + id: string + elevation: number + anchor: readonly [number, number] + label: string +} + +export type ElevationSnapMatch = { + target: ElevationSnapTarget + elevation: number +} + +function polygonCenter(polygon: ReadonlyArray): [number, number] { + if (polygon.length === 0) return [0, 0] + let x = 0 + let z = 0 + for (const point of polygon) { + x += point[0] + z += point[1] + } + return [x / polygon.length, z / polygon.length] +} + +function segmentCenter( + start: readonly [number, number], + end: readonly [number, number], +): [number, number] { + return [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] +} + +// Mirrors `resolveFenceLiftElevationForNodes` in `@pascal-app/nodes`, which this +// package cannot import (the fence definition imports the guides from here). +// A stale host resolves to the level base — the sculpted ground under the +// fence's start point, sampled where the builder samples it, so the guide line +// lands on the rail it claims to describe. +function fenceBaseElevation(node: AnyNode, nodes: Record): number { + if (node.type !== 'fence') return 0 + const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined + const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null) + const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes) + const support = hosted + ? (host.elevation ?? 0) + : levelId + ? levelBaseElevationAt(nodes, levelId, node.start[0], node.start[1]) + : 0 + return support + (node.supportOffset ?? 0) +} + +/** + * Structural Y datums on the source node's level. This is editor-runtime + * collection over authoritative vertical resolvers; matching remains a pure + * scalar operation in {@link resolveElevationSnapMatch}. + */ +export function collectElevationSnapTargets( + source: ElevationGuideSource, + nodes: Record, +): ElevationSnapTarget[] { + if (!source.levelId) return [] + + const targets: ElevationSnapTarget[] = [ + { + id: `${source.levelId}:level`, + elevation: 0, + anchor: source.anchor, + label: 'Level', + }, + ] + // Sculpted ground under the thing being dragged. A separate target rather than + // a redefinition of `Level`: the storey plane is still a real datum a user may + // want (a fence sunk to the building's floor line), and on a hillside the + // ground is a second, different one. Emitted only when they actually differ, + // so a flat scene keeps exactly one target at 0. + const groundElevation = levelBaseElevationAt( + nodes, + source.levelId, + source.anchor[0], + source.anchor[1], + ) + if (Math.abs(groundElevation) > GUIDE_MATCH_EPSILON_M) { + targets.push({ + id: `${source.levelId}:ground`, + elevation: groundElevation, + anchor: source.anchor, + label: 'Ground', + }) + } + const level = nodes[source.levelId as AnyNodeId] + if (level?.type !== 'level') return targets + + for (const childId of level.children) { + if (childId === source.nodeId) continue + const node = nodes[childId as AnyNodeId] + if (!node) continue + + if (node.type === 'slab') { + const center = polygonCenter(node.polygon) + const top = node.elevation ?? 0.05 + targets.push({ + id: `${node.id}:top`, + elevation: top, + anchor: center, + label: 'Slab top', + }) + if (!node.recessed) { + targets.push({ + id: `${node.id}:base`, + elevation: top - (node.thickness ?? 0.05), + anchor: center, + label: 'Slab underside', + }) + } + continue + } + + if (node.type === 'ceiling') { + targets.push({ + id: `${node.id}:ceiling`, + elevation: resolveCeilingHeight(node, nodes as Record), + anchor: polygonCenter(node.polygon), + label: 'Ceiling', + }) + continue + } + + if (node.type === 'wall') { + const base = getWallBaseElevationForNodes(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Wall base', + }) + if (node.endHeightOffset) { + targets.push({ + id: `${node.id}:top-start`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0), + anchor: node.start, + label: 'Wall top start', + }) + targets.push({ + id: `${node.id}:top-end`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 1), + anchor: node.end, + label: 'Wall top end', + }) + } else { + targets.push({ + id: `${node.id}:top`, + elevation: base + getWallEffectiveHeightForNodes(node, nodes, 0.5), + anchor: center, + label: 'Wall top', + }) + } + continue + } + + if (node.type === 'fence') { + const base = fenceBaseElevation(node, nodes) + const center = segmentCenter(node.start, node.end) + targets.push({ + id: `${node.id}:base`, + elevation: base, + anchor: center, + label: 'Fence base', + }) + targets.push({ + id: `${node.id}:top`, + elevation: base + (node.height ?? 1.8), + anchor: center, + label: 'Fence top', + }) + } + } + + return targets +} + +export function resolveElevationSnapMatch( + proposedElevation: number, + sourceAnchor: readonly [number, number], + targets: readonly ElevationSnapTarget[], + threshold = ELEVATION_ALIGNMENT_THRESHOLD_M, +): ElevationSnapMatch | null { + let best: ElevationSnapTarget | null = null + let bestDelta = Number.POSITIVE_INFINITY + let bestPlanDistance = Number.POSITIVE_INFINITY + + for (const target of targets) { + const delta = Math.abs(target.elevation - proposedElevation) + if (delta > threshold) continue + const dx = target.anchor[0] - sourceAnchor[0] + const dz = target.anchor[1] - sourceAnchor[1] + const planDistance = dx * dx + dz * dz + if ( + delta < bestDelta - 1e-9 || + (Math.abs(delta - bestDelta) <= 1e-9 && planDistance < bestPlanDistance) + ) { + best = target + bestDelta = delta + bestPlanDistance = planDistance + } + } + + return best ? { target: best, elevation: best.elevation } : null +} + +export function resolveStructuralElevationSnap( + source: ElevationGuideSource, + proposedElevation: number, + nodes: Record, +): number { + return ( + resolveElevationSnapMatch( + proposedElevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + )?.elevation ?? proposedElevation + ) +} + +export function publishStructuralElevationGuide( + source: ElevationGuideSource, + elevation: number, + nodes: Record, +): void { + if (!source.levelId) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const match = resolveElevationSnapMatch( + elevation, + source.anchor, + collectElevationSnapTargets(source, nodes), + GUIDE_MATCH_EPSILON_M, + ) + if (!match) { + clearStructuralElevationGuide(source.nodeId) + return + } + + const dx = match.target.anchor[0] - source.anchor[0] + const dz = match.target.anchor[1] - source.anchor[1] + const length = Math.hypot(dx, dz) + const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0] + + useElevationGuides.getState().publish({ + ownerId: source.nodeId, + levelId: source.levelId, + center: source.anchor, + direction, + elevation: match.elevation, + label: match.target.label, + }) +} + +export function clearStructuralElevationGuide(ownerId: string): void { + useElevationGuides.getState().clear(ownerId) +} 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..1c767b2507 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 type { AnyNode, AnyNodeId, 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,52 @@ export function clampToWall( localX: number, width: number, height: number, -): { clampedX: number; clampedY: number } { + 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] - 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 + + 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) + 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..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. @@ -209,7 +210,12 @@ 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) + 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. @@ -254,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/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..a8ae2ff483 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,56 @@ 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) { + // 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)) + } + } + 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 0d2dc398e6..807392b490 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -231,17 +231,19 @@ export default function WallPanel() { const followsTerrain = node.fillToTerrain === true const isPlaneBound = node.height == null 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) 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 ?? {}) } @@ -300,6 +302,24 @@ export default function WallPanel() { value={Math.round(displayHeight * 100) / 100} /> )} + { + const minMeters = -(wallHeightMeters - 0.01) + handleUpdate({ + endHeightOffset: linearControlValueToMeters(v, unit, { + maxMeters: 3, + minMeters, + }), + }) + }} + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayEndHeightOffset * 100) / 100} + />
Bottom
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..4ea9a21939 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -1,283 +1,291 @@ -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 + let lastFits = 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, + ) + 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. + 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 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() { + // 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..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 { resolveWallOpeningCeiling } 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,16 +51,84 @@ export function clampToWall( localY: number, width: number, height: number, - nodes: Readonly>, -): { clampedX: number; clampedY: number } { + 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] - 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: 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) + 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 f84f48c0ff..c0321b36c1 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -933,6 +933,37 @@ 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, + bodyHeight: number, +): void { + const rawOffset = wallNode.endHeightOffset + if (!rawOffset || wallLength < 1e-9) { + return + } + const minEndHeight = 0.01 + const endHeightOffset = Math.max(rawOffset, -(bodyHeight - minEndHeight)) + 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[], @@ -1016,9 +1047,9 @@ export function generateExtrudedWall( bevelEnabled: false, }) - // 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, height) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry)