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