editor: level-scoped alignment, reference-floor symbols & registry slab tool (#373)
* feat(editor): level-scoped alignment, reference-floor registry symbols, registry slab tool - Scope alignment candidates to the active level so a node directly below on another floor no longer snaps (alignment is XZ-only); building-scoped nodes like elevator shafts stay in the pool across floors. - Derive the elevator alignment footprint from its outer shaft (not the inset cab), so its guide actually surfaces within the snap threshold. - Extract shared stair footprint geometry (rotateXZ, segment transforms, stairFootprintAABB) so opening-sync and alignment anchors derive the chain identically; stairs now contribute plan bbox anchors. - Render reference-floor stairs/roofs/elevators/shelves/spawns through their registry floorplan builders for pixel-identical symbols. - Move slab creation to the registry-driven slab tool (parity with ceiling); 2D handlers only maintain draft state. - Lift the 3D alignment guide ribbon to the active level's Y each frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): registry-drive elevator/stair alignment footprints Address architecture review of the level-scoped alignment work: the core anchor bridge (`alignment-anchors.ts`) hardcoded `if (node.type === 'elevator')` and `if (node.type === 'stair')` branches to derive their plan footprints. Move that onto the kinds themselves via a new `alignmentFootprint` capability so the bridge dispatches generically — matching the registry composition model. - Add `Capabilities.alignmentFootprint`: returns a `box` (rotatable rect centred on position, relocatable for movable kinds) or an `aabb` (already resolved, for non-rectangular plan shapes). Elevator uses `box` (its outer shaft, and it's movable); stair uses `aabb` (segment chain / annular sector, moves by origin). - Drop both hardcoded branches; the bridge now consults the capability via `floorFootprint` (box) and a unified `alignmentAABB` (box ∪ aabb). - Export `stairFootprintAABB` from core so the stair definition consumes it. - Tests register synthetic defs carrying the capability (the bridge no longer knows elevator/stair by name), reproducing the production glue from the same core helpers. - Document why `REFERENCE_REGISTRY_KINDS` is a deliberate editor-local curation, not an auto-derived set (most floorplan-builder kinds shouldn't appear as standalone reference symbols, and "reference floor" is an editor concept core must not know). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): hide node arrow handles during thumbnail capture The arrow-handle rig lives on SCENE_LAYER (so its chevrons read as proper 3D plates), but the thumbnail camera only filters EDITOR_LAYER + GRID_LAYER — so a node selected at capture time would leak its arrows into the snapshot. Hide the rig on `thumbnail:before-capture` and restore it on `thumbnail:after-capture`, the same emitter handshake SelectionManager uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
46f94b97b3
commit
d1b40aa98d
@@ -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 {
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
} from './subtree'
|
||||
export type {
|
||||
Affordance,
|
||||
AlignmentFootprint,
|
||||
AlignmentFootprintConfig,
|
||||
AnyNodeDefinition,
|
||||
AssetRef,
|
||||
Capabilities,
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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', () => {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,23 +59,53 @@ export function footprintAABBFrom(
|
||||
return { minX, minZ, maxX, maxZ }
|
||||
}
|
||||
|
||||
/** The floor-placed footprint config for a node, or null when it has none
|
||||
/** The relocatable box footprint for a node, or null when it has none
|
||||
* (walls / slabs / polygon kinds) or the kind's predicate excludes it
|
||||
* (e.g. a wall-attached item that doesn't rest on the floor). */
|
||||
* (e.g. a wall-attached item that doesn't rest on the floor).
|
||||
*
|
||||
* Box footprints come from one of two capabilities: `floorPlaced` (kinds
|
||||
* whose Y is also slab-lifted — columns, items) or `alignmentFootprint`
|
||||
* with a `box` shape (kinds that align by their footprint but aren't
|
||||
* floor-coupled — the elevator's outer shaft). A kind whose
|
||||
* `alignmentFootprint` is an `aabb` (stair) has no centred box, so it's
|
||||
* resolved directly in `nodeAlignmentAnchors`, not here. */
|
||||
function floorFootprint(
|
||||
node: AnyNode,
|
||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
||||
const floorPlaced = capabilities?.floorPlaced
|
||||
if (floorPlaced) {
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||
return floorPlaced.footprint(node)
|
||||
}
|
||||
// Elevator isn't a `floorPlaced` kind (no slab-elevation coupling) but it
|
||||
// does rest on the floor with a `width × depth` cab — give it a footprint
|
||||
// so it aligns like other boxes (the registry move tool reads this).
|
||||
if (node.type === 'elevator') {
|
||||
const e = node as { width?: number; depth?: number; rotation?: number }
|
||||
return { dimensions: [e.width ?? 1.6, 1, e.depth ?? 1.6], rotation: [0, e.rotation ?? 0, 0] }
|
||||
const alignment = capabilities?.alignmentFootprint?.(node)
|
||||
if (alignment?.shape === 'box') {
|
||||
return { dimensions: alignment.dimensions, rotation: alignment.rotation }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ bounding box a node occupies in plan, unifying the two non-structural
|
||||
* sources: a relocatable box (`floorFootprint`, covering floor-placed kinds
|
||||
* and the elevator's alignment box) and a kind that hands back an explicit
|
||||
* `aabb` because its plan shape isn't a centred rectangle (stair). Returns
|
||||
* null for kinds with neither.
|
||||
*/
|
||||
function alignmentAABB(
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): FootprintAABB | null {
|
||||
const box = footprintAABB(node)
|
||||
if (box) return box
|
||||
const alignment = nodeRegistry.get(node.type)?.capabilities?.alignmentFootprint?.(node, nodes)
|
||||
if (alignment?.shape === 'aabb') {
|
||||
return {
|
||||
minX: alignment.minX,
|
||||
minZ: alignment.minZ,
|
||||
maxX: alignment.maxX,
|
||||
maxZ: alignment.maxZ,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -178,11 +208,20 @@ export function polygonAnchors(
|
||||
|
||||
/**
|
||||
* Alignment anchors a node contributes to the candidate pool, dispatched by
|
||||
* kind: floor-placed footprints → corner anchors; walls / fences → segment
|
||||
* endpoints + midpoint; slabs / ceilings → polygon vertices. Kinds without a
|
||||
* kind: walls / fences → segment endpoints + midpoint; slabs / ceilings →
|
||||
* polygon vertices; everything else → the corners of its plan bounding box
|
||||
* (`alignmentAABB`, which covers floor-placed kinds, the elevator's
|
||||
* alignment box, and the stair's chain / sector footprint). Kinds with no
|
||||
* usable footprint contribute nothing.
|
||||
*
|
||||
* `nodes` is needed only by kinds whose footprint walks siblings / children
|
||||
* (a straight stair's `stair-segment` chain); every other kind derives its
|
||||
* anchors from `node` alone.
|
||||
*/
|
||||
export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] {
|
||||
export function nodeAlignmentAnchors(
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): AlignmentAnchor[] {
|
||||
if (node.type === 'wall' || node.type === 'fence') {
|
||||
const seg = node as {
|
||||
id: string
|
||||
@@ -198,24 +237,57 @@ export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] {
|
||||
const poly = (node as { polygon?: [number, number][] }).polygon
|
||||
return poly ? polygonAnchors(node.id, poly) : []
|
||||
}
|
||||
const aabb = footprintAABB(node)
|
||||
const aabb = alignmentAABB(node, nodes)
|
||||
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the level a node belongs to by walking its `parentId` chain, or
|
||||
* null when it isn't under a level. Inlined here (rather than importing the
|
||||
* spatial-grid `resolveLevelId`) to keep this services module free of
|
||||
* hook / store dependencies.
|
||||
*/
|
||||
function resolveNodeLevelId(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): string | null {
|
||||
let current: AnyNode | undefined = node
|
||||
while (current) {
|
||||
if (current.type === 'level') return current.id
|
||||
current = current.parentId ? nodes[current.parentId] : undefined
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors from every alignable node except `excludeId` — the unified
|
||||
* candidate pool every move / placement tool resolves against, so any
|
||||
* draggable object can align to any other (items, walls, fences, slabs,
|
||||
* ceilings, columns).
|
||||
*
|
||||
* When `levelId` is given, nodes that belong to a *different* level are
|
||||
* dropped. Alignment is XZ-only, so without this a node directly below on
|
||||
* another floor (e.g. the ground floor while you place on the first) would
|
||||
* snap and draw a guide even though the two sit at different heights.
|
||||
* Building-/site-scoped nodes with no level ancestor (e.g. an elevator
|
||||
* shaft, which is parented to the building and spans every floor) resolve to
|
||||
* null and stay in the pool so they align on any floor. The 2D floor-plan
|
||||
* deliberately omits the filter — aligning a wall to the one directly below
|
||||
* in plan is the whole point of the reference floor.
|
||||
*/
|
||||
export function collectAlignmentAnchors(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
excludeId: string,
|
||||
levelId?: string | null,
|
||||
): AlignmentAnchor[] {
|
||||
const anchors: AlignmentAnchor[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.id === excludeId) continue
|
||||
anchors.push(...nodeAlignmentAnchors(node))
|
||||
if (levelId) {
|
||||
const nodeLevelId = resolveNodeLevelId(node, nodes)
|
||||
if (nodeLevelId !== null && nodeLevelId !== levelId) continue
|
||||
}
|
||||
anchors.push(...nodeAlignmentAnchors(node, nodes))
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
|
||||
|
||||
/**
|
||||
* Stair footprint geometry shared by the slab-opening sync and the
|
||||
* alignment-anchor adapters. A stair has no single box footprint: straight
|
||||
* stairs are a cumulative chain of `stair-segment` children, curved / spiral
|
||||
* stairs are an annular sector stored entirely on the parent. Both reduce to
|
||||
* an XZ bounding box here so callers that only need "where does the stair sit
|
||||
* in plan" (alignment guides) don't have to re-walk the geometry.
|
||||
*
|
||||
* All math is in the building-local XZ frame, matching `node.position`.
|
||||
*/
|
||||
|
||||
export type StairFootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ rotation in the stair geometry convention (equivalent to rotating by
|
||||
* `-angle` in standard math): positive `angle` turns local +Z toward +X. Every
|
||||
* stair helper — slab openings, floor-plan emitter, this footprint — shares it,
|
||||
* so anchors line up with the rendered stair.
|
||||
*/
|
||||
export function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative per-segment transforms for a straight (segment-chained) stair.
|
||||
* Each flight attaches to the previous segment's end; `attachmentSide` rotates
|
||||
* the chain ±90° (left / right) or continues straight (front). Positions are in
|
||||
* the stair's local frame (before the stair's own `position` / `rotation`).
|
||||
*/
|
||||
export function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!segment) continue
|
||||
|
||||
if (index === 0) {
|
||||
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = segments[index - 1]
|
||||
if (!previous) continue
|
||||
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += deltaX
|
||||
currentY += previous.height
|
||||
currentZ += deltaZ
|
||||
currentRot += rotationDelta
|
||||
|
||||
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
function emptyBox(): StairFootprintAABB {
|
||||
return {
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minZ: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxZ: Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/** Grow `box` to include the world-plan point produced by rotating the
|
||||
* stair-local point by the stair's rotation and offsetting by its position. */
|
||||
function extendByLocal(box: StairFootprintAABB, stair: StairNode, localX: number, localZ: number) {
|
||||
const [wx, wz] = rotateXZ(localX, localZ, stair.rotation ?? 0)
|
||||
const x = stair.position[0] + wx
|
||||
const z = stair.position[2] + wz
|
||||
if (x < box.minX) box.minX = x
|
||||
if (x > box.maxX) box.maxX = x
|
||||
if (z < box.minZ) box.minZ = z
|
||||
if (z > box.maxZ) box.maxZ = z
|
||||
}
|
||||
|
||||
function finiteBox(box: StairFootprintAABB): StairFootprintAABB | null {
|
||||
return Number.isFinite(box.minX) && Number.isFinite(box.minZ) ? box : null
|
||||
}
|
||||
|
||||
/** Bounding box of a straight stair's segment chain, walking the children. */
|
||||
function straightStairAABB(
|
||||
stair: StairNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): StairFootprintAABB | null {
|
||||
const segments = (stair.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter(
|
||||
(segment): segment is StairSegmentNode =>
|
||||
segment?.type === 'stair-segment' && segment.visible !== false,
|
||||
)
|
||||
if (segments.length === 0) return null
|
||||
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
const box = emptyBox()
|
||||
segments.forEach((segment, index) => {
|
||||
const transform = transforms[index]
|
||||
if (!transform) return
|
||||
const halfWidth = segment.width / 2
|
||||
// Segment-local footprint: X across the flight, Z along the run from the
|
||||
// attachment edge (0) to the far edge (length).
|
||||
for (const [cornerX, cornerZ] of [
|
||||
[-halfWidth, 0],
|
||||
[halfWidth, 0],
|
||||
[halfWidth, segment.length],
|
||||
[-halfWidth, segment.length],
|
||||
] as const) {
|
||||
const [offsetX, offsetZ] = rotateXZ(cornerX, cornerZ, transform.rotation)
|
||||
extendByLocal(box, stair, transform.position[0] + offsetX, transform.position[2] + offsetZ)
|
||||
}
|
||||
})
|
||||
return finiteBox(box)
|
||||
}
|
||||
|
||||
const ARC_SAMPLES = 48
|
||||
|
||||
/** Bounding box of a curved / spiral stair's annular sector (plus the
|
||||
* integrated spiral top landing when present). */
|
||||
function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
|
||||
const isSpiral = stair.stairType === 'spiral'
|
||||
const minInnerRadius = isSpiral ? 0.05 : 0.2
|
||||
const innerRadius = Math.max(minInnerRadius, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9))
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const outerRadius = innerRadius + width
|
||||
|
||||
let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
// A full revolution would make the arc degenerate; clamp just under 2π the
|
||||
// same way the floor-plan emitter does so the sampled box stays correct.
|
||||
if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
|
||||
const half = sweep / 2
|
||||
|
||||
const box = emptyBox()
|
||||
// Sample both rims across the sweep — the extremes can fall on either the
|
||||
// arc ends or an axis crossing in between, so we need the full sweep.
|
||||
for (let step = 0; step <= ARC_SAMPLES; step += 1) {
|
||||
const angle = -half + (sweep * step) / ARC_SAMPLES
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
extendByLocal(box, stair, cos * innerRadius, sin * innerRadius)
|
||||
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
|
||||
}
|
||||
|
||||
// Integrated spiral top landing — a rectangle hung off the outer rim.
|
||||
if (isSpiral && stair.topLandingMode === 'integrated') {
|
||||
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
|
||||
const halfWidth = width / 2
|
||||
for (const [cornerX, cornerZ] of [
|
||||
[outerRadius, -halfWidth],
|
||||
[outerRadius + depth, -halfWidth],
|
||||
[outerRadius + depth, halfWidth],
|
||||
[outerRadius, halfWidth],
|
||||
] as const) {
|
||||
extendByLocal(box, stair, cornerX, cornerZ)
|
||||
}
|
||||
}
|
||||
|
||||
return finiteBox(box)
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ bounding box of a stair's plan footprint, or null when it can't be
|
||||
* determined (a straight stair whose segment children aren't in `nodes`).
|
||||
* Straight stairs need the children to walk the flight chain; curved / spiral
|
||||
* stairs are derived from the parent alone, so `nodes` is optional for them.
|
||||
*/
|
||||
export function stairFootprintAABB(
|
||||
stair: StairNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): StairFootprintAABB | null {
|
||||
if ((stair.stairType ?? 'straight') === 'straight') {
|
||||
return nodes ? straightStairAABB(stair, nodes) : null
|
||||
}
|
||||
return arcStairAABB(stair)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SurfaceHoleMetadata,
|
||||
} from '../../schema'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
|
||||
@@ -75,70 +76,8 @@ function normalizeExistingMetadata(
|
||||
}
|
||||
|
||||
// (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators)
|
||||
|
||||
function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
|
||||
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!segment) continue
|
||||
|
||||
if (index === 0) {
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = segments[index - 1]
|
||||
if (!previous) continue
|
||||
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += deltaX
|
||||
currentY += previous.height
|
||||
currentZ += deltaZ
|
||||
currentRot += rotationDelta
|
||||
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
// `rotateXZ` + `computeSegmentTransforms` are shared with the alignment-anchor
|
||||
// footprint via `./stair-footprint` so both derive the chain identically.
|
||||
|
||||
function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>) {
|
||||
if (!levelId) return undefined
|
||||
|
||||
Reference in New Issue
Block a user