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
+1
View File
@@ -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 {
+2
View File
@@ -48,6 +48,8 @@ export {
} from './subtree'
export type {
Affordance,
AlignmentFootprint,
AlignmentFootprintConfig,
AnyNodeDefinition,
AssetRef,
Capabilities,
+36
View File
@@ -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 = {
+2 -2
View File
@@ -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)
})
})
+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
}
@@ -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
@@ -1,9 +1,11 @@
'use client'
import { type AlignmentGuide, useAlignmentGuides } from '@pascal-app/core'
import { type AlignmentGuide, sceneRegistry, useAlignmentGuides } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { memo, useMemo } from 'react'
import { BoxGeometry, CircleGeometry } from 'three'
import { useFrame } from '@react-three/fiber'
import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, type Group } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
@@ -20,7 +22,9 @@ import { EDITOR_LAYER } from '../../lib/constants'
*
* Guide coordinates are XZ meters in the building-local frame; this layer is
* mounted inside ToolManager's building-local group so they render at the
* right world position (and line up with the cursor).
* right world position (and line up with the cursor). The whole ribbon is
* lifted to the active level's building-local Y each frame so it lies on the
* floor being edited — not the building base — when floors are stacked.
*/
const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
@@ -48,13 +52,28 @@ type Vec3 = [number, number, number]
export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const levelId = useViewer((s) => s.selection.levelId)
const groupRef = useRef<Group>(null)
// Guides carry only XZ (building-local plan coords); their Y has to track
// the active level so the ground ribbon lies on the floor being edited,
// not the building base. Read the level mesh's building-local Y each frame
// — the same source `grid.tsx` uses, so the ribbon stays locked to the
// grid plane (and lerps with it during a level switch).
useFrame(() => {
const group = groupRef.current
if (!group) return
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
group.position.y = levelMesh ? levelMesh.position.y : 0
})
if (guides.length === 0) return null
return (
<>
<group ref={groupRef}>
{guides.map((guide, i) => (
<GuideLine guide={guide} key={i} />
))}
</>
</group>
)
})
@@ -12,6 +12,8 @@ import {
type ElevatorNode,
emitter,
type FenceNode,
type FloorplanGeometry,
type GeometryContext,
type GridEvent,
type GuideNode,
getRenderableSlabPolygon,
@@ -28,7 +30,7 @@ import {
type RoofNode,
type RoofSegmentNode,
type SiteNode,
SlabNode,
type SlabNode,
type SpawnNode,
type StairNode,
StairNode as StairNodeSchema,
@@ -87,6 +89,7 @@ import {
} from '../editor-2d/floorplan-render-context'
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
@@ -528,6 +531,7 @@ type ReferenceFloorData = {
fenceEntries: FloorplanFenceEntry[]
itemEntries: FloorplanItemEntry[]
openingPolygons: OpeningPolygonEntry[]
registryEntries: ReferenceFloorRegistryEntry[]
slabPolygons: SlabPolygonEntry[]
wallPolygons: WallPolygonEntry[]
}
@@ -538,6 +542,33 @@ type ReferenceFloorColumnEntry = {
polygon: Point2D[]
}
type ReferenceFloorRegistryEntry = {
geometry: FloorplanGeometry
node: AnyNode
}
// Top-level structural kinds drawn on the *reference* (dimmed, below) floor
// via their `def.floorplan` builder, so the symbol is identical to the active
// floor. Walls / columns / slabs / fences / items / openings still have
// bespoke reference rendering above; these five are the registry-driven kinds
// that don't.
//
// This is a deliberate curation, NOT "every kind with a `def.floorplan`":
// ~24 kinds expose one, including children (`roof-segment`, `stair-segment`),
// containers (`level`, `building`), and surfaces (`ceiling`, `zone`) that
// either render through a parent's builder or shouldn't appear as standalone
// reference symbols — auto-deriving would double-draw or clutter the floor.
// The list also can't live on `NodeDefinition`: "reference floor" is an
// editor 2D-view concept that `packages/core` must stay unaware of (see
// wiki/architecture/layers.md). New top-level structural kinds opt in here.
const REFERENCE_REGISTRY_KINDS = new Set<AnyNode['type']>([
'stair',
'roof',
'shelf',
'spawn',
'elevator',
])
type FloorplanStairSegmentEntry = {
centerLine: FloorplanLineSegment | null
innerPoints: string
@@ -3440,6 +3471,10 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
vectorEffect="non-scaling-stroke"
/>
))}
{data.registryEntries.map(({ node, geometry }) => (
<FloorplanGeometryRenderer geometry={geometry} key={node.id} />
))}
</g>
)
})
@@ -4721,6 +4756,48 @@ export function FloorplanPanel() {
]
})
// Render reference-floor stairs and roofs through the SAME registry
// builders the active level uses (`def.floorplan` → `buildStairFloorplan`
// / `buildRoofFloorplan`), so the symbol is pixel-identical to a stair /
// roof on the current floor. These are the only registry-driven kinds
// the legacy reference layer doesn't already collect manually.
// `viewState` is omitted so each builder emits its unselected appearance
// (no selection chrome / resize handles). Both the reference layer and
// the registry renderer draw in the same plan-meter space
// (`toSvgX`/`toSvgY` are identity), so the geometry composes directly.
const registryEntries = referenceDescendants.flatMap<ReferenceFloorRegistryEntry>((node) => {
if (!REFERENCE_REGISTRY_KINDS.has(node.type)) {
return []
}
const builder = nodeRegistry.get(node.type)?.floorplan
if (!builder) {
return []
}
// Each builder walks its own segment children (stair-segment /
// roof-segment) and filters by type. Pass them in stored `children`
// order — stair-segment transforms are cumulative, so order matters.
const childIds = (node as { children?: AnyNodeId[] }).children ?? []
const children = childIds.flatMap((childId) => {
const child = referenceDescendantById.get(childId)
return child ? [child] : []
})
const ctx: GeometryContext = {
resolve: (rid) => referenceDescendantById.get(rid) as never,
children,
siblings: [],
parent: referenceFloorLevel,
viewState: undefined,
}
const geometry = builder(node, ctx)
if (!geometry) {
return []
}
return [{ geometry, node }]
})
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
const itemEntries = referenceDescendants.flatMap((node) => {
if (
@@ -4759,6 +4836,7 @@ export function FloorplanPanel() {
fenceEntries,
itemEntries,
openingPolygons,
registryEntries,
slabPolygons,
wallPolygons,
}
@@ -6359,26 +6437,6 @@ export function FloorplanPanel() {
}
}, [clearDraft])
const createSlabOnCurrentLevel = useCallback(
(points: WallPlanPoint[]) => {
if (!levelId) {
return null
}
const { createNode, nodes } = useScene.getState()
const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length
const slab = SlabNode.parse({
name: `Slab ${slabCount + 1}`,
polygon: points.map(([x, z]) => [x, z] as [number, number]),
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
setSelection({ selectedIds: [slab.id] })
return slab.id
},
[levelId, setSelection],
)
const createZoneOnCurrentLevel = useCallback(
(points: WallPlanPoint[]) => {
if (!levelId) {
@@ -7676,6 +7734,11 @@ export function FloorplanPanel() {
],
)
// Slab creation is owned by the registry-driven slab tool (parity with
// ceiling): the click/double-click that closes the polygon is forwarded as
// a grid event, and the 3D tool commits the node. These 2D handlers only
// maintain the draft-preview state and clear it on close — they must NOT
// create a node themselves, or every slab would be built twice.
const handleSlabPlacementPoint = useCallback(
(point: WallPlanPoint) => {
const lastPoint = slabDraftPoints[slabDraftPoints.length - 1]
@@ -7685,7 +7748,6 @@ export function FloorplanPanel() {
const firstPoint = slabDraftPoints[0]
if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
createSlabOnCurrentLevel(slabDraftPoints)
clearDraft()
return
}
@@ -7693,7 +7755,7 @@ export function FloorplanPanel() {
setSlabDraftPoints((currentPoints) => [...currentPoints, point])
setCursorPoint(point)
},
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
[clearDraft, slabDraftPoints],
)
const handleSlabPlacementConfirm = useCallback(
(point?: WallPlanPoint) => {
@@ -7716,10 +7778,9 @@ export function FloorplanPanel() {
return
}
createSlabOnCurrentLevel(nextPoints)
clearDraft()
},
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
[clearDraft, slabDraftPoints],
)
const handleCeilingPlacementPoint = useCallback(
(point: WallPlanPoint) => {
@@ -8063,6 +8124,9 @@ export function FloorplanPanel() {
if (isZoneBuildActive) {
handleZonePlacementConfirm(snappedPoint)
} else {
// Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', planPoint, event)
handleSlabPlacementConfirm(snappedPoint)
}
},
@@ -6,6 +6,7 @@ import {
type ArcResizeHandle,
type Cursor,
createSceneApi,
emitter,
type HandleDescriptor,
type HandlePortal,
type LinearResizeHandle,
@@ -433,9 +434,25 @@ function NodeArrowHandlesForNode({
// shader, and it's the reason the wall height arrow (which also stays on
// SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
// them on EDITOR_LAYER hides them from scenePass and the chevron renders
// flat. Arrows are only mounted while a node is selected, so thumbnail
// captures (which never have selection) don't need the layer-based
// exclusion the wall arrow also goes without.
// flat. Because they live on SCENE_LAYER, the thumbnail camera (which only
// filters EDITOR_LAYER + GRID_LAYER) would otherwise capture them when a
// node is selected at capture time — so the rig hides itself during a
// snapshot via the same before/after-capture handshake SelectionManager
// uses, then reappears.
useEffect(() => {
const hide = () => {
if (outerRef.current) outerRef.current.visible = false
}
const show = () => {
if (outerRef.current) outerRef.current.visible = true
}
emitter.on('thumbnail:before-capture', hide)
emitter.on('thumbnail:after-capture', show)
return () => {
emitter.off('thumbnail:before-capture', hide)
emitter.off('thumbnail:after-capture', show)
}
}, [])
useFrame(() => {
if (outerRef.current && outerRide) {
@@ -1,5 +1,5 @@
export const DEFAULT_ELEVATOR_WIDTH = 1.6
export const DEFAULT_ELEVATOR_DEPTH = 1.6
export const DEFAULT_ELEVATOR_WIDTH = 1.84
export const DEFAULT_ELEVATOR_DEPTH = 1.84
export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35
export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95
export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1
@@ -157,7 +157,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The elevator aligns by its ORIGIN point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
// Snap the elevator origin onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
@@ -252,7 +252,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
rotationRef.current,
onPlaced,
)
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
useAlignmentGuides.getState().clear()
// The placed elevator's footprint now renders for real; drop the ghost
// (the next grid:move re-publishes it for the following placement).
@@ -632,7 +632,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
let alignZ = 0
const bypassAlign = event.nativeEvent?.altKey === true
if (!bypassAlign && draft) {
alignmentCandidates ??= collectAlignmentAnchors(useScene.getState().nodes, draft.id)
alignmentCandidates ??= collectAlignmentAnchors(
useScene.getState().nodes,
draft.id,
useViewer.getState().selection.levelId,
)
const ar = resolveAlignment({
moving: movingFootprintAnchors(
draft as unknown as AnyNode,
@@ -238,11 +238,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
// Static alignment candidates — anchors of every OTHER alignable object
// (items, walls, fences, slabs, ceilings, columns), gathered once at drag
// start (the scene graph is stable during an imperative move). Coords are
// building-local, the same frame as `event.localPosition` and the
// rendered cursor, so the guide dots line up with the cursor.
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
// (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
// gathered once at drag start (the scene graph is stable during an
// imperative move). Level-scoped so a node directly below on another
// floor doesn't snap (alignment is XZ-only). Coords are building-local,
// the same frame as `event.localPosition` and the rendered cursor, so
// the guide dots line up with the cursor.
const alignmentCandidates = collectAlignmentAnchors(
useScene.getState().nodes,
node.id,
useViewer.getState().selection.levelId ?? node.parentId,
)
const onGridMove = (event: GridEvent) => {
let x = snapToGridStep(event.localPosition[0])
@@ -173,7 +173,7 @@ export const RoofTool: React.FC = () => {
// Alignment candidates — anchors of every alignable object; refreshed
// after each roof commits. Both corners of the rectangle align.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
// Snap the drafted corner onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
@@ -291,7 +291,7 @@ export const RoofTool: React.FC = () => {
corner1Ref.current = null
outlineRef.current.visible = false
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear()
} else {
corner1Ref.current = [gridX, y, gridZ]
@@ -154,7 +154,7 @@ export const StairTool: React.FC = () => {
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The stair aligns by its ORIGIN point.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
// Snap the stair origin onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
@@ -230,7 +230,7 @@ export const StairTool: React.FC = () => {
event.nativeEvent?.altKey === true,
)
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
useAlignmentGuides.getState().clear()
}
+15
View File
@@ -166,6 +166,21 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
// 2D body-move flow through `FloorplanRegistryMoveOverlay`'s
// Path 2 — position[0] / position[2] update with a 0.5m grid snap.
movable: { axes: ['x', 'z'], gridSnap: true },
// Align by the OUTER SHAFT (shaft + wall — what's drawn in plan and 3D),
// not the cab `width × depth`: the cab is inset by the shaft wall +
// clearance, so cab corners sit ~9 cm inside the visible edge — past the
// 8 cm snap, which is why the elevator never surfaced a guide. A `box`
// shape (not `aabb`) because the elevator is `movable`, so the anchor
// bridge relocates this same footprint to the drag point.
alignmentFootprint: (node) => {
const e = node as ElevatorNodeType
const wall = getElevatorShaftWallThickness(e)
return {
shape: 'box',
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2],
rotation: [0, e.rotation ?? 0, 0],
}
},
duplicable: true,
deletable: true,
},
+10
View File
@@ -3,6 +3,7 @@ import {
type NodeDefinition,
StairNode as StairNodeSchema,
type StairNode as StairNodeType,
stairFootprintAABB,
} from '@pascal-app/core'
const MIN_CURVED_RISE = 0.3
@@ -318,6 +319,15 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
capabilities: {
selectable: { hitVolume: 'bbox' },
// A stair has no centred box footprint: straight = a cumulative
// `stair-segment` chain, curved / spiral = an annular sector. Hand the
// alignment bridge the resolved plan `aabb` directly (not a `box`) — the
// stair moves by its origin via `affordanceTools.move`, so it only ever
// contributes static candidate anchors, never the relocatable box path.
alignmentFootprint: (node, nodes) => {
const aabb = stairFootprintAABB(node as StairNodeType, nodes)
return aabb ? { shape: 'aabb', ...aabb } : null
},
duplicable: true,
deletable: true,
},