diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts
index c4b7818fb..9edff1c7c 100644
--- a/apps/ifc-converter/next-env.d.ts
+++ b/apps/ifc-converter/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c0b8abb0e..344eada88 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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 {
diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts
index 896208661..baf7c7494 100644
--- a/packages/core/src/registry/index.ts
+++ b/packages/core/src/registry/index.ts
@@ -48,6 +48,8 @@ export {
} from './subtree'
export type {
Affordance,
+ AlignmentFootprint,
+ AlignmentFootprintConfig,
AnyNodeDefinition,
AssetRef,
Capabilities,
diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index cc8a792ee..4f0bb611b 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -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>,
+) => AlignmentFootprint | null
+
// ─── Relations ───────────────────────────────────────────────────────
export type Relations = {
diff --git a/packages/core/src/schema/nodes/elevator.ts b/packages/core/src/schema/nodes/elevator.ts
index 37d275729..75639387e 100644
--- a/packages/core/src/schema/nodes/elevator.ts
+++ b/packages/core/src/schema/nodes/elevator.ts
@@ -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),
diff --git a/packages/core/src/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts
index d44b15f25..ae796b3b3 100644
--- a/packages/core/src/services/alignment-anchors.test.ts
+++ b/packages/core/src/services/alignment-anchors.test.ts
@@ -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>) => {
+ 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)
+ })
})
diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts
index 3fac4eed3..03fd3dce5 100644
--- a/packages/core/src/services/alignment-anchors.ts
+++ b/packages/core/src/services/alignment-anchors.ts
@@ -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>,
+): 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>,
+): 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>,
+): 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>,
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
}
diff --git a/packages/core/src/systems/stair/stair-footprint.ts b/packages/core/src/systems/stair/stair-footprint.ts
new file mode 100644
index 000000000..9a6e88ad4
--- /dev/null
+++ b/packages/core/src/systems/stair/stair-footprint.ts
@@ -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>,
+): 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>,
+): StairFootprintAABB | null {
+ if ((stair.stairType ?? 'straight') === 'straight') {
+ return nodes ? straightStairAABB(stair, nodes) : null
+ }
+ return arcStairAABB(stair)
+}
diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts
index 5c9b296f2..bced29db0 100644
--- a/packages/core/src/systems/stair/stair-opening-sync.ts
+++ b/packages/core/src/systems/stair/stair-opening-sync.ts
@@ -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) {
if (!levelId) return undefined
diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
index 13ec6cffd..7d48bc1b1 100644
--- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
+++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx
@@ -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(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 (
- <>
+
{guides.map((guide, i) => (
))}
- >
+
)
})
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 48b0a80b7..f2da8a022 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -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([
+ '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 }) => (
+
+ ))}
)
})
@@ -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((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()
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)
}
},
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx
index 8d2a9bc36..b18f61759 100644
--- a/packages/editor/src/components/editor/node-arrow-handles.tsx
+++ b/packages/editor/src/components/editor/node-arrow-handles.tsx
@@ -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) {
diff --git a/packages/editor/src/components/tools/elevator/elevator-defaults.ts b/packages/editor/src/components/tools/elevator/elevator-defaults.ts
index d7cfa0e0b..8d85864bf 100644
--- a/packages/editor/src/components/tools/elevator/elevator-defaults.ts
+++ b/packages/editor/src/components/tools/elevator/elevator-defaults.ts
@@ -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
diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx
index 450118016..b535dc318 100644
--- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx
+++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx
@@ -157,7 +157,7 @@ export const ElevatorTool: React.FC = ({ 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 = ({ 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).
diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
index 4fae6ae1a..ae00f981e 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -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,
diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
index 027756803..84a3e5a36 100644
--- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
+++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx
@@ -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])
diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx
index 277f56e2a..2290a7f58 100644
--- a/packages/editor/src/components/tools/roof/roof-tool.tsx
+++ b/packages/editor/src/components/tools/roof/roof-tool.tsx
@@ -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]
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx
index 12b7b404b..15d8d971e 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -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()
}
diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts
index a840d0164..16622e954 100644
--- a/packages/nodes/src/elevator/definition.ts
+++ b/packages/nodes/src/elevator/definition.ts
@@ -166,6 +166,21 @@ export const elevatorDefinition: NodeDefinition = {
// 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,
},
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index d5ff25a8f..7fc032d78 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -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 = {
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,
},