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:
Sudhir Yadav
2026-06-04 16:04:14 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 46f94b97b3
commit d1b40aa98d
20 changed files with 691 additions and 131 deletions
@@ -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)
})
})
+86 -14
View File
@@ -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
}