import { beforeEach, describe, expect, test } from 'bun:test' import { z } from 'zod' import { nodeRegistry, registerNode } from '../registry' import type { AnyNodeDefinition } from '../registry/types' import type { AnyNode } from '../schema/types' import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, } from '../systems/elevator/elevator-geometry' import { stairFootprintAABB } from '../systems/stair/stair-footprint' import { collectAlignmentAnchors, footprintAABB, footprintAABBFrom, movingAlignmentAnchors, movingFootprintAnchors, polygonAnchors, wallSegmentAnchors, } from './alignment-anchors' // Minimal floor-placed def whose footprint reads `dimensions` / `rotation` // straight off the node, so tests can drive the AABB math directly. function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNodeDefinition { return { kind, schemaVersion: 1, schema: z.object({ type: z.literal(kind) }) as any, category: 'utility', defaults: () => ({}) as any, capabilities: { floorPlaced: { footprint: (n: AnyNode) => ({ dimensions: (n as { dimensions?: [number, number, number] }).dimensions ?? [1, 1, 1], rotation: (n as { rotation?: [number, number, number] }).rotation ?? [0, 0, 0], }), ...(applies ? { applies } : {}), }, }, renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, } as AnyNodeDefinition } // Mirrors the real elevator/stair definitions, which expose their plan // footprint via the `alignmentFootprint` capability rather than a hardcoded // branch in the anchor bridge. The glue (shaft-outset box / stair AABB) is // reproduced here from the same core helpers production uses. function elevatorDef(): AnyNodeDefinition { return { kind: 'elevator', schemaVersion: 1, schema: z.object({ type: z.literal('elevator') }) as any, category: 'structure', defaults: () => ({}) as any, capabilities: { alignmentFootprint: (n: AnyNode) => { const e = n as any const wall = getElevatorShaftWallThickness(e) return { shape: 'box', dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2], rotation: [0, e.rotation ?? 0, 0], } }, }, renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, } as AnyNodeDefinition } function stairDef(): AnyNodeDefinition { return { kind: 'stair', schemaVersion: 1, schema: z.object({ type: z.literal('stair') }) as any, category: 'structure', defaults: () => ({}) as any, capabilities: { alignmentFootprint: (n: AnyNode, nodes?: Readonly>) => { const aabb = stairFootprintAABB(n as any, nodes) return aabb ? { shape: 'aabb', ...aabb } : null }, }, renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, } as AnyNodeDefinition } function plainDef(kind: string): AnyNodeDefinition { return { kind, schemaVersion: 1, schema: z.object({ type: z.literal(kind) }) as any, category: 'utility', defaults: () => ({}) as any, capabilities: {}, renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, } as AnyNodeDefinition } const node = (over: Record): AnyNode => over as unknown as AnyNode describe('footprintAABBFrom', () => { test('unrotated box is centred at position', () => { const aabb = footprintAABBFrom([10, 0, 20], [2, 1, 4], 0) expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) }) test('90° rotation swaps width and depth extents', () => { const aabb = footprintAABBFrom([0, 0, 0], [2, 1, 4], Math.PI / 2) expect(aabb.minX).toBeCloseTo(-2, 10) expect(aabb.maxX).toBeCloseTo(2, 10) expect(aabb.minZ).toBeCloseTo(-1, 10) expect(aabb.maxZ).toBeCloseTo(1, 10) }) }) describe('footprintAABB', () => { beforeEach(() => nodeRegistry._reset()) test('reads dimensions + rotation from a floor-placed kind', () => { registerNode(floorPlacedDef('box')) const aabb = footprintAABB( node({ id: 'b1', type: 'box', position: [10, 0, 20], dimensions: [2, 1, 4] }), ) expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 }) }) test('returns null for a kind without a footprint', () => { registerNode(plainDef('wall')) expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull() }) test('derives an elevator footprint from its OUTER SHAFT, not the cab', () => { // Aligns to the visible shaft outline: cab 2×4 + 0.09 m wall each side → // 2.18 × 4.18, centred at (10, 20). The cab corners alone would sit ~9 cm // inside the drawn edge (past the 8 cm snap), so a guide never appeared. // The footprint comes from the elevator's `alignmentFootprint` (box) cap. registerNode(elevatorDef()) const aabb = footprintAABB( node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }), ) expect(aabb).toEqual({ minX: 8.91, minZ: 17.91, maxX: 11.09, maxZ: 22.09 }) }) test('returns null when the kind predicate excludes the node', () => { registerNode(floorPlacedDef('lamp', (n) => !(n as { attached?: boolean }).attached)) expect( footprintAABB(node({ id: 'l1', type: 'lamp', position: [0, 0, 0], attached: true })), ).toBeNull() expect( footprintAABB(node({ id: 'l2', type: 'lamp', position: [0, 0, 0], attached: false })), ).not.toBeNull() }) }) describe('movingFootprintAnchors', () => { beforeEach(() => nodeRegistry._reset()) test('relocates the footprint corners around the proposed centre (edges only, no centre anchor)', () => { registerNode(floorPlacedDef('box')) const anchors = movingFootprintAnchors( node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4] }), 10, 20, ) // 2×4 box centred at (10, 20): corners at x∈{9,11}, z∈{18,22}. expect(anchors).toHaveLength(4) expect(anchors.every((a) => a.kind === 'corner')).toBe(true) expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9, 11])) expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([18, 22])) }) test('rotationY override drives the AABB regardless of node rotation', () => { registerNode(floorPlacedDef('box')) const anchors = movingFootprintAnchors( node({ id: 'm', type: 'box', position: [0, 0, 0], dimensions: [2, 1, 4], rotation: [0, 0, 0], }), 0, 0, Math.PI / 2, ) const xs = anchors.map((a) => a.x) // Rotated 90°, the 2×4 box spans ±2 in X (its depth) rather than ±1. expect(Math.max(...xs)).toBeCloseTo(2, 10) expect(Math.min(...xs)).toBeCloseTo(-2, 10) }) test('returns empty for a footprintless kind', () => { registerNode(plainDef('wall')) expect( movingFootprintAnchors(node({ id: 'w', type: 'wall', position: [0, 0, 0] }), 1, 1), ).toEqual([]) }) }) describe('movingAlignmentAnchors', () => { beforeEach(() => nodeRegistry._reset()) test('relocates a straight stair by its segment-chain footprint', () => { registerNode(stairDef()) const nodes = { st: node({ id: 'st', type: 'stair', position: [0, 0, 0], rotation: 0, stairType: 'straight', width: 1, children: ['seg'], }), seg: node({ id: 'seg', type: 'stair-segment', parentId: 'st', width: 1, length: 3, height: 2.5, attachmentSide: 'front', }), } const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, 0) expect(anchors).toHaveLength(4) expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([9.5, 10.5])) expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([20, 23])) }) test('rotation override drives a moving straight stair footprint', () => { registerNode(stairDef()) const nodes = { st: node({ id: 'st', type: 'stair', position: [0, 0, 0], rotation: 0, stairType: 'straight', width: 1, children: ['seg'], }), seg: node({ id: 'seg', type: 'stair-segment', parentId: 'st', width: 1, length: 3, height: 2.5, attachmentSide: 'front', }), } const anchors = movingAlignmentAnchors(nodes.st, nodes, 10, 20, Math.PI / 2) const xs = anchors.map((a) => a.x) const zs = anchors.map((a) => a.z) expect(Math.min(...xs)).toBeCloseTo(10, 10) expect(Math.max(...xs)).toBeCloseTo(13, 10) expect(Math.min(...zs)).toBeCloseTo(19.5, 10) expect(Math.max(...zs)).toBeCloseTo(20.5, 10) }) }) describe('wallSegmentAnchors', () => { test('returns both endpoints as corners and the chord midpoint as center', () => { const anchors = wallSegmentAnchors('w', [0, 0], [4, 2]) expect(anchors).toEqual([ { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, { nodeId: 'w', kind: 'corner', x: 4, z: 2 }, { nodeId: 'w', kind: 'center', x: 2, z: 1 }, ]) }) test('adds ±thickness/2 face corners on each endpoint when thickness is given', () => { // Horizontal wall along +X: perpendicular is ±Z, so faces sit at z = ±0.1. const anchors = wallSegmentAnchors('w', [0, 0], [4, 0], 0.2) expect(anchors).toEqual([ { nodeId: 'w', kind: 'corner', x: 0, z: 0 }, { nodeId: 'w', kind: 'corner', x: 4, z: 0 }, { nodeId: 'w', kind: 'center', x: 2, z: 0 }, { nodeId: 'w', kind: 'corner', x: 0, z: 0.1 }, { nodeId: 'w', kind: 'corner', x: 0, z: -0.1 }, { nodeId: 'w', kind: 'corner', x: 4, z: 0.1 }, { nodeId: 'w', kind: 'corner', x: 4, z: -0.1 }, ]) }) test('skips face corners for zero/degenerate input', () => { expect(wallSegmentAnchors('w', [0, 0], [4, 0], 0)).toHaveLength(3) expect(wallSegmentAnchors('w', [1, 1], [1, 1], 0.2)).toHaveLength(3) }) }) describe('polygonAnchors', () => { test('returns each vertex as a corner anchor', () => { expect( polygonAnchors('s', [ [0, 0], [2, 0], [2, 3], ]), ).toEqual([ { nodeId: 's', kind: 'corner', x: 0, z: 0 }, { nodeId: 's', kind: 'corner', x: 2, z: 0 }, { nodeId: 's', kind: 'corner', x: 2, z: 3 }, ]) }) }) describe('collectAlignmentAnchors', () => { beforeEach(() => nodeRegistry._reset()) test('unions footprint corners, segment anchors and polygon vertices, excluding the moving node', () => { registerNode(floorPlacedDef('box')) const nodes = { moving: node({ id: 'moving', type: 'box', position: [0, 0, 0], dimensions: [1, 1, 1] }), box: node({ id: 'box', type: 'box', position: [5, 0, 5], dimensions: [2, 1, 2] }), wall: node({ id: 'wall', type: 'wall', start: [0, 0], end: [4, 0] }), slab: node({ id: 'slab', type: 'slab', polygon: [ [0, 0], [2, 0], [2, 2], ], }), } const anchors = collectAlignmentAnchors(nodes, 'moving') const ids = anchors.map((a) => a.nodeId) expect(ids).not.toContain('moving') expect(ids.filter((id) => id === 'box')).toHaveLength(4) // corner anchors expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices }) test('levelId filter keeps only nodes resolving to that level (incl. nested)', () => { registerNode(floorPlacedDef('box')) registerNode(elevatorDef()) const nodes = { b: node({ id: 'b', type: 'building' }), L1: node({ id: 'L1', type: 'level', parentId: 'b' }), L2: node({ id: 'L2', type: 'level', parentId: 'b' }), moving: node({ id: 'moving', type: 'box', parentId: 'L1', position: [0, 0, 0] }), sameFloor: node({ id: 'sameFloor', type: 'box', parentId: 'L1', position: [5, 0, 5] }), // Item resting on `sameFloor` — resolves to L1 through the parent chain. nested: node({ id: 'nested', type: 'box', parentId: 'sameFloor', position: [5, 0, 5] }), otherFloor: node({ id: 'otherFloor', type: 'box', parentId: 'L2', position: [5, 0, 5] }), // Building-scoped (parented to the building, no level ancestor) — spans // every floor, so it stays in the pool regardless of the active level. elevator: node({ id: 'elevator', type: 'elevator', parentId: 'b', position: [9, 0, 9], width: 1.6, depth: 1.6, }), } const ids = collectAlignmentAnchors(nodes, 'moving', 'L1').map((a) => a.nodeId) expect(ids.filter((id) => id === 'sameFloor')).toHaveLength(4) expect(ids.filter((id) => id === 'nested')).toHaveLength(4) expect(ids.filter((id) => id === 'elevator')).toHaveLength(4) expect(ids).not.toContain('otherFloor') }) test('straight stair contributes its segment-chain footprint corners', () => { registerNode(stairDef()) const nodes = { st: node({ id: 'st', type: 'stair', position: [0, 0, 0], rotation: 0, stairType: 'straight', width: 1, children: ['seg'], }), // Single 1×3 flight; origin at the run start, extending +Z by length. seg: node({ id: 'seg', type: 'stair-segment', parentId: 'st', width: 1, length: 3, height: 2.5, attachmentSide: 'front', }), } const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'st') expect(anchors).toHaveLength(4) expect(anchors.every((a) => a.kind === 'corner')).toBe(true) expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([-0.5, 0.5])) expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([0, 3])) }) test('curved stair contributes its sector bounding-box corners', () => { registerNode(stairDef()) const nodes = { cs: node({ id: 'cs', type: 'stair', position: [0, 0, 0], rotation: 0, stairType: 'curved', width: 1, innerRadius: 1, sweepAngle: Math.PI / 2, }), } const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'cs') expect(anchors).toHaveLength(4) // outerRadius = inner(1) + width(1) = 2, sweep π/2 centred on +X. Outer rim // reaches X=2 at the bisector; min X is the inner rim's ±π/4 ends (cos45·1); // Z spans ±(outer·sin45). const xs = anchors.map((a) => a.x) const zs = anchors.map((a) => a.z) expect(Math.max(...xs)).toBeCloseTo(2, 5) expect(Math.min(...xs)).toBeCloseTo(Math.SQRT1_2, 5) expect(Math.max(...zs)).toBeCloseTo(Math.SQRT2, 5) expect(Math.min(...zs)).toBeCloseTo(-Math.SQRT2, 5) }) test('spiral stair contributes a full-circle bounding box', () => { registerNode(stairDef()) const nodes = { sp: node({ id: 'sp', type: 'stair', position: [5, 0, 5], rotation: 0, stairType: 'spiral', width: 1, innerRadius: 0.5, sweepAngle: Math.PI * 2, }), } const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'sp') expect(anchors).toHaveLength(4) // outerRadius = inner(0.5) + width(1) = 1.5, a full revolution about (5, 5). const xs = anchors.map((a) => a.x) const zs = anchors.map((a) => a.z) expect(Math.max(...xs)).toBeCloseTo(6.5, 2) expect(Math.min(...xs)).toBeCloseTo(3.5, 2) expect(Math.max(...zs)).toBeCloseTo(6.5, 2) expect(Math.min(...zs)).toBeCloseTo(3.5, 2) }) })