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
39 changes: 12 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,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.
*/
Expand All @@ -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)
}

/**
Expand All @@ -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 = {
Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/lib/plan-footprint.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
79 changes: 79 additions & 0 deletions packages/core/src/lib/plan-footprint.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
36 changes: 5 additions & 31 deletions packages/core/src/services/alignment-anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading