Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/ifc-converter/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export {
resolveElevatorServiceLevelIds,
resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export {
} from './subtree'
export type {
Affordance,
AlignmentFootprint,
AlignmentFootprintConfig,
AnyNodeDefinition,
AssetRef,
Capabilities,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,14 @@ export type Capabilities = {
selectable?: SelectableConfig
interactive?: boolean
floorPlaced?: FloorPlacedConfig
/**
* Plan footprint this kind exposes to the alignment-anchor pool when it
* isn't `floorPlaced` and isn't a structural primitive the bridge handles
* directly (wall, slab). Lets a kind self-describe where it sits in plan
* instead of the core anchor bridge hardcoding it per type. See
* `AlignmentFootprintConfig`.
*/
alignmentFootprint?: AlignmentFootprintConfig
roofAccessory?: RoofAccessoryConfig
paint?: PaintCapability
/**
Expand Down Expand Up @@ -1244,6 +1252,34 @@ export type FloorPlacedConfig = {
applies?: (node: AnyNode) => boolean
}

/**
* Plan footprint a kind contributes to the alignment-anchor pool when it is
* neither `floorPlaced` (columns / items, whose footprint the bridge already
* reads) nor a primitive the bridge knows structurally (walls → segments,
* slabs → polygons). Two shapes:
*
* - `box` — a rotatable rectangle centred on the node's `position`. Use
* when the kind also moves by its footprint edges (elevator): the anchor
* bridge relocates the box to the proposed drag point, so one descriptor
* serves both the static candidate and the moving node.
* - `aabb` — an already-resolved XZ bounding box, for kinds whose plan
* shape isn't a centred rectangle (stair: a segment chain or annular
* sector). Static candidates only — these kinds move by their origin, so
* the box's relocation path never needs them.
*
* `nodes` is supplied only when a kind needs siblings / children to resolve
* its footprint (a straight stair walks its `stair-segment` children); box
* kinds derive everything from `node` alone.
*/
export type AlignmentFootprint =
| { shape: 'box'; dimensions: [number, number, number]; rotation: [number, number, number] }
| { shape: 'aabb'; minX: number; minZ: number; maxX: number; maxZ: number }

export type AlignmentFootprintConfig = (
node: AnyNode,
nodes?: Readonly<Record<string, AnyNode>>,
) => AlignmentFootprint | null

// ─── Relations ───────────────────────────────────────────────────────

export type Relations = {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/schema/nodes/elevator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const ElevatorNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around the Y axis in radians.
rotation: z.number().default(0),
width: z.number().default(1.6),
depth: z.number().default(1.6),
width: z.number().default(1.84),
depth: z.number().default(1.84),
shaftWidth: z.number().optional(),
shaftDepth: z.number().optional(),
shaftWallThickness: z.number().default(0.09),
Expand Down
170 changes: 168 additions & 2 deletions packages/core/src/services/alignment-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ 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,
Expand Down Expand Up @@ -34,6 +40,49 @@ function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNod
} 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<Record<string, AnyNode>>) => {
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,
Expand Down Expand Up @@ -79,11 +128,16 @@ describe('footprintAABB', () => {
expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull()
})

test('derives an elevator footprint from its width / depth (no floorPlaced needed)', () => {
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: 9, minZ: 18, maxX: 11, maxZ: 22 })
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', () => {
Expand Down Expand Up @@ -214,4 +268,116 @@ describe('collectAlignmentAnchors', () => {
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)
})
})
Loading
Loading