ai-chatbot/packages/core/src/services/alignment-anchors.ts
2026-06-08 01:07:53 -04:00

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
}