diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index a3ffb678fc..6299469dbb 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,1319 +1,1321 @@ -import { getRenderableSlabPolygon } from '../../lib/slab-polygon' -import { levelBaseElevationAt } from '../../lib/terrain-support' -import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' -import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' -import { getWallPlaneTop } from '../../services/storey' -import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' -import useLiveTransforms from '../../store/use-live-transforms' -import useScene from '../../store/use-scene' -import { - computeWallSlabSupport, - pointInPolygon, - SUPPORT_ELEVATION_EPSILON, - type WallSlabSupport, -} from '../../systems/slab/slab-support' -import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' -import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' -import { getFloorPlacedFootprints } from './floor-placed-elevation' -import { SpatialGrid } from './spatial-grid' -import { GROUND_SUPPORT_ID } from './support-host-id' -import { WallSpatialGrid } from './wall-spatial-grid' - -export { - computeWallSlabElevation, - computeWallSlabSupport, - pointInPolygon, - SUPPORT_ELEVATION_EPSILON, - type WallOverlapInput, - type WallSlabSupport, - type WallSlabSupportSegment, - wallOverlapsPolygon, -} from '../../systems/slab/slab-support' - -// ============================================================================ -// GEOMETRY HELPERS -// ============================================================================ - -/** - * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. - */ -function getItemFootprint( - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - inset = 0, -): Array<[number, number]> { - const [x, , z] = position - const [w, , d] = dimensions - const yRot = rotation[1] - const halfW = Math.max(0, w / 2 - inset) - const halfD = Math.max(0, d / 2 - inset) - const cos = Math.cos(yRot) - const sin = Math.sin(yRot) - - return [ - [x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)], - [x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)], - [x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)], - [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)], - ] -} - -/** - * Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The - * rotated width/depth is the same conservative bound the floor-placement draft - * uses, so a draft and an existing node are compared with identical math. - */ -function footprintBoundsXZ( - position: [number, number, number], - dimensions: [number, number, number], - yRot: number, -): { minX: number; maxX: number; minZ: number; maxZ: number } { - const [width, , depth] = dimensions - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - return { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } -} - -type ItemLocalBounds = { - min: [number, number, number] - max: [number, number, number] -} - -type ItemParentAabb = { - minX: number - maxX: number - minY: number - maxY: number - minZ: number - maxZ: number -} - -function getItemLocalBounds(item: ItemNode): ItemLocalBounds { - const [width, height, depth] = getScaledDimensions(item) - const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 - const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 - return { - min: [-width / 2, 0, minZ], - max: [width / 2, height, maxZ], - } -} - -function getItemParentAabb(item: ItemNode): ItemParentAabb { - const bounds = getItemLocalBounds(item) - const corners: Array<[number, number, number]> = [ - [bounds.min[0], bounds.min[1], bounds.min[2]], - [bounds.min[0], bounds.min[1], bounds.max[2]], - [bounds.min[0], bounds.max[1], bounds.min[2]], - [bounds.min[0], bounds.max[1], bounds.max[2]], - [bounds.max[0], bounds.min[1], bounds.min[2]], - [bounds.max[0], bounds.min[1], bounds.max[2]], - [bounds.max[0], bounds.max[1], bounds.min[2]], - [bounds.max[0], bounds.max[1], bounds.max[2]], - ] - const yRot = item.rotation[1] ?? 0 - const cos = Math.cos(yRot) - const sin = Math.sin(yRot) - - let minX = Number.POSITIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - - for (const [cx, cy, cz] of corners) { - const rotatedX = cx * cos + cz * sin - const rotatedZ = -cx * sin + cz * cos - const worldX = rotatedX + item.position[0] - const worldY = cy + item.position[1] - const worldZ = rotatedZ + item.position[2] - minX = Math.min(minX, worldX) - minY = Math.min(minY, worldY) - minZ = Math.min(minZ, worldZ) - maxX = Math.max(maxX, worldX) - maxY = Math.max(maxY, worldY) - maxZ = Math.max(maxZ, worldZ) - } - - return { minX, maxX, minY, maxY, minZ, maxZ } -} - -function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { - return minA < maxB - epsilon && maxA > minB + epsilon -} - -function resolveNodeLevelId(node: AnyNode, nodes: Record): string { - if (node.type === 'level') return node.id - - let current: AnyNode | undefined = node - while (current) { - if (current.type === 'level') return current.id - current = current.parentId ? nodes[current.parentId] : undefined - } - - return 'default' -} - -function expandIgnoredNodeIds( - ignoreIds: readonly string[] | undefined, - nodes: Record, -): Set { - const ignored = new Set(ignoreIds ?? []) - const queue = [...ignored] - - while (queue.length > 0) { - const id = queue.pop()! - const node = nodes[id] - const children = (node as { children?: unknown } | undefined)?.children - if (!Array.isArray(children)) continue - for (const childId of children) { - if (typeof childId !== 'string' || ignored.has(childId)) continue - ignored.add(childId) - queue.push(childId) - } - } - - return ignored -} - -/** - * Test if two line segments (a1->a2) and (b1->b2) intersect. - */ -function segmentsIntersect( - ax1: number, - az1: number, - ax2: number, - az2: number, - bx1: number, - bz1: number, - bx2: number, - bz2: number, -): boolean { - const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) => - (ax - ox) * (bz - oz) - (az - oz) * (bx - ox) - - const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) - const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) - const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) - const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) - - if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { - return true - } - - // Collinear touching cases - const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => - Math.min(px, qx) <= rx && - rx <= Math.max(px, qx) && - Math.min(pz, qz) <= rz && - rz <= Math.max(pz, qz) - - if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true - if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true - if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true - if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true - - return false -} - -/** - * Test if a line segment intersects any edge of a polygon. - */ -function segmentIntersectsPolygon( - sx1: number, - sz1: number, - sx2: number, - sz2: number, - polygon: Array<[number, number]>, -): boolean { - const n = polygon.length - for (let i = 0; i < n; i++) { - const j = (i + 1) % n - if ( - segmentsIntersect( - sx1, - sz1, - sx2, - sz2, - polygon[i]![0], - polygon[i]![1], - polygon[j]![0], - polygon[j]![1], - ) - ) { - return true - } - } - return false -} - -/** - * Test if an item's footprint overlaps with a polygon. - * Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect. - */ -export function itemOverlapsPolygon( - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - polygon: Array<[number, number]>, - inset = 0, -): boolean { - const corners = getItemFootprint(position, dimensions, rotation, inset) - - // Check if any item corner is inside the polygon - for (const [cx, cz] of corners) { - if (pointInPolygon(cx, cz, polygon)) return true - } - - // Check if any polygon vertex is inside the item footprint - // (handles case where slab is fully inside a large item) - for (const [px, pz] of polygon) { - if (pointInPolygon(px, pz, corners)) return true - } - - // Check if any item edge intersects any polygon edge - for (let i = 0; i < 4; i++) { - const j = (i + 1) % 4 - if ( - segmentIntersectsPolygon( - corners[i]![0], - corners[i]![1], - corners[j]![0], - corners[j]![1], - polygon, - ) - ) - return true - } - - return false -} - -/** One slab overlapping a queried footprint, as seen by support election. */ -export type SlabSupportCandidate = { - slabId: string - elevation: number -} - -export type ItemSlabSupport = { - elevation: number - /** The winning slab, or null when no slab overlaps the footprint. */ - slabId: string | null -} - -export type PointedSupportSurface = ItemSlabSupport & { - /** - * Level-local XZ where the ray meets the pointed surface's plane, or - * null when the ray never reaches it (grazing / aimed above the base). - * This is the plan point the pointer actually indicates: unlike a grid - * event-plane hit — whose XZ shifts with whatever height the event - * plane currently rides at — it depends only on the ray and the - * aimed-at surface, so election/preview at this point cannot flip when - * the event plane changes storey. - */ - point: [number, number] | null -} - -export class SpatialGridManager { - private readonly floorGrids = new Map() // levelId -> grid - private readonly wallGrids = new Map() // levelId -> wall grid - private readonly walls = new Map() // wallId -> wall data (for length calculations) - private readonly slabsByLevel = new Map>() // levelId -> (slabId -> slab) - private readonly ceilingGrids = new Map() // ceilingId -> grid - private readonly ceilings = new Map() // ceilingId -> ceiling data - private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) - - private readonly cellSize: number - - constructor(cellSize = 0.5) { - this.cellSize = cellSize - } - - private getFloorGrid(levelId: string): SpatialGrid { - if (!this.floorGrids.has(levelId)) { - this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize })) - } - return this.floorGrids.get(levelId)! - } - - private getWallGrid(levelId: string): WallSpatialGrid { - if (!this.wallGrids.has(levelId)) { - this.wallGrids.set(levelId, new WallSpatialGrid()) - } - return this.wallGrids.get(levelId)! - } - - private getWallLength(wallId: string): number { - const wall = this.walls.get(wallId) - if (!wall) return 0 - const dx = wall.end[0] - wall.start[0] - const dy = wall.end[1] - wall.start[1] - return Math.sqrt(dx * dx + dy * dy) - } - - private getWallHeight(wallId: string): number { - const wall = this.walls.get(wallId) - if (!wall) return 0 - if (wall.height != null) return wall.height - - const nodes = useScene.getState().nodes - const levelId = resolveNodeLevelId(wall, nodes) - const support = this.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ) - return resolveWallEffectiveHeight( - wall, - getWallPlaneTop(wall, levelId, nodes), - support.elevation, - ) - } - - private getCeilingGrid(ceilingId: string): SpatialGrid { - if (!this.ceilingGrids.has(ceilingId)) { - this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize })) - } - return this.ceilingGrids.get(ceilingId)! - } - - private getSlabMap(levelId: string): Map { - if (!this.slabsByLevel.has(levelId)) { - this.slabsByLevel.set(levelId, new Map()) - } - return this.slabsByLevel.get(levelId)! - } - - /** - * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item - * support queries run per frame and the projection scans the level's - * walls + sibling slabs, so the result is cached per slab id and - * dropped for the whole level whenever a slab or wall on that level - * flows through the manager's create/update/delete handlers. - */ - private readonly renderedSlabPolygons = new Map>() - - private invalidateRenderedSlabPolygons(levelId: string) { - this.supportInputsRevision += 1 - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return - for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) - } - - /** - * True while a slab or wall on `levelId` has a live preview: group drags - * publish translated slab polygons and wall endpoints to - * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp - * publish a translation DELTA to `useLiveTransforms` — either way the - * scene store commits only on release, so the committed cache and index - * would elect support against pre-drag footprints (items and walls - * visibly drop to ground mid-preview). Support queries then read - * live-effective records and skip the rendered-polygon cache. - */ - private levelHasLivePreview(levelId: string): boolean { - const nodes = useScene.getState().nodes - const structuralOnLevel = (id: string) => { - const node = nodes[id as AnyNodeId] - if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false - return resolveNodeLevelId(node, nodes) === levelId - } - const overrides = useLiveNodeOverrides.getState().overrides - for (const id of overrides.keys()) { - if (structuralOnLevel(id)) return true - } - const transforms = useLiveTransforms.getState().transforms - for (const id of transforms.keys()) { - if (structuralOnLevel(id)) return true - } - return false - } - - /** - * The live-effective slab record: field overrides merged, then the - * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset - * stamp — store a translation, not an absolute position) applied to the - * polygon, holes, and elevation. Mapping happens exactly ONCE at each - * public query's loop entry: `slabSupportsFootprint` / - * `getRenderedSlabPolygon` take the already-effective record and must - * never re-map, or the delta would apply twice. - */ - private effectiveSlabRecord(slab: SlabNode): SlabNode { - let effective = getEffectiveNode(slab) - const live = useLiveTransforms.getState().get(slab.id) - if (live) { - const [dx, dy, dz] = live.position - if (dx !== 0 || dy !== 0 || dz !== 0) { - effective = { - ...effective, - polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), - holes: (effective.holes || []).map((hole) => - hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), - ), - elevation: (effective.elevation ?? 0.05) + dy, - } - } - } - return effective - } - - private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { - const live = this.levelHasLivePreview(levelId) - if (!live) { - const cached = this.renderedSlabPolygons.get(slab.id) - if (cached) return cached - } - - const siblingSlabs: SlabNode[] = [] - for (const other of this.getSlabMap(levelId).values()) { - if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) - } - const walls = this.getLevelWallNodes(levelId) - const polygon = getRenderableSlabPolygon(slab, { - walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, - siblingSlabs, - }) - if (!live) this.renderedSlabPolygons.set(slab.id, polygon) - return polygon - } - - /** - * Support test shared by election, candidate listing, and persisted-host - * validation: the footprint overlaps the slab's RENDERED polygon (what - * users see — matching the wall election in `computeWallSlabSupport`), - * with the center-point hole veto kept against the stored holes (holes - * are data, never render-offset). - */ - private slabSupportsFootprint( - levelId: string, - slab: SlabNode, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): boolean { - if (slab.polygon.length < 3) return false - const rendered = this.getRenderedSlabPolygon(levelId, slab) - if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false - - const [cx, , cz] = position - for (const hole of slab.holes || []) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false - } - return true - } - - // Called when nodes change - handleNodeCreated(node: AnyNode, levelId: string) { - if (node.type === 'slab') { - this.getSlabMap(levelId).set(node.id, node as SlabNode) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'ceiling') { - this.ceilings.set(node.id, node as CeilingNode) - } else if (node.type === 'wall') { - const wall = node as WallNode - this.walls.set(wall.id, wall) - // Rendered slab polygons adopt wall bands — a new wall can extend them. - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Wall-attached item - use parentId as the wall ID - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Ceiling item - use parentId as the ceiling ID - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - // Floor item - this.getFloorGrid(levelId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } - } - } - - handleNodeUpdated(node: AnyNode, levelId: string) { - if (node.type === 'slab') { - this.getSlabMap(levelId).set(node.id, node as SlabNode) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'ceiling') { - this.ceilings.set(node.id, node as CeilingNode) - } else if (node.type === 'wall') { - const wall = node as WallNode - this.walls.set(wall.id, wall) - this.invalidateRenderedSlabPolygons(levelId) - } else if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { - // Remove old placement and re-insert - this.getWallGrid(levelId).removeByItemId(item.id) - const wallId = item.parentId - if (wallId && this.walls.has(wallId)) { - const wallLength = this.getWallLength(wallId) - if (wallLength > 0) { - const [width, height] = getScaledDimensions(item) - const halfW = width / wallLength / 2 - // Calculate t from local X position (position[0] is distance along wall) - const t = item.position[0] / wallLength - // position[1] is the bottom of the item - this.getWallGrid(levelId).insert({ - itemId: item.id, - wallId, - tStart: t - halfW, - tEnd: t + halfW, - yStart: item.position[1], - yEnd: item.position[1] + height, - attachType: item.asset.attachTo as 'wall' | 'wall-side', - side: item.side, - }) - } - } - } else if (item.asset.attachTo === 'ceiling') { - // Remove from old ceiling grid - const oldCeilingId = this.itemCeilingMap.get(item.id) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(item.id) - this.itemCeilingMap.delete(item.id) - } - // Insert into new ceiling grid - const ceilingId = item.parentId - if (ceilingId && this.ceilings.has(ceilingId)) { - this.getCeilingGrid(ceilingId).insert( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - this.itemCeilingMap.set(item.id, ceilingId) - } - } else if (!item.asset.attachTo) { - this.getFloorGrid(levelId).update( - item.id, - item.position, - getScaledDimensions(item), - item.rotation, - ) - } - } - } - - handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { - if (nodeType === 'slab') { - // Invalidate before removal so the deleted slab's own cache entry - // (still keyed in the level map here) is dropped with its siblings'. - this.invalidateRenderedSlabPolygons(levelId) - this.getSlabMap(levelId).delete(nodeId) - } else if (nodeType === 'ceiling') { - this.ceilings.delete(nodeId) - this.ceilingGrids.delete(nodeId) - } else if (nodeType === 'wall') { - this.walls.delete(nodeId) - this.invalidateRenderedSlabPolygons(levelId) - // Remove all items attached to this wall from the spatial grid - const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) - return removedItemIds // Caller can use this to delete the items from scene - } else if (nodeType === 'item') { - this.getFloorGrid(levelId).remove(nodeId) - this.getWallGrid(levelId).removeByItemId(nodeId) - // Also clean up ceiling grid - const oldCeilingId = this.itemCeilingMap.get(nodeId) - if (oldCeilingId) { - this.getCeilingGrid(oldCeilingId).remove(nodeId) - this.itemCeilingMap.delete(nodeId) - } - } - return [] - } - - // Query methods - canPlaceOnFloor( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ignoreIds?: string[], - ) { - return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) - } - - canPlaceOnFloorFootprints( - levelId: string, - footprints: readonly { - position: [number, number, number] - dimensions: [number, number, number] - rotation: [number, number, number] - }[], - ignoreIds?: string[], - ) { - const nodes = useScene.getState().nodes - const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) - const draftBounds = footprints.map((footprint) => - footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), - ) - for (let i = 0; i < draftBounds.length; i += 1) { - const a = draftBounds[i]! - for (let j = i + 1; j < draftBounds.length; j += 1) { - const b = draftBounds[j]! - if ( - intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && - intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) - ) { - return { valid: false, conflictIds: [] } - } - } - } - - // A floor placement conflicts with any other COLLIDING floor-resting node, - // not just items — every kind whose `floorPlaced.collides` is set (item / - // shelf / column / cabinet / stair) contributes its footprint(s) as an - // obstacle. Each candidate's XZ extent is read from the same declarative - // footprint the elevation + sync paths use, so adding a colliding kind - // needs no change here. - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (ignoreSet.has(node.id)) continue - const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced - if (!floorPlaced?.collides) continue - if (floorPlaced.applies && !floorPlaced.applies(node)) continue - // Low-profile item surfaces (rugs, mats) are stack-on targets, not - // obstacles — keep the long-standing item-only exemption. - if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue - if (resolveNodeLevelId(node, nodes) !== levelId) continue - - for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { - const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0 - const bounds = footprintBoundsXZ( - footprint.position ?? (node as { position: [number, number, number] }).position, - footprint.dimensions, - fpRotation, - ) - if ( - draftBounds.some( - (draft) => - intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), - ) - ) { - conflicts.push(node.id) - break - } - } - } - - return { valid: conflicts.length === 0, conflictIds: conflicts } - } - - /** - * Check if an item can be placed on a wall - * @param levelId - the level containing the wall - * @param wallId - the wall to check - * @param localX - X position in wall-local space (distance from wall start) - * @param localY - Y position (height from floor) - * @param dimensions - item dimensions [width, height, depth] - * @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side) - * @param side - which side for 'wall-side' items - * @param ignoreIds - item IDs to ignore in collision check - */ - canPlaceOnWall( - levelId: string, - wallId: string, - localX: number, - localY: number, - dimensions: [number, number, number], - attachType: 'wall' | 'wall-side' = 'wall', - side?: 'front' | 'back', - ignoreIds?: string[], - ) { - const wallLength = this.getWallLength(wallId) - if (wallLength === 0) { - return { valid: false, conflictIds: [] } - } - const wallHeight = this.getWallHeight(wallId) - // Convert local X position to parametric t (0-1) - const tCenter = localX / wallLength - const [itemWidth, itemHeight] = dimensions - const baseResult = this.getWallGrid(levelId).canPlaceOnWall( - wallId, - wallLength, - wallHeight, - tCenter, - itemWidth, - localY, - itemHeight, - attachType, - side, - ignoreIds, - ) - - if (!baseResult.valid) return baseResult - - const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) - const draftBounds = { - minX: localX - itemWidth / 2, - maxX: localX + itemWidth / 2, - minY: baseResult.adjustedY, - maxY: baseResult.adjustedY + itemHeight, - } - - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue - if (ignoreSet.has(item.id)) continue - if (item.parentId !== wallId) continue - - if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { - if (side !== item.side) continue - } - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) - ) { - conflicts.push(item.id) - } - } - - return { - ...baseResult, - valid: conflicts.length === 0, - conflictIds: conflicts, - } - } - - getWallForItem(levelId: string, itemId: string): string | undefined { - return this.getWallGrid(levelId).getWallForItem(itemId) - } - - /** - * Get the total slab elevation at a given (x, z) position on a level. - * Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0. - */ - getSlabElevationAt(levelId: string, x: number, z: number): number { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 - - let maxElevation = 0 - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { - // Check if point is in any hole - let inHole = false - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(x, z, hole)) { - inHole = true - break - } - } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } - } - } - return maxElevation - } - - /** - * Get the slab elevation for an item using its full footprint (bounding box). - * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) - * that only need the number. - */ - getSlabElevationForItem( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - maxElevation?: number | null, - ): number { - return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) - .elevation - } - - /** - * Elect the supporting slab for a footprint: the highest-elevation slab - * whose RENDERED polygon the footprint overlaps (center-point hole veto - * applies). Returns `{ elevation: 0, slabId: null }` when nothing - * overlaps. - * - * `maxElevation` is the pointer-decided cap: when set, only slabs whose - * walking surface sits at or below `maxElevation + - * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface - * the cursor ray actually hit never captures the election. - */ - getSlabSupportForItem( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - maxElevation?: number | null, - ): ItemSlabSupport { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return { elevation: 0, slabId: null } - - let winningElevation = Number.NEGATIVE_INFINITY - let winnerId: string | null = null - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - const elevation = slab.elevation ?? 0.05 - if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue - if (elevation > winningElevation) { - winningElevation = elevation - winnerId = slab.id - } - } - return winnerId === null - ? { elevation: 0, slabId: null } - : { elevation: winningElevation, slabId: winnerId } - } - - /** - * The walking surface the pointer actually points at: the nearest slab - * plane the ray crosses INSIDE that slab's rendered polygon (hole veto - * applies), or the level base (`elevation: 0, slabId: null`) when it - * crosses none. Ray origin/direction are level-local. Deliberately a - * point test, not a footprint test — it answers "which surface is under - * the cursor", which then caps the footprint election so a deck hanging - * above the aimed-at floor never lifts the placement. `point` is the - * ray's crossing of that surface's plane — the stable plan point - * callers should elect/preview at (see {@link PointedSupportSurface}). - */ - getPointedSupportSurface( - levelId: string, - rayOrigin: [number, number, number], - rayDirection: [number, number, number], - ): PointedSupportSurface { - const slabMap = this.slabsByLevel.get(levelId) - const [ox, oy, oz] = rayOrigin - const [dx, dy, dz] = rayDirection - if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } - - let best: { t: number; elevation: number; slabId: string } | null = null - if (slabMap) { - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (slab.polygon.length < 3) continue - const elevation = slab.elevation ?? 0.05 - const t = (elevation - oy) / dy - if (t <= 0) continue - if (best && t >= best.t) continue - const x = ox + dx * t - const z = oz + dz * t - const rendered = this.getRenderedSlabPolygon(levelId, slab) - if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue - let inHole = false - for (const hole of slab.holes || []) { - if (hole.length >= 3 && pointInPolygon(x, z, hole)) { - inHole = true - break - } - } - if (inHole) continue - best = { t, elevation, slabId: slab.id } - } - } - if (best) { - return { - elevation: best.elevation, - slabId: best.slabId, - point: [ox + dx * best.t, oz + dz * best.t], - } - } - const tBase = -oy / dy - return { - elevation: 0, - slabId: null, - point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, - } - } - - /** - * All slabs supporting a footprint, one entry per overlapping slab - * (highest elevation first; slab id breaks ties deterministically). - * Commit-side ambiguity check: persist a `supportSlabId` only when the - * candidates carry ≥ 2 distinct elevations. - */ - getSupportCandidatesForFootprint( - levelId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): SlabSupportCandidate[] { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return [] - - const candidates: SlabSupportCandidate[] = [] - for (const stored of slabMap.values()) { - const slab = this.effectiveSlabRecord(stored) - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue - candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) - } - candidates.sort( - (a, b) => - b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), - ) - return candidates - } - - /** - * Elevation of a persisted support host for a footprint, or null when - * the slab no longer exists on the level or no longer overlaps the - * footprint (same overlap test as election). Deliberately read-only: a - * host reshaped away is NOT cleared — callers fall back to election and - * the stale reference resumes hosting if the slab's polygon returns. - * Slab deletion is the only writer (`deleteNodesAction` strips it). - */ - getHostSlabElevationForFootprint( - levelId: string, - slabId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ): number | null { - const stored = this.slabsByLevel.get(levelId)?.get(slabId) - if (!stored) return null - const slab = this.effectiveSlabRecord(stored) - if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null - return slab.elevation ?? 0.05 - } - - /** - * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). - * Returns the highest slab elevation found, or 0 if none. - * - * Accepts an optional `curveOffset` so curved walls evaluate overlap - * against their actual centerline samples, not just the chord. - */ - getSlabElevationForWall( - levelId: string, - start: [number, number], - end: [number, number], - curveOffset = 0, - thickness = DEFAULT_WALL_THICKNESS, - preferredSlabId?: string | null, - ): number { - return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) - .elevation - } - - getSlabSupportForWall( - levelId: string, - start: [number, number], - end: [number, number], - curveOffset = 0, - thickness = DEFAULT_WALL_THICKNESS, - preferredSlabId?: string | null, - maxElevation?: number | null, - supportOffset = 0, - ): WallSlabSupport { - // Sampled at the wall's own start point — the same anchor the mesh is - // positioned at, so the resolver and the renderer cannot disagree about - // where the ground is under this wall. - const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) - - if (preferredSlabId === GROUND_SUPPORT_ID) { - const elevation = levelBase + supportOffset - return { - elevation, - electedSlabId: null, - baseElevation: elevation, - baseSegments: [{ start: 0, end: 1, elevation }], - } - } - - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) { - const elevation = levelBase + supportOffset - return { - elevation, - electedSlabId: null, - baseElevation: elevation, - baseSegments: [{ start: 0, end: 1, elevation }], - } - } - - const inputs = this.getSupportInputs(levelId, slabMap) - - const support = computeWallSlabSupport( - { start, end, curveOffset, thickness }, - inputs.slabs, - inputs.walls, - preferredSlabId, - maxElevation, - levelBase, - ) - if (supportOffset === 0) return support - return { - ...support, - elevation: support.elevation + supportOffset, - baseElevation: support.baseElevation + supportOffset, - baseSegments: support.baseSegments.map((segment) => ({ - ...segment, - elevation: segment.elevation + supportOffset, - })), - } - } - - /** - * Effective slab and wall records for a level, held BY IDENTITY. A single - * viewer pass queries support once per wall, and each query used to derive - * both arrays afresh — mapping every wall on the level through - * `getEffectiveNode` — which also defeated the rendered-polygon memo - * downstream in `computeWallSlabSupport`. Rebuilt only when the scene - * nodes, either live-preview store, or the manager's own slab/wall - * bookkeeping changes. - */ - private supportInputsRevision = 0 - private readonly supportInputs = new Map< - string, - { - revision: number - nodes: object - overrides: object - transforms: object - slabs: SlabNode[] - walls: WallNode[] - } - >() - - private getSupportInputs(levelId: string, slabMap: Map) { - const nodes = useScene.getState().nodes - const overrides = useLiveNodeOverrides.getState().overrides - const transforms = useLiveTransforms.getState().transforms - const cached = this.supportInputs.get(levelId) - if ( - cached && - cached.revision === this.supportInputsRevision && - cached.nodes === nodes && - cached.overrides === overrides && - cached.transforms === transforms - ) { - return cached - } - - const next = { - revision: this.supportInputsRevision, - nodes, - overrides, - transforms, - slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), - walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), - } - this.supportInputs.set(levelId, next) - return next - } - - /** - * Walls on a level, resolved fresh from the scene store (the manager's - * own wall map is only maintained on create/delete, not on updates). - * Cached per scene `nodes` record so per-pointer-tick callers - * (door/window move) don't rescan the node map. - */ - private readonly levelWallsCache = new WeakMap>() - - private getLevelWallNodes(levelId: string): WallNode[] { - const nodes = useScene.getState().nodes - let byLevel = this.levelWallsCache.get(nodes) - if (!byLevel) { - byLevel = new Map() - this.levelWallsCache.set(nodes, byLevel) - } - const cached = byLevel.get(levelId) - if (cached) return cached - - const walls: WallNode[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'wall') continue - // Walk the parent chain to the owning level (guarded against cycles). - let current: AnyNode | undefined = node - let guard = 0 - while (current && current.type !== 'level' && guard < 16) { - current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined - guard += 1 - } - if (current?.type === 'level' && current.id === levelId) { - walls.push(node as WallNode) - } - } - byLevel.set(levelId, walls) - return walls - } - - /** - * Check if an item can be placed on a ceiling. - * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items. - */ - canPlaceOnCeiling( - ceilingId: string, - position: [number, number, number], - dimensions: [number, number, number], - rotation: [number, number, number], - ignoreIds?: string[], - ): { valid: boolean; conflictIds: string[] } { - const ceiling = this.ceilings.get(ceilingId) - if (!ceiling || ceiling.polygon.length < 3) { - return { valid: false, conflictIds: [] } - } - - // Check that the item footprint is entirely within the ceiling polygon - const corners = getItemFootprint(position, dimensions, rotation) - for (const [cx, cz] of corners) { - if (!pointInPolygon(cx, cz, ceiling.polygon)) { - return { valid: false, conflictIds: [] } - } - } - - // Check if item center is in any hole (if so, it cannot be placed) - const [centerX, , centerZ] = position - const holes = ceiling.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) { - return { valid: false, conflictIds: [] } - } - } - - const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) - const [width, , depth] = dimensions - const yRot = rotation[1] - const cos = Math.abs(Math.cos(yRot)) - const sin = Math.abs(Math.sin(yRot)) - const rotatedW = width * cos + depth * sin - const rotatedD = width * sin + depth * cos - const draftBounds = { - minX: position[0] - rotatedW / 2, - maxX: position[0] + rotatedW / 2, - minZ: position[2] - rotatedD / 2, - maxZ: position[2] + rotatedD / 2, - } - - const conflicts: string[] = [] - for (const node of Object.values(nodes)) { - if (node.type !== 'item') continue - const item = node as ItemNode - if (item.asset.attachTo !== 'ceiling') continue - if (ignoreSet.has(item.id)) continue - if (item.parentId !== ceilingId) continue - - const bounds = getItemParentAabb(item) - if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) - ) { - conflicts.push(item.id) - } - } - - return { valid: conflicts.length === 0, conflictIds: conflicts } - } - - clearLevel(levelId: string) { - this.invalidateRenderedSlabPolygons(levelId) - this.floorGrids.delete(levelId) - this.wallGrids.delete(levelId) - this.slabsByLevel.delete(levelId) - } - - clear() { - this.floorGrids.clear() - this.wallGrids.clear() - this.walls.clear() - this.slabsByLevel.clear() - this.ceilingGrids.clear() - this.ceilings.clear() - this.itemCeilingMap.clear() - this.renderedSlabPolygons.clear() - this.supportInputs.clear() - this.supportInputsRevision += 1 - } -} - -// Singleton instance -export const spatialGridManager = new SpatialGridManager() - -/** Level-local Y where the rendered wall mesh begins. */ -export function getWallBaseElevationForNodes( - wall: WallNode, - nodes: Record, -): number { - const levelId = resolveNodeLevelId(wall, nodes) - return spatialGridManager.getSlabSupportForWall( - levelId, - wall.start, - wall.end, - wall.curveOffset ?? 0, - wall.thickness, - wall.supportSlabId ?? null, - undefined, - wall.supportOffset, - ).elevation -} - -/** - * Effective (extruded) height of a wall resolved from a nodes record: - * {@link resolveWallEffectiveHeight} over the covering-clamped plane top - * (`getWallPlaneTop`) and the singleton manager's slab election — so the - * value always agrees with the rendered wall. One shared resolver for the - * editor overlays (measurement label, action menu, side handles) that used - * to copy this derivation locally. - */ -export function getWallEffectiveHeightForNodes( - wall: WallNode, - nodes: Record, -): number { - const levelId = resolveNodeLevelId(wall, nodes) - const baseElevation = getWallBaseElevationForNodes(wall, nodes) - return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) -} +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import { levelBaseElevationAt } from '../../lib/terrain-support' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' +import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { getWallPlaneTop } from '../../services/storey' +import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' +import { + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallSlabSupport, +} from '../../systems/slab/slab-support' +import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' +import { getFloorPlacedFootprints } from './floor-placed-elevation' +import { SpatialGrid } from './spatial-grid' +import { GROUND_SUPPORT_ID } from './support-host-id' +import { WallSpatialGrid } from './wall-spatial-grid' + +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallOverlapInput, + type WallSlabSupport, + type WallSlabSupportSegment, + wallOverlapsPolygon, +} from '../../systems/slab/slab-support' + +// ============================================================================ +// GEOMETRY HELPERS +// ============================================================================ + +/** + * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. + */ +function getItemFootprint( + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + inset = 0, +): Array<[number, number]> { + const [x, , z] = position + const [w, , d] = dimensions + const yRot = rotation[1] + const halfW = Math.max(0, w / 2 - inset) + const halfD = Math.max(0, d / 2 - inset) + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) + + return [ + [x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)], + [x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)], + [x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)], + [x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)], + ] +} + +/** + * Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The + * rotated width/depth is the same conservative bound the floor-placement draft + * uses, so a draft and an existing node are compared with identical math. + */ +function footprintBoundsXZ( + position: [number, number, number], + dimensions: [number, number, number], + yRot: number, +): { minX: number; maxX: number; minZ: number; maxZ: number } { + const [width, , depth] = dimensions + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + return { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } +} + +type ItemLocalBounds = { + min: [number, number, number] + max: [number, number, number] +} + +type ItemParentAabb = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number +} + +function getItemLocalBounds(item: ItemNode): ItemLocalBounds { + const [width, height, depth] = getScaledDimensions(item) + const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 + const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 + return { + min: [-width / 2, 0, minZ], + max: [width / 2, height, maxZ], + } +} + +function getItemParentAabb(item: ItemNode): ItemParentAabb { + const bounds = getItemLocalBounds(item) + const corners: Array<[number, number, number]> = [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], + ] + const yRot = item.rotation[1] ?? 0 + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [cx, cy, cz] of corners) { + const rotatedX = cx * cos + cz * sin + const rotatedZ = -cx * sin + cz * cos + const worldX = rotatedX + item.position[0] + const worldY = cy + item.position[1] + const worldZ = rotatedZ + item.position[2] + minX = Math.min(minX, worldX) + minY = Math.min(minY, worldY) + minZ = Math.min(minZ, worldZ) + maxX = Math.max(maxX, worldX) + maxY = Math.max(maxY, worldY) + maxZ = Math.max(maxZ, worldZ) + } + + return { minX, maxX, minY, maxY, minZ, maxZ } +} + +function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return minA < maxB - epsilon && maxA > minB + epsilon +} + +function resolveNodeLevelId(node: AnyNode, nodes: Record): string { + if (node.type === 'level') return node.id + + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + + return 'default' +} + +function expandIgnoredNodeIds( + ignoreIds: readonly string[] | undefined, + nodes: Record, +): Set { + const ignored = new Set(ignoreIds ?? []) + const queue = [...ignored] + + while (queue.length > 0) { + const id = queue.pop()! + const node = nodes[id] + const children = (node as { children?: unknown } | undefined)?.children + if (!Array.isArray(children)) continue + for (const childId of children) { + if (typeof childId !== 'string' || ignored.has(childId)) continue + ignored.add(childId) + queue.push(childId) + } + } + + return ignored +} + +/** + * Test if two line segments (a1->a2) and (b1->b2) intersect. + */ +function segmentsIntersect( + ax1: number, + az1: number, + ax2: number, + az2: number, + bx1: number, + bz1: number, + bx2: number, + bz2: number, +): boolean { + const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) => + (ax - ox) * (bz - oz) - (az - oz) * (bx - ox) + + const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1) + const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2) + const d3 = cross(ax1, az1, ax2, az2, bx1, bz1) + const d4 = cross(ax1, az1, ax2, az2, bx2, bz2) + + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true + } + + // Collinear touching cases + const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) => + Math.min(px, qx) <= rx && + rx <= Math.max(px, qx) && + Math.min(pz, qz) <= rz && + rz <= Math.max(pz, qz) + + if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true + if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true + if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true + if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true + + return false +} + +/** + * Test if a line segment intersects any edge of a polygon. + */ +function segmentIntersectsPolygon( + sx1: number, + sz1: number, + sx2: number, + sz2: number, + polygon: Array<[number, number]>, +): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + if ( + segmentsIntersect( + sx1, + sz1, + sx2, + sz2, + polygon[i]![0], + polygon[i]![1], + polygon[j]![0], + polygon[j]![1], + ) + ) { + return true + } + } + return false +} + +/** + * Test if an item's footprint overlaps with a polygon. + * Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect. + */ +export function itemOverlapsPolygon( + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + polygon: Array<[number, number]>, + inset = 0, +): boolean { + const corners = getItemFootprint(position, dimensions, rotation, inset) + + // Check if any item corner is inside the polygon + for (const [cx, cz] of corners) { + if (pointInPolygon(cx, cz, polygon)) return true + } + + // Check if any polygon vertex is inside the item footprint + // (handles case where slab is fully inside a large item) + for (const [px, pz] of polygon) { + if (pointInPolygon(px, pz, corners)) return true + } + + // Check if any item edge intersects any polygon edge + for (let i = 0; i < 4; i++) { + const j = (i + 1) % 4 + if ( + segmentIntersectsPolygon( + corners[i]![0], + corners[i]![1], + corners[j]![0], + corners[j]![1], + polygon, + ) + ) + return true + } + + return false +} + +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string + elevation: number +} + +export type ItemSlabSupport = { + elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null +} + +export type PointedSupportSurface = ItemSlabSupport & { + /** + * Level-local XZ where the ray meets the pointed surface's plane, or + * null when the ray never reaches it (grazing / aimed above the base). + * This is the plan point the pointer actually indicates: unlike a grid + * event-plane hit — whose XZ shifts with whatever height the event + * plane currently rides at — it depends only on the ray and the + * aimed-at surface, so election/preview at this point cannot flip when + * the event plane changes storey. + */ + point: [number, number] | null +} + +export class SpatialGridManager { + private readonly floorGrids = new Map() // levelId -> grid + private readonly wallGrids = new Map() // levelId -> wall grid + private readonly walls = new Map() // wallId -> wall data (for length calculations) + private readonly slabsByLevel = new Map>() // levelId -> (slabId -> slab) + private readonly ceilingGrids = new Map() // ceilingId -> grid + private readonly ceilings = new Map() // ceilingId -> ceiling data + private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) + + private readonly cellSize: number + + constructor(cellSize = 0.5) { + this.cellSize = cellSize + } + + private getFloorGrid(levelId: string): SpatialGrid { + if (!this.floorGrids.has(levelId)) { + this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize })) + } + return this.floorGrids.get(levelId)! + } + + private getWallGrid(levelId: string): WallSpatialGrid { + if (!this.wallGrids.has(levelId)) { + this.wallGrids.set(levelId, new WallSpatialGrid()) + } + return this.wallGrids.get(levelId)! + } + + private getWallLength(wallId: string): number { + const wall = this.walls.get(wallId) + if (!wall) return 0 + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + return Math.hypot(dx, dy) + } + + private getWallHeight(wallId: string, t?: number): number { + const wall = this.walls.get(wallId) + if (!wall) return 0 + const offset = (wall.endHeightOffset && t !== undefined) ? wall.endHeightOffset * t : 0 + if (wall.height != null) return wall.height + offset + + const nodes = useScene.getState().nodes + const levelId = resolveNodeLevelId(wall, nodes) + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + undefined, + wall.supportOffset, + ) + return resolveWallEffectiveHeight( + wall, + getWallPlaneTop(wall, levelId, nodes), + support.elevation, + t + ) + } + + private getCeilingGrid(ceilingId: string): SpatialGrid { + if (!this.ceilingGrids.has(ceilingId)) { + this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize })) + } + return this.ceilingGrids.get(ceilingId)! + } + + private getSlabMap(levelId: string): Map { + if (!this.slabsByLevel.has(levelId)) { + this.slabsByLevel.set(levelId, new Map()) + } + return this.slabsByLevel.get(levelId)! + } + + /** + * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item + * support queries run per frame and the projection scans the level's + * walls + sibling slabs, so the result is cached per slab id and + * dropped for the whole level whenever a slab or wall on that level + * flows through the manager's create/update/delete handlers. + */ + private readonly renderedSlabPolygons = new Map>() + + private invalidateRenderedSlabPolygons(levelId: string) { + this.supportInputsRevision += 1 + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return + for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) + } + + /** + * True while a slab or wall on `levelId` has a live preview: group drags + * publish translated slab polygons and wall endpoints to + * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp + * publish a translation DELTA to `useLiveTransforms` — either way the + * scene store commits only on release, so the committed cache and index + * would elect support against pre-drag footprints (items and walls + * visibly drop to ground mid-preview). Support queries then read + * live-effective records and skip the rendered-polygon cache. + */ + private levelHasLivePreview(levelId: string): boolean { + const nodes = useScene.getState().nodes + const structuralOnLevel = (id: string) => { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false + return resolveNodeLevelId(node, nodes) === levelId + } + const overrides = useLiveNodeOverrides.getState().overrides + for (const id of overrides.keys()) { + if (structuralOnLevel(id)) return true + } + const transforms = useLiveTransforms.getState().transforms + for (const id of transforms.keys()) { + if (structuralOnLevel(id)) return true + } + return false + } + + /** + * The live-effective slab record: field overrides merged, then the + * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset + * stamp — store a translation, not an absolute position) applied to the + * polygon, holes, and elevation. Mapping happens exactly ONCE at each + * public query's loop entry: `slabSupportsFootprint` / + * `getRenderedSlabPolygon` take the already-effective record and must + * never re-map, or the delta would apply twice. + */ + private effectiveSlabRecord(slab: SlabNode): SlabNode { + let effective = getEffectiveNode(slab) + const live = useLiveTransforms.getState().get(slab.id) + if (live) { + const [dx, dy, dz] = live.position + if (dx !== 0 || dy !== 0 || dz !== 0) { + effective = { + ...effective, + polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (effective.holes || []).map((hole) => + hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + elevation: (effective.elevation ?? 0.05) + dy, + } + } + } + return effective + } + + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { + const live = this.levelHasLivePreview(levelId) + if (!live) { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + } + + const siblingSlabs: SlabNode[] = [] + for (const other of this.getSlabMap(levelId).values()) { + if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) + } + const walls = this.getLevelWallNodes(levelId) + const polygon = getRenderableSlabPolygon(slab, { + walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, + siblingSlabs, + }) + if (!live) this.renderedSlabPolygons.set(slab.id, polygon) + return polygon + } + + /** + * Support test shared by election, candidate listing, and persisted-host + * validation: the footprint overlaps the slab's RENDERED polygon (what + * users see — matching the wall election in `computeWallSlabSupport`), + * with the center-point hole veto kept against the stored holes (holes + * are data, never render-offset). + */ + private slabSupportsFootprint( + levelId: string, + slab: SlabNode, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): boolean { + if (slab.polygon.length < 3) return false + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false + + const [cx, , cz] = position + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false + } + return true + } + + // Called when nodes change + handleNodeCreated(node: AnyNode, levelId: string) { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) + } else if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + // Rendered slab polygons adopt wall bands — a new wall can extend them. + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + // Wall-attached item - use parentId as the wall ID + const wallId = item.parentId + if (wallId && this.walls.has(wallId)) { + const wallLength = this.getWallLength(wallId) + if (wallLength > 0) { + const [width, height] = getScaledDimensions(item) + const halfW = width / wallLength / 2 + // Calculate t from local X position (position[0] is distance along wall) + const t = item.position[0] / wallLength + // position[1] is the bottom of the item + this.getWallGrid(levelId).insert({ + itemId: item.id, + wallId, + tStart: t - halfW, + tEnd: t + halfW, + yStart: item.position[1], + yEnd: item.position[1] + height, + attachType: item.asset.attachTo as 'wall' | 'wall-side', + side: item.side, + }) + } + } + } else if (item.asset.attachTo === 'ceiling') { + // Ceiling item - use parentId as the ceiling ID + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.itemCeilingMap.set(item.id, ceilingId) + } + } else if (!item.asset.attachTo) { + // Floor item + this.getFloorGrid(levelId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + } + } + } + + handleNodeUpdated(node: AnyNode, levelId: string) { + if (node.type === 'slab') { + this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'ceiling') { + this.ceilings.set(node.id, node as CeilingNode) + } else if (node.type === 'wall') { + const wall = node as WallNode + this.walls.set(wall.id, wall) + this.invalidateRenderedSlabPolygons(levelId) + } else if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + // Remove old placement and re-insert + this.getWallGrid(levelId).removeByItemId(item.id) + const wallId = item.parentId + if (wallId && this.walls.has(wallId)) { + const wallLength = this.getWallLength(wallId) + if (wallLength > 0) { + const [width, height] = getScaledDimensions(item) + const halfW = width / wallLength / 2 + // Calculate t from local X position (position[0] is distance along wall) + const t = item.position[0] / wallLength + // position[1] is the bottom of the item + this.getWallGrid(levelId).insert({ + itemId: item.id, + wallId, + tStart: t - halfW, + tEnd: t + halfW, + yStart: item.position[1], + yEnd: item.position[1] + height, + attachType: item.asset.attachTo as 'wall' | 'wall-side', + side: item.side, + }) + } + } + } else if (item.asset.attachTo === 'ceiling') { + // Remove from old ceiling grid + const oldCeilingId = this.itemCeilingMap.get(item.id) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(item.id) + this.itemCeilingMap.delete(item.id) + } + // Insert into new ceiling grid + const ceilingId = item.parentId + if (ceilingId && this.ceilings.has(ceilingId)) { + this.getCeilingGrid(ceilingId).insert( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + this.itemCeilingMap.set(item.id, ceilingId) + } + } else if (!item.asset.attachTo) { + this.getFloorGrid(levelId).update( + item.id, + item.position, + getScaledDimensions(item), + item.rotation, + ) + } + } + } + + handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { + if (nodeType === 'slab') { + // Invalidate before removal so the deleted slab's own cache entry + // (still keyed in the level map here) is dropped with its siblings'. + this.invalidateRenderedSlabPolygons(levelId) + this.getSlabMap(levelId).delete(nodeId) + } else if (nodeType === 'ceiling') { + this.ceilings.delete(nodeId) + this.ceilingGrids.delete(nodeId) + } else if (nodeType === 'wall') { + this.walls.delete(nodeId) + this.invalidateRenderedSlabPolygons(levelId) + // Remove all items attached to this wall from the spatial grid + const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) + return removedItemIds // Caller can use this to delete the items from scene + } else if (nodeType === 'item') { + this.getFloorGrid(levelId).remove(nodeId) + this.getWallGrid(levelId).removeByItemId(nodeId) + // Also clean up ceiling grid + const oldCeilingId = this.itemCeilingMap.get(nodeId) + if (oldCeilingId) { + this.getCeilingGrid(oldCeilingId).remove(nodeId) + this.itemCeilingMap.delete(nodeId) + } + } + return [] + } + + // Query methods + canPlaceOnFloor( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ) { + return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) + } + + canPlaceOnFloorFootprints( + levelId: string, + footprints: readonly { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }[], + ignoreIds?: string[], + ) { + const nodes = useScene.getState().nodes + const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) + const draftBounds = footprints.map((footprint) => + footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), + ) + for (let i = 0; i < draftBounds.length; i += 1) { + const a = draftBounds[i]! + for (let j = i + 1; j < draftBounds.length; j += 1) { + const b = draftBounds[j]! + if ( + intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && + intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) + ) { + return { valid: false, conflictIds: [] } + } + } + } + + // A floor placement conflicts with any other COLLIDING floor-resting node, + // not just items — every kind whose `floorPlaced.collides` is set (item / + // shelf / column / cabinet / stair) contributes its footprint(s) as an + // obstacle. Each candidate's XZ extent is read from the same declarative + // footprint the elevation + sync paths use, so adding a colliding kind + // needs no change here. + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (ignoreSet.has(node.id)) continue + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced?.collides) continue + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + // Low-profile item surfaces (rugs, mats) are stack-on targets, not + // obstacles — keep the long-standing item-only exemption. + if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue + if (resolveNodeLevelId(node, nodes) !== levelId) continue + + for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { + const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0 + const bounds = footprintBoundsXZ( + footprint.position ?? (node as { position: [number, number, number] }).position, + footprint.dimensions, + fpRotation, + ) + if ( + draftBounds.some( + (draft) => + intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), + ) + ) { + conflicts.push(node.id) + break + } + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } + } + + /** + * Check if an item can be placed on a wall + * @param levelId - the level containing the wall + * @param wallId - the wall to check + * @param localX - X position in wall-local space (distance from wall start) + * @param localY - Y position (height from floor) + * @param dimensions - item dimensions [width, height, depth] + * @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side) + * @param side - which side for 'wall-side' items + * @param ignoreIds - item IDs to ignore in collision check + */ + canPlaceOnWall( + levelId: string, + wallId: string, + localX: number, + localY: number, + dimensions: [number, number, number], + attachType: 'wall' | 'wall-side' = 'wall', + side?: 'front' | 'back', + ignoreIds?: string[], + ) { + const wallLength = this.getWallLength(wallId) + if (wallLength === 0) { + return { valid: false, conflictIds: [] } + } + // Convert local X position to parametric t (0-1) + const tCenter = localX / wallLength + const wallHeight = this.getWallHeight(wallId, tCenter) + const [itemWidth, itemHeight] = dimensions + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( + wallId, + wallLength, + wallHeight, + tCenter, + itemWidth, + localY, + itemHeight, + attachType, + side, + ignoreIds, + ) + + if (!baseResult.valid) return baseResult + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const draftBounds = { + minX: localX - itemWidth / 2, + maxX: localX + itemWidth / 2, + minY: baseResult.adjustedY, + maxY: baseResult.adjustedY + itemHeight, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== wallId) continue + + if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { + if (side !== item.side) continue + } + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) + ) { + conflicts.push(item.id) + } + } + + return { + ...baseResult, + valid: conflicts.length === 0, + conflictIds: conflicts, + } + } + + getWallForItem(levelId: string, itemId: string): string | undefined { + return this.getWallGrid(levelId).getWallForItem(itemId) + } + + /** + * Get the total slab elevation at a given (x, z) position on a level. + * Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0. + */ + getSlabElevationAt(levelId: string, x: number, z: number): number { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return 0 + + let maxElevation = 0 + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { + // Check if point is in any hole + let inHole = false + const holes = slab.holes || [] + for (const hole of holes) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { + inHole = true + break + } + } + + if (!inHole) { + const elevation = slab.elevation ?? 0.05 + if (elevation > maxElevation) { + maxElevation = elevation + } + } + } + } + return maxElevation + } + + /** + * Get the slab elevation for an item using its full footprint (bounding box). + * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) + * that only need the number. + */ + getSlabElevationForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): number { + return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) + .elevation + } + + /** + * Elect the supporting slab for a footprint: the highest-elevation slab + * whose RENDERED polygon the footprint overlaps (center-point hole veto + * applies). Returns `{ elevation: 0, slabId: null }` when nothing + * overlaps. + * + * `maxElevation` is the pointer-decided cap: when set, only slabs whose + * walking surface sits at or below `maxElevation + + * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface + * the cursor ray actually hit never captures the election. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): ItemSlabSupport { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return { elevation: 0, slabId: null } + + let winningElevation = Number.NEGATIVE_INFINITY + let winnerId: string | null = null + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + const elevation = slab.elevation ?? 0.05 + if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + if (elevation > winningElevation) { + winningElevation = elevation + winnerId = slab.id + } + } + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: winningElevation, slabId: winnerId } + } + + /** + * The walking surface the pointer actually points at: the nearest slab + * plane the ray crosses INSIDE that slab's rendered polygon (hole veto + * applies), or the level base (`elevation: 0, slabId: null`) when it + * crosses none. Ray origin/direction are level-local. Deliberately a + * point test, not a footprint test — it answers "which surface is under + * the cursor", which then caps the footprint election so a deck hanging + * above the aimed-at floor never lifts the placement. `point` is the + * ray's crossing of that surface's plane — the stable plan point + * callers should elect/preview at (see {@link PointedSupportSurface}). + */ + getPointedSupportSurface( + levelId: string, + rayOrigin: [number, number, number], + rayDirection: [number, number, number], + ): PointedSupportSurface { + const slabMap = this.slabsByLevel.get(levelId) + const [ox, oy, oz] = rayOrigin + const [dx, dy, dz] = rayDirection + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } + + let best: { t: number; elevation: number; slabId: string } | null = null + if (slabMap) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (slab.polygon.length < 3) continue + const elevation = slab.elevation ?? 0.05 + const t = (elevation - oy) / dy + if (t <= 0) continue + if (best && t >= best.t) continue + const x = ox + dx * t + const z = oz + dz * t + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue + let inHole = false + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { + inHole = true + break + } + } + if (inHole) continue + best = { t, elevation, slabId: slab.id } + } + } + if (best) { + return { + elevation: best.elevation, + slabId: best.slabId, + point: [ox + dx * best.t, oz + dz * best.t], + } + } + const tBase = -oy / dy + return { + elevation: 0, + slabId: null, + point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, + } + } + + /** + * All slabs supporting a footprint, one entry per overlapping slab + * (highest elevation first; slab id breaks ties deterministically). + * Commit-side ambiguity check: persist a `supportSlabId` only when the + * candidates carry ≥ 2 distinct elevations. + */ + getSupportCandidatesForFootprint( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): SlabSupportCandidate[] { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return [] + + const candidates: SlabSupportCandidate[] = [] + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) + } + candidates.sort( + (a, b) => + b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), + ) + return candidates + } + + /** + * Elevation of a persisted support host for a footprint, or null when + * the slab no longer exists on the level or no longer overlaps the + * footprint (same overlap test as election). Deliberately read-only: a + * host reshaped away is NOT cleared — callers fall back to election and + * the stale reference resumes hosting if the slab's polygon returns. + * Slab deletion is the only writer (`deleteNodesAction` strips it). + */ + getHostSlabElevationForFootprint( + levelId: string, + slabId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): number | null { + const stored = this.slabsByLevel.get(levelId)?.get(slabId) + if (!stored) return null + const slab = this.effectiveSlabRecord(stored) + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null + return slab.elevation ?? 0.05 + } + + /** + * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). + * Returns the highest slab elevation found, or 0 if none. + * + * Accepts an optional `curveOffset` so curved walls evaluate overlap + * against their actual centerline samples, not just the chord. + */ + getSlabElevationForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + ): number { + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) + .elevation + } + + getSlabSupportForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + maxElevation?: number | null, + supportOffset = 0, + ): WallSlabSupport { + // Sampled at the wall's own start point — the same anchor the mesh is + // positioned at, so the resolver and the renderer cannot disagree about + // where the ground is under this wall. + const levelBase = levelBaseElevationAt(useScene.getState().nodes, levelId, start[0], start[1]) + + if (preferredSlabId === GROUND_SUPPORT_ID) { + const elevation = levelBase + supportOffset + return { + elevation, + electedSlabId: null, + baseElevation: elevation, + baseSegments: [{ start: 0, end: 1, elevation }], + } + } + + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) { + const elevation = levelBase + supportOffset + return { + elevation, + electedSlabId: null, + baseElevation: elevation, + baseSegments: [{ start: 0, end: 1, elevation }], + } + } + + const inputs = this.getSupportInputs(levelId, slabMap) + + const support = computeWallSlabSupport( + { start, end, curveOffset, thickness }, + inputs.slabs, + inputs.walls, + preferredSlabId, + maxElevation, + levelBase, + ) + if (supportOffset === 0) return support + return { + ...support, + elevation: support.elevation + supportOffset, + baseElevation: support.baseElevation + supportOffset, + baseSegments: support.baseSegments.map((segment) => ({ + ...segment, + elevation: segment.elevation + supportOffset, + })), + } + } + + /** + * Effective slab and wall records for a level, held BY IDENTITY. A single + * viewer pass queries support once per wall, and each query used to derive + * both arrays afresh — mapping every wall on the level through + * `getEffectiveNode` — which also defeated the rendered-polygon memo + * downstream in `computeWallSlabSupport`. Rebuilt only when the scene + * nodes, either live-preview store, or the manager's own slab/wall + * bookkeeping changes. + */ + private supportInputsRevision = 0 + private readonly supportInputs = new Map< + string, + { + revision: number + nodes: object + overrides: object + transforms: object + slabs: SlabNode[] + walls: WallNode[] + } + >() + + private getSupportInputs(levelId: string, slabMap: Map) { + const nodes = useScene.getState().nodes + const overrides = useLiveNodeOverrides.getState().overrides + const transforms = useLiveTransforms.getState().transforms + const cached = this.supportInputs.get(levelId) + if ( + cached && + cached.revision === this.supportInputsRevision && + cached.nodes === nodes && + cached.overrides === overrides && + cached.transforms === transforms + ) { + return cached + } + + const next = { + revision: this.supportInputsRevision, + nodes, + overrides, + transforms, + slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), + walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), + } + this.supportInputs.set(levelId, next) + return next + } + + /** + * Walls on a level, resolved fresh from the scene store (the manager's + * own wall map is only maintained on create/delete, not on updates). + * Cached per scene `nodes` record so per-pointer-tick callers + * (door/window move) don't rescan the node map. + */ + private readonly levelWallsCache = new WeakMap>() + + private getLevelWallNodes(levelId: string): WallNode[] { + const nodes = useScene.getState().nodes + let byLevel = this.levelWallsCache.get(nodes) + if (!byLevel) { + byLevel = new Map() + this.levelWallsCache.set(nodes, byLevel) + } + const cached = byLevel.get(levelId) + if (cached) return cached + + const walls: WallNode[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'wall') continue + // Walk the parent chain to the owning level (guarded against cycles). + let current: AnyNode | undefined = node + let guard = 0 + while (current && current.type !== 'level' && guard < 16) { + current = current.parentId ? nodes[current.parentId as AnyNode['id']] : undefined + guard += 1 + } + if (current?.type === 'level' && current.id === levelId) { + walls.push(node as WallNode) + } + } + byLevel.set(levelId, walls) + return walls + } + + /** + * Check if an item can be placed on a ceiling. + * Validates that the footprint is within the ceiling polygon (but not in any holes) and doesn't overlap other ceiling items. + */ + canPlaceOnCeiling( + ceilingId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ): { valid: boolean; conflictIds: string[] } { + const ceiling = this.ceilings.get(ceilingId) + if (!ceiling || ceiling.polygon.length < 3) { + return { valid: false, conflictIds: [] } + } + + // Check that the item footprint is entirely within the ceiling polygon + const corners = getItemFootprint(position, dimensions, rotation) + for (const [cx, cz] of corners) { + if (!pointInPolygon(cx, cz, ceiling.polygon)) { + return { valid: false, conflictIds: [] } + } + } + + // Check if item center is in any hole (if so, it cannot be placed) + const [centerX, , centerZ] = position + const holes = ceiling.holes || [] + for (const hole of holes) { + if (hole.length >= 3 && pointInPolygon(centerX, centerZ, hole)) { + return { valid: false, conflictIds: [] } + } + } + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo !== 'ceiling') continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== ceilingId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } + } + + clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) + this.floorGrids.delete(levelId) + this.wallGrids.delete(levelId) + this.slabsByLevel.delete(levelId) + } + + clear() { + this.floorGrids.clear() + this.wallGrids.clear() + this.walls.clear() + this.slabsByLevel.clear() + this.ceilingGrids.clear() + this.ceilings.clear() + this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() + this.supportInputs.clear() + this.supportInputsRevision += 1 + } +} + +// Singleton instance +export const spatialGridManager = new SpatialGridManager() + +/** Level-local Y where the rendered wall mesh begins. */ +export function getWallBaseElevationForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + return spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + undefined, + wall.supportOffset, + ).elevation +} + +/** + * Effective (extruded) height of a wall resolved from a nodes record: + * {@link resolveWallEffectiveHeight} over the covering-clamped plane top + * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * value always agrees with the rendered wall. One shared resolver for the + * editor overlays (measurement label, action menu, side handles) that used + * to copy this derivation locally. + */ +export function getWallEffectiveHeightForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const baseElevation = getWallBaseElevationForNodes(wall, nodes) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), baseElevation) +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 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/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts index 2bbf475da7..65e1f7a8ae 100644 --- a/packages/core/src/systems/wall/wall-top.ts +++ b/packages/core/src/systems/wall/wall-top.ts @@ -1,56 +1,67 @@ -import type { WallNode } from '../../schema/nodes/wall' - -/** - * Minimum wall body height in meters. Governs both the wall height - * arrow's lower drag bound and the slab-elevation clamp: a slab may not - * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall - * elects it as its base, or the wall's extrusion (plane minus base) - * would collapse below this minimum. - */ -export const MIN_WALL_HEIGHT = 0.5 - -/** - * Wall-top inversion (vertical building model): a wall with no stored - * `height` is plane-bound — its top sits at the storey plane (level-local - * Y = the level's stored height), so a slab lifting the wall's base makes - * the wall shorter, never taller, and no gap can open at the top of a - * level. A wall WITH `height` is an explicit exception (half wall, - * parapet) and keeps the legacy semantics: the top rides a raised elected - * base (`electedBase + height`), while a zero or sunken slab base leaves - * the top at `height` (the legacy negative-slab constraint). Explicit - * ground-hosted walls are the terrain exception: `height` is always body - * height, including below datum, so sculpting cannot stretch the wall. - * - * Returns the top in level-local Y (same frame as `electedBase`). - */ -export function resolveWallTop( - wall: Pick, - storeyHeight: number, - electedBase: number, -): number { - if (wall.height == null) return storeyHeight - if (wall.supportSlabId === 'ground') return electedBase + wall.height - return electedBase > 0 ? electedBase + wall.height : wall.height -} - -/** - * Extruded height of the wall body: {@link resolveWallTop} minus the - * elected base. Base convention: the elected slab-support elevation itself - * — the viewer computes `effectiveBaseElevation = min(baseElevation, - * slabElevation)` and defaults `baseElevation` to the elected elevation, - * so with only the election in hand the two coincide. Fill-down below the - * elected base (`baseSegments`) is a geometry detail the extruder handles - * separately and never changes where the top sits. - * - * Equivalently: the wall-local Y of the wall's top, measured from the wall - * mesh origin (which sits at `electedBase`). May be non-positive when a - * slab reaches the storey plane; callers own the degenerate-geometry - * policy. - */ -export function resolveWallEffectiveHeight( - wall: Pick, - storeyHeight: number, - electedBase: number, -): number { - return resolveWallTop(wall, storeyHeight, electedBase) - electedBase -} +import type { WallNode } from '../../schema/nodes/wall' + +/** + * Minimum wall body height in meters. Governs both the wall height + * arrow's lower drag bound and the slab-elevation clamp: a slab may not + * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall + * elects it as its base, or the wall's extrusion (plane minus base) + * would collapse below this minimum. + */ +export const MIN_WALL_HEIGHT = 0.5 + +/** + * Wall-top inversion (vertical building model): a wall with no stored + * `height` is plane-bound — its top sits at the storey plane (level-local + * Y = the level's stored height), so a slab lifting the wall's base makes + * the wall shorter, never taller, and no gap can open at the top of a + * level. A wall WITH `height` is an explicit exception (half wall, + * parapet) and keeps the legacy semantics: the top rides a raised elected + * base (`electedBase + height`), while a zero or sunken slab base leaves + * the top at `height` (the legacy negative-slab constraint). Explicit + * ground-hosted walls are the terrain exception: `height` is always body + * height, including below datum, so sculpting cannot stretch the wall. + * + * Returns the top in level-local Y (same frame as `electedBase`). + */ +export function resolveWallTop( + wall: Pick, + storeyHeight: number, + electedBase: number, + t?: number, +): number { + let top: number + if (wall.height == null) { + top = storeyHeight + } else if (wall.supportSlabId === 'ground') { + top = electedBase + wall.height + } else { + top = electedBase > 0 ? electedBase + wall.height : wall.height + } + if (wall.endHeightOffset && t !== undefined) { + top += wall.endHeightOffset * t + } + return top +} + +/** + * Extruded height of the wall body: {@link resolveWallTop} minus the + * elected base. Base convention: the elected slab-support elevation itself + * — the viewer computes `effectiveBaseElevation = min(baseElevation, + * slabElevation)` and defaults `baseElevation` to the elected elevation, + * so with only the election in hand the two coincide. Fill-down below the + * elected base (`baseSegments`) is a geometry detail the extruder handles + * separately and never changes where the top sits. + * + * Equivalently: the wall-local Y of the wall's top, measured from the wall + * mesh origin (which sits at `electedBase`). May be non-positive when a + * slab reaches the storey plane; callers own the degenerate-geometry + * policy. + */ +export function resolveWallEffectiveHeight( + wall: Pick, + storeyHeight: number, + electedBase: number, + t?: number, +): number { + return resolveWallTop(wall, storeyHeight, electedBase, t) - electedBase +} diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 0d2dc398e6..70a04d8bbc 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -231,12 +231,14 @@ 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) @@ -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/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index f84f48c0ff..10b16231e2 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -13,6 +13,7 @@ import { getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, + getWallArcData, getWallThickness, isCurvedWall, type Point2D, @@ -933,6 +934,80 @@ 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, + localArc?: { center: { x: number; z: number }; direction: number } | null, +): void { + const rawOffset = wallNode.endHeightOffset + if (!rawOffset || wallLength < 1e-9) { + return + } + const wallHeight = wallNode.height ?? 2.5 + const minEndHeight = 0.01 + const endHeightOffset = Math.max(rawOffset, -(wallHeight - minEndHeight)) + const position = geometry.getAttribute('position') as THREE.BufferAttribute + + const getSignedAngleDiff = (from: number, to: number) => { + let diff = to - from + while (diff > Math.PI) diff -= Math.PI * 2 + while (diff < -Math.PI) diff += Math.PI * 2 + return diff + } + + let startAngle = 0 + let delta = 0 + if (localArc) { + // Determine start angle of the arc from local origin (0,0) + startAngle = Math.atan2(0 - localArc.center.z, 0 - localArc.center.x) + // Determine end angle of the arc at (wallLength, 0) + const endAngle = Math.atan2(0 - localArc.center.z, wallLength - localArc.center.x) + delta = getSignedAngleDiff(startAngle, endAngle) + + // Ensure delta has the correct sign matching the arc direction. + // For exact semicircles, getSignedAngleDiff might return -PI when we want PI. + if (localArc.direction > 0 && delta < -1e-6) { + delta += Math.PI * 2 + } else if (localArc.direction < 0 && delta > 1e-6) { + delta -= Math.PI * 2 + } + } + + for (let i = 0; i < position.count; i++) { + if (Math.abs(position.getY(i) - topY) > 1e-4) continue + + let t: number + if (localArc && Math.abs(delta) > 1e-6) { + const px = position.getX(i) + const pz = position.getZ(i) + const vertexAngle = Math.atan2(pz - localArc.center.z, px - localArc.center.x) + let vertexDelta = vertexAngle - startAngle + + // Unwrap vertexDelta so it stays close to the expected angle for this position. + // This prevents vertices on the end caps from wrapping around the PI boundary. + const expectedDelta = delta * (px / wallLength) + while (vertexDelta - expectedDelta > Math.PI) vertexDelta -= Math.PI * 2 + while (vertexDelta - expectedDelta < -Math.PI) vertexDelta += Math.PI * 2 + + t = THREE.MathUtils.clamp(vertexDelta / delta, 0, 1) + } else { + t = THREE.MathUtils.clamp(position.getX(i) / wallLength, 0, 1) + } + position.setY(i, topY + endHeightOffset * t) + } + position.needsUpdate = true +} + export function generateExtrudedWall( wallNode: WallNode, childrenNodes: AnyNode[], @@ -1017,8 +1092,14 @@ export function generateExtrudedWall( }) // Rotate so extrusion direction (Z) becomes height direction (Y) + const arc = isCurvedWall(wallNode) ? getWallArcData(wallNode) : null + const localArc = arc + ? { center: worldToLocal(arc.center), direction: arc.direction } + : null + geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) + applyWallEndHeightSlope(geometry, wallNode, L, localBottom + height, localArc) geometry.computeVertexNormals() assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry)