From 727cf4c808a972a089cd1478abc438fa7d09ac67 Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Wed, 5 Aug 2026 02:38:11 +0400 Subject: [PATCH 1/2] core: shared pure plan-footprint math for spatial-grid and MCP Extract rotation-aware XZ footprint AABB/corners and expand-then-intersect gap overlap into packages/core/lib/plan-footprint. Spatial-grid and alignment-anchors delegate to it. Export via @pascal-app/core/plan-footprint and spatial-grid. Design note documents one source and gap call-site rules after #569. --- packages/core/package.json | 5 + .../spatial-grid/spatial-grid-manager.ts | 46 +++--- packages/core/src/lib/plan-footprint.test.ts | 149 ++++++++++++++++++ packages/core/src/lib/plan-footprint.ts | 136 ++++++++++++++++ .../core/src/services/alignment-anchors.ts | 36 +---- wiki/architecture/README.md | 1 + wiki/architecture/plan-footprint.md | 43 +++++ 7 files changed, 358 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/lib/plan-footprint.test.ts create mode 100644 packages/core/src/lib/plan-footprint.ts create mode 100644 wiki/architecture/plan-footprint.md diff --git a/packages/core/package.json b/packages/core/package.json index 8612ea2e7c..1992ad3748 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,11 @@ "import": "./dist/hooks/spatial-grid/spatial-grid-manager.js", "default": "./dist/hooks/spatial-grid/spatial-grid-manager.js" }, + "./plan-footprint": { + "types": "./dist/lib/plan-footprint.d.ts", + "import": "./dist/lib/plan-footprint.js", + "default": "./dist/lib/plan-footprint.js" + }, "./wall": { "types": "./dist/systems/wall/wall-footprint.d.ts", "import": "./dist/systems/wall/wall-footprint.js", 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..2f919c5781 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,3 +1,8 @@ +import { + planFootprintAABB, + planFootprintCorners, + type PlanAabb, +} from '../../lib/plan-footprint' import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { levelBaseElevationAt } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' @@ -32,9 +37,19 @@ export { } from '../../systems/slab/slab-support' // ============================================================================ -// GEOMETRY HELPERS +// GEOMETRY HELPERS (delegate to pure plan-footprint — one source with MCP/editor) // ============================================================================ +export { + aabbsOverlapPlan, + planFootprintAABB, + planFootprintAABBForItem, + planFootprintAABBFromCorners, + planFootprintCorners, + type PlanAabb, + type PlanVec2, +} from '../../lib/plan-footprint' + /** * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. */ @@ -44,20 +59,7 @@ function getItemFootprint( 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)], - ] + return planFootprintCorners(position, dimensions, rotation[1], inset) } /** @@ -69,18 +71,8 @@ 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, - } +): PlanAabb { + return planFootprintAABB(position, dimensions, yRot) } type ItemLocalBounds = { diff --git a/packages/core/src/lib/plan-footprint.test.ts b/packages/core/src/lib/plan-footprint.test.ts new file mode 100644 index 0000000000..713f28da4d --- /dev/null +++ b/packages/core/src/lib/plan-footprint.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'bun:test' +import { ItemNode } from '../schema' +import { + aabbsOverlapPlan, + planFootprintAABB, + planFootprintAABBForItem, + planFootprintAABBFromCorners, + planFootprintCorners, +} from './plan-footprint' + +describe('planFootprintAABB', () => { + test('unrotated box is centred at position', () => { + const aabb = planFootprintAABB([10, 0, 20], [2, 1, 4], 0) + expect(aabb).toEqual({ minX: 9, maxX: 11, minZ: 18, maxZ: 22 }) + }) + + test('90° rotation swaps width and depth extents', () => { + const aabb = planFootprintAABB([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) + }) + + test('45° rotation expands AABB (rotation-aware extents)', () => { + const aabb = planFootprintAABB([0, 0, 0], [2, 1, 2], Math.PI / 4) + // rotated half-extent = (2*(√2/2) + 2*(√2/2))/2 = √2 ≈ 1.414 + expect(aabb.maxX).toBeCloseTo(Math.SQRT2, 10) + expect(aabb.minX).toBeCloseTo(-Math.SQRT2, 10) + expect(aabb.maxZ).toBeCloseTo(Math.SQRT2, 10) + expect(aabb.minZ).toBeCloseTo(-Math.SQRT2, 10) + }) +}) + +describe('planFootprintCorners parity with AABB', () => { + test('AABB from corners matches planFootprintAABB (axis-aligned)', () => { + const pos: [number, number, number] = [3, 0, 5] + const dims: [number, number, number] = [2, 1, 4] + const fromFast = planFootprintAABB(pos, dims, 0) + const fromCorners = planFootprintAABBFromCorners(pos, dims, 0) + expect(fromCorners.minX).toBeCloseTo(fromFast.minX, 10) + expect(fromCorners.maxX).toBeCloseTo(fromFast.maxX, 10) + expect(fromCorners.minZ).toBeCloseTo(fromFast.minZ, 10) + expect(fromCorners.maxZ).toBeCloseTo(fromFast.maxZ, 10) + }) + + test('AABB from corners matches planFootprintAABB (rotated)', () => { + const pos: [number, number, number] = [1, 0, -2] + const dims: [number, number, number] = [1.5, 1, 3] + const y = Math.PI / 3 + const fromFast = planFootprintAABB(pos, dims, y) + const fromCorners = planFootprintAABBFromCorners(pos, dims, y) + expect(fromCorners.minX).toBeCloseTo(fromFast.minX, 10) + expect(fromCorners.maxX).toBeCloseTo(fromFast.maxX, 10) + expect(fromCorners.minZ).toBeCloseTo(fromFast.minZ, 10) + expect(fromCorners.maxZ).toBeCloseTo(fromFast.maxZ, 10) + }) + + test('four corners form a rectangle of expected half-extents when unrotated', () => { + const corners = planFootprintCorners([0, 0, 0], [4, 1, 2], 0) + expect(corners).toHaveLength(4) + const xs = corners.map((c) => c[0]).sort((a, b) => a - b) + const zs = corners.map((c) => c[1]).sort((a, b) => a - b) + expect(xs[0]).toBeCloseTo(-2, 10) + expect(xs[3]).toBeCloseTo(2, 10) + expect(zs[0]).toBeCloseTo(-1, 10) + expect(zs[3]).toBeCloseTo(1, 10) + }) +}) + +describe('aabbsOverlapPlan gap semantics', () => { + test('gap 0 only true interpenetration', () => { + const a = { minX: 0, maxX: 1, minZ: 0, maxZ: 1 } + const b = { minX: 1.05, maxX: 2.05, minZ: 0, maxZ: 1 } + expect(aabbsOverlapPlan(a, b, 0)).toBe(false) + const c = { minX: 0.9, maxX: 1.9, minZ: 0, maxZ: 1 } + expect(aabbsOverlapPlan(a, c, 0)).toBe(true) + }) + + test('gap 0.08 collides boxes 0.05 m apart (expand-then-intersect)', () => { + // half-width 0.5 each, centres 1.05 apart → 0.05 m free gap + const a = { minX: 0, maxX: 1, minZ: 0, maxZ: 1 } + const b = { minX: 1.05, maxX: 2.05, minZ: 0, maxZ: 1 } + expect(aabbsOverlapPlan(a, b, 0.08)).toBe(true) + expect(aabbsOverlapPlan(a, b, 0.04)).toBe(false) + }) +}) + +describe('planFootprintAABBForItem', () => { + test('uses scaled dimensions', () => { + const item = ItemNode.parse({ + name: 'Box', + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [2, 1, 3], + asset: { + id: 'box', + name: 'Box', + category: 'furniture', + thumbnail: '/t.webp', + src: '/m.glb', + dimensions: [1, 1, 1], + }, + }) + const aabb = planFootprintAABBForItem(item) + expect(aabb).not.toBeNull() + // width 2, depth 3 unrotated + expect(aabb!.minX).toBeCloseTo(-1, 10) + expect(aabb!.maxX).toBeCloseTo(1, 10) + expect(aabb!.minZ).toBeCloseTo(-1.5, 10) + expect(aabb!.maxZ).toBeCloseTo(1.5, 10) + }) + + test('returns null for wall-hosted items', () => { + const item = ItemNode.parse({ + name: 'Shelf', + position: [0, 1, 0], + rotation: [0, 0, 0], + asset: { + id: 'shelf', + name: 'Shelf', + category: 'furniture', + thumbnail: '/t.webp', + src: '/m.glb', + dimensions: [1, 0.2, 0.3], + attachTo: 'wall', + }, + }) + expect(planFootprintAABBForItem(item)).toBeNull() + }) + + test('returns null for ceiling-hosted items', () => { + const item = ItemNode.parse({ + name: 'Light', + position: [0, 2.5, 0], + rotation: [0, 0, 0], + asset: { + id: 'light', + name: 'Light', + category: 'furniture', + thumbnail: '/t.webp', + src: '/m.glb', + dimensions: [0.3, 0.2, 0.3], + attachTo: 'ceiling', + }, + }) + expect(planFootprintAABBForItem(item)).toBeNull() + }) +}) diff --git a/packages/core/src/lib/plan-footprint.ts b/packages/core/src/lib/plan-footprint.ts new file mode 100644 index 0000000000..6acb271c24 --- /dev/null +++ b/packages/core/src/lib/plan-footprint.ts @@ -0,0 +1,136 @@ +/** + * Pure plan (XZ) footprint math — one source for MCP layout clearance, + * spatial-grid collision, and alignment anchors. + * + * ## Why this exists (post #569 / Aymericr) + * MCP layout clearance and core spatial-grid each need rotation-aware plan + * boxes. Duplicating the formula risks a rotation-sign drift. Callers share + * these helpers; do not invent a third AABB path. + * + * ## Gap call-site meanings + * - `aabbsOverlapPlan(a, b, gap)` treats `gap` as **minimum free space** + * (expand-then-intersect). Positive gap means boxes that close to within + * `gap` meters still count as overlapping. + * - **Packing / furnish:** typically `gap ≈ 0.08` for breathing room. + * - **check / verify collision:** use `gap = 0` for true interpenetration only. + * + * ## Scope + * Foundation only after MCP layout clearance (#569). Does not move door + * keep-outs, level ancestry, or furnish search into core. + * + * ## Non-floor hosts + * `planFootprintAABBForItem` returns null for wall / wall-side / ceiling + * attach targets so wall-local coords are never treated as world XZ. + */ + +import type { ItemNode } from '../schema' +import { getScaledDimensions } from '../schema' + +export type PlanAabb = { + minX: number + maxX: number + minZ: number + maxZ: number +} + +export type PlanVec2 = [number, number] + +/** + * Four XZ corners of a centred footprint at `position`, rotated by Y + * rotation. Matches spatial-grid `getItemFootprint` convention: + * local +X maps with (cos, sin), local +Z with (-sin, cos) terms as used + * in the existing corner formula. + */ +export function planFootprintCorners( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, + inset = 0, +): PlanVec2[] { + const [x, , z] = position + const [w, , d] = dimensions + const halfW = Math.max(0, w / 2 - inset) + const halfD = Math.max(0, d / 2 - inset) + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + + 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. Equivalent to the AABB of + * `planFootprintCorners` (no inset) and to spatial-grid `footprintBoundsXZ`. + */ +export function planFootprintAABB( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, +): PlanAabb { + const [width, , depth] = dimensions + const cos = Math.abs(Math.cos(rotationY)) + const sin = Math.abs(Math.sin(rotationY)) + 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, + } +} + +/** + * True when A and B come closer than `gap` meters (including penetration). + * `gap` is minimum free space: expand each box by `gap` effectively via + * `a.maxX + gap > b.minX && a.minX - gap < b.maxX` (same for Z). + */ +export function aabbsOverlapPlan(a: PlanAabb, b: PlanAabb, gap = 0): boolean { + return ( + a.maxX + gap > b.minX && + a.minX - gap < b.maxX && + a.maxZ + gap > b.minZ && + a.minZ - gap < b.maxZ + ) +} + +/** + * Plan AABB for a scene item using **scaled** dimensions. + * Returns null for wall / wall-side / ceiling hosted items (local frame, + * not world-XZ floor packing). + */ +export function planFootprintAABBForItem(item: ItemNode): PlanAabb | null { + const attach = item.asset?.attachTo + if (attach === 'wall' || attach === 'wall-side' || attach === 'ceiling') { + return null + } + const dimensions = getScaledDimensions(item) + const rotationY = Array.isArray(item.rotation) ? (item.rotation[1] ?? 0) : 0 + const position = (item.position ?? [0, 0, 0]) as [number, number, number] + return planFootprintAABB(position, dimensions, rotationY) +} + +/** Corners AABB — same bounds as `planFootprintAABB` (sanity / exact match). */ +export function planFootprintAABBFromCorners( + position: readonly [number, number, number], + dimensions: readonly [number, number, number], + rotationY: number, + inset = 0, +): PlanAabb { + const corners = planFootprintCorners(position, dimensions, rotationY, inset) + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const [cx, cz] of corners) { + if (cx < minX) minX = cx + if (cx > maxX) maxX = cx + if (cz < minZ) minZ = cz + if (cz > maxZ) maxZ = cz + } + return { minX, maxX, minZ, maxZ } +} diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 5032b154a5..43c501b9dd 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -12,51 +12,25 @@ * entirely in that frame, so the resulting guides line up with the cursor. */ +import { planFootprintAABB, type PlanAabb } from '../lib/plan-footprint' 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 } +export type FootprintAABB = PlanAabb /** * 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. + * `position`. Delegates to pure `planFootprintAABB` (same math as + * spatial-grid / MCP layout clearance). */ 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 } + return planFootprintAABB(position, dimensions, rotationY) } /** The relocatable box footprint for a node, or null when it has none diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 51dbf359e6..7e2dd59ae0 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -24,6 +24,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` | | [vertical-model](vertical-model.md) | Stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, clamp rules, and the load migration | | [events](events.md) | Typed event bus — emitting and listening to node and grid events | +| [plan-footprint](plan-footprint.md) | Shared pure XZ footprint AABB / corners / gap overlap (spatial-grid + MCP) | | [creating-rules](creating-rules.md) | How to add or update a page in this folder | ## Reading order for an architecture review diff --git a/wiki/architecture/plan-footprint.md b/wiki/architecture/plan-footprint.md new file mode 100644 index 0000000000..d62ef252db --- /dev/null +++ b/wiki/architecture/plan-footprint.md @@ -0,0 +1,43 @@ +# Plan footprint math (shared core) + +*Pure XZ footprint helpers for floor-placed items — one source for MCP layout clearance, spatial-grid, and alignment anchors.* + +Applies to: `packages/core/src/lib/plan-footprint.ts`, re-exported from `@pascal-app/core/spatial-grid` and `@pascal-app/core/plan-footprint`. + +## Why + +After MCP layout clearance (#569), Aymericr noted footprint / AABB overlap should not be a third parallel implementation next to spatial-grid. This module **extracts** the spatial-grid corner + AABB formulas into a pure Node-safe surface. + +## API (pure) + +| Helper | Role | +|---|---| +| `planFootprintCorners` | 4 XZ corners (optional inset) | +| `planFootprintAABB` | Conservative rotation-aware AABB | +| `aabbsOverlapPlan(a, b, gap?)` | Expand-then-intersect; `gap` = min free space | +| `planFootprintAABBForItem` | Scaled dims; **null** for wall/ceiling hosts | + +## Gap call-site meanings + +| Call site | Typical `gap` | Meaning | +|---|---|---| +| Furnish / packing | ~0.08 m | Breathing room between items | +| `check_collisions` / hard verify | `0` | True interpenetration only | + +Do not share one default blindly across both questions. + +## Non-goals (this foundation) + +- Door keep-outs, level ancestry, planned entrances (layout policy) +- Full MCP/editor rewire of furnish / verify +- Polygon-exact collision as the only API (`itemOverlapsPolygon` remains) + +## Imports + +```ts +// Pure (preferred for MCP / Node) +import { planFootprintAABB, aabbsOverlapPlan } from '@pascal-app/core/plan-footprint' + +// Also re-exported next to spatial-grid manager +import { planFootprintAABB } from '@pascal-app/core/spatial-grid' +``` From 6384a73b7aefe914d3e9be6ce3cb0f234f4936df Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Wed, 5 Aug 2026 03:54:49 +0400 Subject: [PATCH 2/2] core: biome format plan-footprint helpers --- .../src/hooks/spatial-grid/spatial-grid-manager.ts | 10 +++------- packages/core/src/lib/plan-footprint.ts | 5 +---- packages/core/src/services/alignment-anchors.ts | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) 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 2f919c5781..be3140745c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,8 +1,4 @@ -import { - planFootprintAABB, - planFootprintCorners, - type PlanAabb, -} from '../../lib/plan-footprint' +import { type PlanAabb, planFootprintAABB, planFootprintCorners } from '../../lib/plan-footprint' import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { levelBaseElevationAt } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' @@ -42,12 +38,12 @@ export { export { aabbsOverlapPlan, + type PlanAabb, + type PlanVec2, planFootprintAABB, planFootprintAABBForItem, planFootprintAABBFromCorners, planFootprintCorners, - type PlanAabb, - type PlanVec2, } from '../../lib/plan-footprint' /** diff --git a/packages/core/src/lib/plan-footprint.ts b/packages/core/src/lib/plan-footprint.ts index 6acb271c24..b3846da4f6 100644 --- a/packages/core/src/lib/plan-footprint.ts +++ b/packages/core/src/lib/plan-footprint.ts @@ -91,10 +91,7 @@ export function planFootprintAABB( */ export function aabbsOverlapPlan(a: PlanAabb, b: PlanAabb, gap = 0): boolean { return ( - a.maxX + gap > b.minX && - a.minX - gap < b.maxX && - a.maxZ + gap > b.minZ && - a.minZ - gap < b.maxZ + a.maxX + gap > b.minX && a.minX - gap < b.maxX && a.maxZ + gap > b.minZ && a.minZ - gap < b.maxZ ) } diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 43c501b9dd..9930f18d57 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -12,7 +12,7 @@ * entirely in that frame, so the resulting guides line up with the cursor. */ -import { planFootprintAABB, type PlanAabb } from '../lib/plan-footprint' +import { type PlanAabb, planFootprintAABB } from '../lib/plan-footprint' import { nodeRegistry } from '../registry' import type { AnyNode } from '../schema/types' import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'