diff --git a/packages/core/package.json b/packages/core/package.json index 1d6ff58f0e..302058cb8b 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..5e4ef6a55f 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,4 @@ +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' @@ -32,9 +33,16 @@ export { } from '../../systems/slab/slab-support' // ============================================================================ -// GEOMETRY HELPERS +// GEOMETRY HELPERS (delegate to pure plan-footprint — one source with MCP/editor) // ============================================================================ +export { + type PlanAabb, + type PlanVec2, + planFootprintAABB, + planFootprintCorners, +} from '../../lib/plan-footprint' + /** * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. */ @@ -44,20 +52,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 +64,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..bb02a5156c --- /dev/null +++ b/packages/core/src/lib/plan-footprint.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { planFootprintAABB, 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', () => { + 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) + }) + + test('AABB of 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 corners = planFootprintCorners(pos, dims, 0) + const xs = corners.map((c) => c[0]) + const zs = corners.map((c) => c[1]) + expect(Math.min(...xs)).toBeCloseTo(fromFast.minX, 10) + expect(Math.max(...xs)).toBeCloseTo(fromFast.maxX, 10) + expect(Math.min(...zs)).toBeCloseTo(fromFast.minZ, 10) + expect(Math.max(...zs)).toBeCloseTo(fromFast.maxZ, 10) + }) + + test('AABB of 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 corners = planFootprintCorners(pos, dims, y) + const xs = corners.map((c) => c[0]) + const zs = corners.map((c) => c[1]) + expect(Math.min(...xs)).toBeCloseTo(fromFast.minX, 10) + expect(Math.max(...xs)).toBeCloseTo(fromFast.maxX, 10) + expect(Math.min(...zs)).toBeCloseTo(fromFast.minZ, 10) + expect(Math.max(...zs)).toBeCloseTo(fromFast.maxZ, 10) + }) +}) diff --git a/packages/core/src/lib/plan-footprint.ts b/packages/core/src/lib/plan-footprint.ts new file mode 100644 index 0000000000..2af345fbaf --- /dev/null +++ b/packages/core/src/lib/plan-footprint.ts @@ -0,0 +1,79 @@ +/** + * Pure plan (XZ) footprint math — one source for spatial-grid collision and + * alignment anchors. The `@pascal-app/core/plan-footprint` subpath is the seam + * for a follow-up that consolidates MCP layout clearance onto these helpers. + * + * ## Invariant + * Callers share `planFootprintCorners` / `planFootprintAABB`. Do not invent a + * third rotation-aware plan AABB path beside this module. + * + * ## Gap call-site meanings (for the follow-up overlap helper) + * When an expand-then-intersect overlap check lands here, treat `gap` as + * **minimum free space** (expand each box by `gap`, then intersect): + * - Packing / furnish: typically `gap ≈ 0.08` for breathing room between items + * - check / verify collision: use `gap = 0` for true interpenetration only + * Do not share one default blindly across both questions. + * + * ## Scope + * Foundation only. Does not move door keep-outs, level ancestry, or furnish + * search into core. Item-scaled / attach-aware wrappers stay with callers + * until the MCP consolidation follow-up. + */ + +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, + } +} diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 5032b154a5..9930f18d57 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 { 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' 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