352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
/**
|
|
* Node → alignment-anchor adapters.
|
|
*
|
|
* `alignment.ts` is pure geometry and knows nothing about nodes. This
|
|
* module bridges the scene graph to it: it reads a floor-placed kind's
|
|
* footprint from the registry and turns it into the bbox anchors the
|
|
* resolver matches against. Kept out of `alignment.ts` so that file stays
|
|
* registry-free.
|
|
*
|
|
* All coordinates are XZ meters in the same frame as `node.position`
|
|
* (building-local for nodes inside a building). The 3D move producer works
|
|
* entirely in that frame, so the resulting guides line up with the cursor.
|
|
*/
|
|
|
|
import { nodeRegistry } from '../registry'
|
|
import type { AnyNode } from '../schema/types'
|
|
import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
|
|
import { type AlignmentAnchor, bboxCornerAnchors } from './alignment'
|
|
|
|
export type FootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
|
|
|
|
/**
|
|
* Axis-aligned XZ bounding box of a rotated rectangle centred at
|
|
* `position`. Mirrors the rotated-corner math the spatial-grid manager
|
|
* uses (`getItemFootprint`) so alignment anchors coincide with the
|
|
* footprint used for collision / slab elevation.
|
|
*/
|
|
export function footprintAABBFrom(
|
|
position: readonly [number, number, number],
|
|
dimensions: readonly [number, number, number],
|
|
rotationY: number,
|
|
): FootprintAABB {
|
|
const [x, , z] = position
|
|
const [w, , d] = dimensions
|
|
const halfW = w / 2
|
|
const halfD = d / 2
|
|
const cos = Math.cos(rotationY)
|
|
const sin = Math.sin(rotationY)
|
|
|
|
let minX = Number.POSITIVE_INFINITY
|
|
let minZ = Number.POSITIVE_INFINITY
|
|
let maxX = Number.NEGATIVE_INFINITY
|
|
let maxZ = Number.NEGATIVE_INFINITY
|
|
|
|
for (const [lx, lz] of [
|
|
[-halfW, -halfD],
|
|
[halfW, -halfD],
|
|
[halfW, halfD],
|
|
[-halfW, halfD],
|
|
] as const) {
|
|
const wx = x + (lx * cos - lz * sin)
|
|
const wz = z + (lx * sin + lz * cos)
|
|
if (wx < minX) minX = wx
|
|
if (wx > maxX) maxX = wx
|
|
if (wz < minZ) minZ = wz
|
|
if (wz > maxZ) maxZ = wz
|
|
}
|
|
|
|
return { minX, minZ, maxX, maxZ }
|
|
}
|
|
|
|
/** The relocatable box footprint for a node, or null when it has none
|
|
* (walls / slabs / polygon kinds) or the kind's predicate excludes it
|
|
* (e.g. a wall-attached item that doesn't rest on the floor).
|
|
*
|
|
* Box footprints come from one of two capabilities: `floorPlaced` (kinds
|
|
* whose Y is also slab-lifted — columns, items) or `alignmentFootprint`
|
|
* with a `box` shape (kinds that align by their footprint but aren't
|
|
* floor-coupled — the elevator's outer shaft). A kind whose
|
|
* `alignmentFootprint` is an `aabb` (stair) has no centred box, so it's
|
|
* resolved directly in `nodeAlignmentAnchors`, not here. */
|
|
function floorFootprint(
|
|
node: AnyNode,
|
|
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
|
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
|
const floorPlaced = capabilities?.floorPlaced
|
|
// `footprint` is optional now that floor-placed kinds may instead declare
|
|
// composite `footprints` (e.g. stairs); those have no single centred box
|
|
// here, so fall through to `alignmentFootprint`.
|
|
if (floorPlaced?.footprint) {
|
|
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
|
return floorPlaced.footprint(node)
|
|
}
|
|
const alignment = capabilities?.alignmentFootprint?.(node)
|
|
if (alignment?.shape === 'box') {
|
|
return { dimensions: alignment.dimensions, rotation: alignment.rotation }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* XZ bounding box a node occupies in plan, unifying the two non-structural
|
|
* sources: a relocatable box (`floorFootprint`, covering floor-placed kinds
|
|
* and the elevator's alignment box) and a kind that hands back an explicit
|
|
* `aabb` because its plan shape isn't a centred rectangle (stair). Returns
|
|
* null for kinds with neither.
|
|
*/
|
|
function alignmentAABB(
|
|
node: AnyNode,
|
|
nodes?: Readonly<Record<string, AnyNode>>,
|
|
): FootprintAABB | null {
|
|
const box = footprintAABB(node)
|
|
if (box) return box
|
|
const alignment = nodeRegistry.get(node.type)?.capabilities?.alignmentFootprint?.(node, nodes)
|
|
if (alignment?.shape === 'aabb') {
|
|
return {
|
|
minX: alignment.minX,
|
|
minZ: alignment.minZ,
|
|
maxX: alignment.maxX,
|
|
maxZ: alignment.maxZ,
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** XZ footprint AABB of a floor-placed node at its current position, or
|
|
* null for kinds without a usable footprint. */
|
|
export function footprintAABB(node: AnyNode): FootprintAABB | null {
|
|
const fp = floorFootprint(node)
|
|
if (!fp) return null
|
|
const position = (node as { position?: [number, number, number] }).position ?? [0, 0, 0]
|
|
return footprintAABBFrom(position, fp.dimensions, fp.rotation[1] ?? 0)
|
|
}
|
|
|
|
/** XZ footprint AABB of a floor-placed node relocated so its centre sits at
|
|
* the proposed (x, z). `rotationY` overrides the node's footprint rotation
|
|
* (R/T bumps it before the scene commit lands). Null when no footprint. */
|
|
export function footprintAABBAt(
|
|
node: AnyNode,
|
|
x: number,
|
|
z: number,
|
|
rotationY?: number,
|
|
): FootprintAABB | null {
|
|
const fp = floorFootprint(node)
|
|
if (!fp) return null
|
|
return footprintAABBFrom([x, 0, z], fp.dimensions, rotationY ?? fp.rotation[1] ?? 0)
|
|
}
|
|
|
|
/**
|
|
* Corner anchors for the moving node's footprint relocated so its centre
|
|
* sits at the proposed (x, z). Corners only — the moving item aligns by its
|
|
* edges, never its centreline. Returns [] when the kind has no footprint.
|
|
*/
|
|
export function movingFootprintAnchors(
|
|
node: AnyNode,
|
|
x: number,
|
|
z: number,
|
|
rotationY?: number,
|
|
): AlignmentAnchor[] {
|
|
const aabb = footprintAABBAt(node, x, z, rotationY)
|
|
if (!aabb) return []
|
|
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
|
}
|
|
|
|
function relocatedPlanNode(node: AnyNode, x: number, z: number, rotationY?: number): AnyNode {
|
|
const position = (node as { position?: unknown }).position
|
|
const y = Array.isArray(position) && typeof position[1] === 'number' ? position[1] : 0
|
|
const relocated: Record<string, unknown> = {
|
|
...(node as Record<string, unknown>),
|
|
position: [x, y, z],
|
|
}
|
|
|
|
if (rotationY !== undefined && 'rotation' in node) {
|
|
const rotation = (node as { rotation?: unknown }).rotation
|
|
relocated.rotation = Array.isArray(rotation)
|
|
? [rotation[0] ?? 0, rotationY, rotation[2] ?? 0]
|
|
: rotationY
|
|
}
|
|
|
|
return relocated as AnyNode
|
|
}
|
|
|
|
/**
|
|
* Corner anchors for a moving node relocated to the proposed plan position.
|
|
* Covers both the centred-box path (`floorPlaced.footprint` /
|
|
* `alignmentFootprint: box`) and explicit AABB footprints such as stairs,
|
|
* whose occupied plan bounds depend on children or curved/spiral geometry.
|
|
*/
|
|
export function movingAlignmentAnchors(
|
|
node: AnyNode,
|
|
nodes: Readonly<Record<string, AnyNode>> | undefined,
|
|
x: number,
|
|
z: number,
|
|
rotationY?: number,
|
|
): AlignmentAnchor[] {
|
|
const box = footprintAABBAt(node, x, z, rotationY)
|
|
if (box) return bboxCornerAnchors(node.id, box.minX, box.minZ, box.maxX, box.maxZ)
|
|
|
|
const alignment = nodeRegistry
|
|
.get(node.type)
|
|
?.capabilities?.alignmentFootprint?.(relocatedPlanNode(node, x, z, rotationY), nodes)
|
|
|
|
if (alignment?.shape === 'box') {
|
|
const aabb = footprintAABBFrom([x, 0, z], alignment.dimensions, alignment.rotation[1] ?? 0)
|
|
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
|
|
}
|
|
if (alignment?.shape === 'aabb') {
|
|
return bboxCornerAnchors(
|
|
node.id,
|
|
alignment.minX,
|
|
alignment.minZ,
|
|
alignment.maxX,
|
|
alignment.maxZ,
|
|
)
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Alignment anchors for a wall segment: the two centerline endpoints + chord
|
|
* midpoint, plus — when `thickness` is known — four **face** corner anchors,
|
|
* each endpoint offset by ±thickness/2 perpendicular to the wall axis.
|
|
*
|
|
* The face anchors are what let a footprint align to a wall's *face* rather
|
|
* than its centerline: for an axis-aligned wall the two same-side face
|
|
* anchors share a constant X (vertical wall) or Z (horizontal wall) running
|
|
* the wall's full length, so the point-to-point resolver snaps a moving
|
|
* corner flush to the face anywhere along the wall (the perpendicular
|
|
* tie-break connects the guide to the nearer face endpoint). A diagonal wall
|
|
* gets only its face/centerline endpoints — point-to-point can't represent a
|
|
* sloped face line; that's an accepted v1 limitation.
|
|
*
|
|
* Curve offset is ignored — endpoints are exact and the chord midpoint is
|
|
* good enough for v1. Coordinates are the wall's `start` / `end`
|
|
* (building-local XZ meters).
|
|
*/
|
|
export function wallSegmentAnchors(
|
|
id: string,
|
|
start: readonly [number, number],
|
|
end: readonly [number, number],
|
|
thickness?: number,
|
|
): AlignmentAnchor[] {
|
|
const anchors: AlignmentAnchor[] = [
|
|
{ nodeId: id, kind: 'corner', x: start[0], z: start[1] },
|
|
{ nodeId: id, kind: 'corner', x: end[0], z: end[1] },
|
|
{ nodeId: id, kind: 'center', x: (start[0] + end[0]) / 2, z: (start[1] + end[1]) / 2 },
|
|
]
|
|
|
|
if (thickness && thickness > 0) {
|
|
const dx = end[0] - start[0]
|
|
const dz = end[1] - start[1]
|
|
const len = Math.hypot(dx, dz)
|
|
if (len > 1e-6) {
|
|
// Perpendicular to the wall axis, scaled to half-thickness.
|
|
const half = thickness / 2
|
|
const px = (-dz / len) * half
|
|
const pz = (dx / len) * half
|
|
for (const [bx, bz] of [start, end] as const) {
|
|
anchors.push({ nodeId: id, kind: 'corner', x: bx + px, z: bz + pz })
|
|
anchors.push({ nodeId: id, kind: 'corner', x: bx - px, z: bz - pz })
|
|
}
|
|
}
|
|
}
|
|
|
|
return anchors
|
|
}
|
|
|
|
/** Each vertex of a polygon (slab / ceiling footprint) as a `corner` anchor. */
|
|
export function polygonAnchors(
|
|
id: string,
|
|
points: readonly (readonly [number, number])[],
|
|
): AlignmentAnchor[] {
|
|
return points.map(([x, z]) => ({ nodeId: id, kind: 'corner' as const, x, z }))
|
|
}
|
|
|
|
/**
|
|
* Alignment anchors a node contributes to the candidate pool, dispatched by
|
|
* kind: walls / fences → segment endpoints + midpoint; slabs / ceilings →
|
|
* polygon vertices; everything else → the corners of its plan bounding box
|
|
* (`alignmentAABB`, which covers floor-placed kinds, the elevator's
|
|
* alignment box, and the stair's chain / sector footprint). Kinds with no
|
|
* usable footprint contribute nothing.
|
|
*
|
|
* `nodes` is needed only by kinds whose footprint walks siblings / children
|
|
* (a straight stair's `stair-segment` chain); every other kind derives its
|
|
* anchors from `node` alone.
|
|
*/
|
|
export function nodeAlignmentAnchors(
|
|
node: AnyNode,
|
|
nodes?: Readonly<Record<string, AnyNode>>,
|
|
): AlignmentAnchor[] {
|
|
if (node.type === 'wall' || node.type === 'fence') {
|
|
const seg = node as {
|
|
id: string
|
|
start: [number, number]
|
|
end: [number, number]
|
|
thickness?: number
|
|
}
|
|
// Wall thickness is schema-optional (falls back to the geometry default);
|
|
// fence always carries one. Either way, pass it through so faces align.
|
|
return wallSegmentAnchors(seg.id, seg.start, seg.end, seg.thickness ?? DEFAULT_WALL_THICKNESS)
|
|
}
|
|
if (node.type === 'slab' || node.type === 'ceiling') {
|
|
const poly = (node as { polygon?: [number, number][] }).polygon
|
|
return poly ? polygonAnchors(node.id, poly) : []
|
|
}
|
|
const aabb = alignmentAABB(node, nodes)
|
|
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
|
|
}
|
|
|
|
/**
|
|
* Resolve the level a node belongs to by walking its `parentId` chain, or
|
|
* null when it isn't under a level. Inlined here (rather than importing the
|
|
* spatial-grid `resolveLevelId`) to keep this services module free of
|
|
* hook / store dependencies.
|
|
*/
|
|
function resolveNodeLevelId(
|
|
node: AnyNode,
|
|
nodes: Readonly<Record<string, AnyNode>>,
|
|
): string | null {
|
|
let current: AnyNode | undefined = node
|
|
while (current) {
|
|
if (current.type === 'level') return current.id
|
|
current = current.parentId ? nodes[current.parentId] : undefined
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Anchors from every alignable node except `excludeId` — the unified
|
|
* candidate pool every move / placement tool resolves against, so any
|
|
* draggable object can align to any other (items, walls, fences, slabs,
|
|
* ceilings, columns).
|
|
*
|
|
* When `levelId` is given, nodes that belong to a *different* level are
|
|
* dropped. Alignment is XZ-only, so without this a node directly below on
|
|
* another floor (e.g. the ground floor while you place on the first) would
|
|
* snap and draw a guide even though the two sit at different heights.
|
|
* Building-/site-scoped nodes with no level ancestor (e.g. an elevator
|
|
* shaft, which is parented to the building and spans every floor) resolve to
|
|
* null and stay in the pool so they align on any floor. The 2D floor-plan
|
|
* deliberately omits the filter — aligning a wall to the one directly below
|
|
* in plan is the whole point of the reference floor.
|
|
*/
|
|
export function collectAlignmentAnchors(
|
|
nodes: Readonly<Record<string, AnyNode>>,
|
|
excludeId: string,
|
|
levelId?: string | null,
|
|
): AlignmentAnchor[] {
|
|
const anchors: AlignmentAnchor[] = []
|
|
for (const node of Object.values(nodes)) {
|
|
if (!node || node.id === excludeId) continue
|
|
if (levelId) {
|
|
const nodeLevelId = resolveNodeLevelId(node, nodes)
|
|
if (nodeLevelId !== null && nodeLevelId !== levelId) continue
|
|
}
|
|
anchors.push(...nodeAlignmentAnchors(node, nodes))
|
|
}
|
|
return anchors
|
|
}
|