From 92787948bb4ed8c5562343e0038a3342cc3f9456 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 5 Jun 2026 00:36:27 +0530 Subject: [PATCH 1/3] 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) --- apps/ifc-converter/next-env.d.ts | 2 +- packages/core/src/schema/nodes/elevator.ts | 4 +- .../src/services/alignment-anchors.test.ts | 115 +++++++++- .../core/src/services/alignment-anchors.ts | 83 ++++++- .../core/src/systems/stair/stair-footprint.ts | 209 ++++++++++++++++++ .../src/systems/stair/stair-opening-sync.ts | 67 +----- .../editor/alignment-3d-guide-layer.tsx | 31 ++- .../src/components/editor/floorplan-panel.tsx | 104 ++++++--- .../tools/elevator/elevator-defaults.ts | 4 +- .../tools/elevator/elevator-tool.tsx | 4 +- .../tools/item/use-placement-coordinator.tsx | 6 +- .../registry/move-registry-node-tool.tsx | 16 +- .../src/components/tools/roof/roof-tool.tsx | 4 +- .../src/components/tools/stair/stair-tool.tsx | 4 +- 14 files changed, 531 insertions(+), 122 deletions(-) create mode 100644 packages/core/src/systems/stair/stair-footprint.ts 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/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..bb6c666e2 100644 --- a/packages/core/src/services/alignment-anchors.test.ts +++ b/packages/core/src/services/alignment-anchors.test.ts @@ -79,11 +79,14 @@ 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. 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 +217,112 @@ 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')) + 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', () => { + 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', () => { + 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', () => { + 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..937e36065 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -13,7 +13,14 @@ */ import { nodeRegistry } from '../registry' +import type { ElevatorNode, StairNode } from '../schema' import type { AnyNode } from '../schema/types' +import { + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, +} from '../systems/elevator/elevator-geometry' +import { stairFootprintAABB } from '../systems/stair/stair-footprint' import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' @@ -71,11 +78,23 @@ function floorFootprint( 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). + // does rest on the floor — give it a footprint so it aligns like other + // boxes (the registry move tool reads this). Use the OUTER SHAFT footprint + // (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 threshold, which is why the + // elevator never surfaced a guide. 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 elevator = node as ElevatorNode + const wall = getElevatorShaftWallThickness(elevator) + return { + dimensions: [ + getElevatorShaftWidth(elevator) + wall * 2, + 1, + getElevatorShaftDepth(elevator) + wall * 2, + ], + rotation: [0, elevator.rotation ?? 0, 0], + } } return null } @@ -179,10 +198,17 @@ 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 - * usable footprint contribute nothing. + * endpoints + midpoint; slabs / ceilings → polygon vertices; stairs → their + * (chain / sector) footprint corners. Kinds without a usable footprint + * contribute nothing. + * + * `nodes` is needed only to resolve a straight stair's `stair-segment` + * children; 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 +224,65 @@ export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] { const poly = (node as { polygon?: [number, number][] }).polygon return poly ? polygonAnchors(node.id, poly) : [] } + // A stair has no box footprint (straight = a segment chain, curved / spiral + // = an annular sector), so it bypasses `floorFootprint` and reduces to its + // plan bounding box here. Without this, elevators / spiral / curved stairs + // never contributed a guide. + if (node.type === 'stair') { + const aabb = stairFootprintAABB(node as StairNode, nodes) + return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] + } const aabb = footprintAABB(node) 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..ac3e4aaf3 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,23 @@ type ReferenceFloorColumnEntry = { polygon: Point2D[] } +type ReferenceFloorRegistryEntry = { + geometry: FloorplanGeometry + node: AnyNode +} + +// Registry-driven kinds the legacy reference-floor layer doesn't collect +// manually — rendered via their `def.floorplan` builder so they look +// identical to the active floor. Everything else (walls, columns, slabs, +// …) still has bespoke reference rendering above. +const REFERENCE_REGISTRY_KINDS = new Set([ + 'stair', + 'roof', + 'shelf', + 'spawn', + 'elevator', +]) + type FloorplanStairSegmentEntry = { centerLine: FloorplanLineSegment | null innerPoints: string @@ -3440,6 +3461,10 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer( vectorEffect="non-scaling-stroke" /> ))} + + {data.registryEntries.map(({ node, geometry }) => ( + + ))} ) }) @@ -4721,6 +4746,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 +4826,7 @@ export function FloorplanPanel() { fenceEntries, itemEntries, openingPolygons, + registryEntries, slabPolygons, wallPolygons, } @@ -6359,26 +6427,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 +7724,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 +7738,6 @@ export function FloorplanPanel() { const firstPoint = slabDraftPoints[0] if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) { - createSlabOnCurrentLevel(slabDraftPoints) clearDraft() return } @@ -7693,7 +7745,7 @@ export function FloorplanPanel() { setSlabDraftPoints((currentPoints) => [...currentPoints, point]) setCursorPoint(point) }, - [clearDraft, createSlabOnCurrentLevel, slabDraftPoints], + [clearDraft, slabDraftPoints], ) const handleSlabPlacementConfirm = useCallback( (point?: WallPlanPoint) => { @@ -7716,10 +7768,9 @@ export function FloorplanPanel() { return } - createSlabOnCurrentLevel(nextPoints) clearDraft() }, - [clearDraft, createSlabOnCurrentLevel, slabDraftPoints], + [clearDraft, slabDraftPoints], ) const handleCeilingPlacementPoint = useCallback( (point: WallPlanPoint) => { @@ -8063,6 +8114,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/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() } From cc851400e7ed19ec56da367aa0bffe18a04230f9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 5 Jun 2026 00:58:30 +0530 Subject: [PATCH 2/3] refactor(core): registry-drive elevator/stair alignment footprints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/index.ts | 1 + packages/core/src/registry/index.ts | 2 + packages/core/src/registry/types.ts | 36 ++++++++ .../src/services/alignment-anchors.test.ts | 55 ++++++++++++ .../core/src/services/alignment-anchors.ts | 87 ++++++++++--------- .../src/components/editor/floorplan-panel.tsx | 18 +++- packages/nodes/src/elevator/definition.ts | 15 ++++ packages/nodes/src/stair/definition.ts | 10 +++ 8 files changed, 179 insertions(+), 45 deletions(-) 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/services/alignment-anchors.test.ts b/packages/core/src/services/alignment-anchors.test.ts index bb6c666e2..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, @@ -83,6 +132,8 @@ describe('footprintAABB', () => { // 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 }), ) @@ -220,6 +271,7 @@ describe('collectAlignmentAnchors', () => { 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' }), @@ -248,6 +300,7 @@ describe('collectAlignmentAnchors', () => { }) test('straight stair contributes its segment-chain footprint corners', () => { + registerNode(stairDef()) const nodes = { st: node({ id: 'st', @@ -277,6 +330,7 @@ describe('collectAlignmentAnchors', () => { }) test('curved stair contributes its sector bounding-box corners', () => { + registerNode(stairDef()) const nodes = { cs: node({ id: 'cs', @@ -303,6 +357,7 @@ describe('collectAlignmentAnchors', () => { }) test('spiral stair contributes a full-circle bounding box', () => { + registerNode(stairDef()) const nodes = { sp: node({ id: 'sp', diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 937e36065..03fd3dce5 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -13,14 +13,7 @@ */ import { nodeRegistry } from '../registry' -import type { ElevatorNode, StairNode } from '../schema' import type { AnyNode } from '../schema/types' -import { - getElevatorShaftDepth, - getElevatorShaftWallThickness, - getElevatorShaftWidth, -} from '../systems/elevator/elevator-geometry' -import { stairFootprintAABB } from '../systems/stair/stair-footprint' import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { type AlignmentAnchor, bboxCornerAnchors } from './alignment' @@ -66,34 +59,52 @@ 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 — give it a footprint so it aligns like other - // boxes (the registry move tool reads this). Use the OUTER SHAFT footprint - // (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 threshold, which is why the - // elevator never surfaced a guide. - if (node.type === 'elevator') { - const elevator = node as ElevatorNode - const wall = getElevatorShaftWallThickness(elevator) + 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 { - dimensions: [ - getElevatorShaftWidth(elevator) + wall * 2, - 1, - getElevatorShaftDepth(elevator) + wall * 2, - ], - rotation: [0, elevator.rotation ?? 0, 0], + minX: alignment.minX, + minZ: alignment.minZ, + maxX: alignment.maxX, + maxZ: alignment.maxZ, } } return null @@ -197,13 +208,15 @@ 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; stairs → their - * (chain / sector) footprint corners. Kinds without a usable footprint - * contribute nothing. + * 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 to resolve a straight stair's `stair-segment` - * children; every other kind derives its anchors from `node` alone. + * `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, @@ -224,15 +237,7 @@ export function nodeAlignmentAnchors( const poly = (node as { polygon?: [number, number][] }).polygon return poly ? polygonAnchors(node.id, poly) : [] } - // A stair has no box footprint (straight = a segment chain, curved / spiral - // = an annular sector), so it bypasses `floorFootprint` and reduces to its - // plan bounding box here. Without this, elevators / spiral / curved stairs - // never contributed a guide. - if (node.type === 'stair') { - const aabb = stairFootprintAABB(node as StairNode, nodes) - return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] - } - const aabb = footprintAABB(node) + const aabb = alignmentAABB(node, nodes) return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index ac3e4aaf3..f2da8a022 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -547,10 +547,20 @@ type ReferenceFloorRegistryEntry = { node: AnyNode } -// Registry-driven kinds the legacy reference-floor layer doesn't collect -// manually — rendered via their `def.floorplan` builder so they look -// identical to the active floor. Everything else (walls, columns, slabs, -// …) still has bespoke reference rendering above. +// 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', 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, }, From b9cf47b2f5a75ffa178fc5db218ffe13e3dca54e Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 5 Jun 2026 01:00:55 +0530 Subject: [PATCH 3/3] fix(editor): hide node arrow handles during thumbnail capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/editor/node-arrow-handles.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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) {