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
@@ -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