Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 15 additions & 27 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -32,9 +33,19 @@ export {
} from '../../systems/slab/slab-support'

// ============================================================================
// GEOMETRY HELPERS
// GEOMETRY HELPERS (delegate to pure plan-footprint — one source with MCP/editor)
// ============================================================================

export {
aabbsOverlapPlan,
type PlanAabb,
type PlanVec2,
planFootprintAABB,
planFootprintAABBForItem,
planFootprintAABBFromCorners,
planFootprintCorners,
} from '../../lib/plan-footprint'

/**
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
*/
Expand All @@ -44,20 +55,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)
}

/**
Expand All @@ -69,18 +67,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 = {
Expand Down
149 changes: 149 additions & 0 deletions packages/core/src/lib/plan-footprint.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
133 changes: 133 additions & 0 deletions packages/core/src/lib/plan-footprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* 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 }
}
Loading
Loading