editor: level-scoped alignment, reference-floor symbols & registry slab tool (#373)
* feat(editor): level-scoped alignment, reference-floor registry symbols, registry slab tool - Scope alignment candidates to the active level so a node directly below on another floor no longer snaps (alignment is XZ-only); building-scoped nodes like elevator shafts stay in the pool across floors. - Derive the elevator alignment footprint from its outer shaft (not the inset cab), so its guide actually surfaces within the snap threshold. - Extract shared stair footprint geometry (rotateXZ, segment transforms, stairFootprintAABB) so opening-sync and alignment anchors derive the chain identically; stairs now contribute plan bbox anchors. - Render reference-floor stairs/roofs/elevators/shelves/spawns through their registry floorplan builders for pixel-identical symbols. - Move slab creation to the registry-driven slab tool (parity with ceiling); 2D handlers only maintain draft state. - Lift the 3D alignment guide ribbon to the active level's Y each frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): registry-drive elevator/stair alignment footprints Address architecture review of the level-scoped alignment work: the core anchor bridge (`alignment-anchors.ts`) hardcoded `if (node.type === 'elevator')` and `if (node.type === 'stair')` branches to derive their plan footprints. Move that onto the kinds themselves via a new `alignmentFootprint` capability so the bridge dispatches generically — matching the registry composition model. - Add `Capabilities.alignmentFootprint`: returns a `box` (rotatable rect centred on position, relocatable for movable kinds) or an `aabb` (already resolved, for non-rectangular plan shapes). Elevator uses `box` (its outer shaft, and it's movable); stair uses `aabb` (segment chain / annular sector, moves by origin). - Drop both hardcoded branches; the bridge now consults the capability via `floorFootprint` (box) and a unified `alignmentAABB` (box ∪ aabb). - Export `stairFootprintAABB` from core so the stair definition consumes it. - Tests register synthetic defs carrying the capability (the bridge no longer knows elevator/stair by name), reproducing the production glue from the same core helpers. - Document why `REFERENCE_REGISTRY_KINDS` is a deliberate editor-local curation, not an auto-derived set (most floorplan-builder kinds shouldn't appear as standalone reference symbols, and "reference floor" is an editor concept core must not know). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): hide node arrow handles during thumbnail capture The arrow-handle rig lives on SCENE_LAYER (so its chevrons read as proper 3D plates), but the thumbnail camera only filters EDITOR_LAYER + GRID_LAYER — so a node selected at capture time would leak its arrows into the snapshot. Hide the rig on `thumbnail:before-capture` and restore it on `thumbnail:after-capture`, the same emitter handshake SelectionManager uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
46f94b97b3
commit
d1b40aa98d
@@ -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 {
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
} from './subtree'
|
||||
export type {
|
||||
Affordance,
|
||||
AlignmentFootprint,
|
||||
AlignmentFootprintConfig,
|
||||
AnyNodeDefinition,
|
||||
AssetRef,
|
||||
Capabilities,
|
||||
|
||||
@@ -971,6 +971,14 @@ export type Capabilities = {
|
||||
selectable?: SelectableConfig
|
||||
interactive?: boolean
|
||||
floorPlaced?: FloorPlacedConfig
|
||||
/**
|
||||
* Plan footprint this kind exposes to the alignment-anchor pool when it
|
||||
* isn't `floorPlaced` and isn't a structural primitive the bridge handles
|
||||
* directly (wall, slab). Lets a kind self-describe where it sits in plan
|
||||
* instead of the core anchor bridge hardcoding it per type. See
|
||||
* `AlignmentFootprintConfig`.
|
||||
*/
|
||||
alignmentFootprint?: AlignmentFootprintConfig
|
||||
roofAccessory?: RoofAccessoryConfig
|
||||
paint?: PaintCapability
|
||||
/**
|
||||
@@ -1244,6 +1252,34 @@ export type FloorPlacedConfig = {
|
||||
applies?: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan footprint a kind contributes to the alignment-anchor pool when it is
|
||||
* neither `floorPlaced` (columns / items, whose footprint the bridge already
|
||||
* reads) nor a primitive the bridge knows structurally (walls → segments,
|
||||
* slabs → polygons). Two shapes:
|
||||
*
|
||||
* - `box` — a rotatable rectangle centred on the node's `position`. Use
|
||||
* when the kind also moves by its footprint edges (elevator): the anchor
|
||||
* bridge relocates the box to the proposed drag point, so one descriptor
|
||||
* serves both the static candidate and the moving node.
|
||||
* - `aabb` — an already-resolved XZ bounding box, for kinds whose plan
|
||||
* shape isn't a centred rectangle (stair: a segment chain or annular
|
||||
* sector). Static candidates only — these kinds move by their origin, so
|
||||
* the box's relocation path never needs them.
|
||||
*
|
||||
* `nodes` is supplied only when a kind needs siblings / children to resolve
|
||||
* its footprint (a straight stair walks its `stair-segment` children); box
|
||||
* kinds derive everything from `node` alone.
|
||||
*/
|
||||
export type AlignmentFootprint =
|
||||
| { shape: 'box'; dimensions: [number, number, number]; rotation: [number, number, number] }
|
||||
| { shape: 'aabb'; minX: number; minZ: number; maxX: number; maxZ: number }
|
||||
|
||||
export type AlignmentFootprintConfig = (
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
) => AlignmentFootprint | null
|
||||
|
||||
// ─── Relations ───────────────────────────────────────────────────────
|
||||
|
||||
export type Relations = {
|
||||
|
||||
@@ -19,8 +19,8 @@ export const ElevatorNode = BaseNode.extend({
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around the Y axis in radians.
|
||||
rotation: z.number().default(0),
|
||||
width: z.number().default(1.6),
|
||||
depth: z.number().default(1.6),
|
||||
width: z.number().default(1.84),
|
||||
depth: z.number().default(1.84),
|
||||
shaftWidth: z.number().optional(),
|
||||
shaftDepth: z.number().optional(),
|
||||
shaftWallThickness: z.number().default(0.09),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { z } from 'zod'
|
||||
import { nodeRegistry, registerNode } from '../registry'
|
||||
import type { AnyNodeDefinition } from '../registry/types'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import {
|
||||
getElevatorShaftDepth,
|
||||
getElevatorShaftWallThickness,
|
||||
getElevatorShaftWidth,
|
||||
} from '../systems/elevator/elevator-geometry'
|
||||
import { stairFootprintAABB } from '../systems/stair/stair-footprint'
|
||||
import {
|
||||
collectAlignmentAnchors,
|
||||
footprintAABB,
|
||||
@@ -34,6 +40,49 @@ function floorPlacedDef(kind: string, applies?: (n: AnyNode) => boolean): AnyNod
|
||||
} as AnyNodeDefinition
|
||||
}
|
||||
|
||||
// Mirrors the real elevator/stair definitions, which expose their plan
|
||||
// footprint via the `alignmentFootprint` capability rather than a hardcoded
|
||||
// branch in the anchor bridge. The glue (shaft-outset box / stair AABB) is
|
||||
// reproduced here from the same core helpers production uses.
|
||||
function elevatorDef(): AnyNodeDefinition {
|
||||
return {
|
||||
kind: 'elevator',
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal('elevator') }) as any,
|
||||
category: 'structure',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {
|
||||
alignmentFootprint: (n: AnyNode) => {
|
||||
const e = n as any
|
||||
const wall = getElevatorShaftWallThickness(e)
|
||||
return {
|
||||
shape: 'box',
|
||||
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2],
|
||||
rotation: [0, e.rotation ?? 0, 0],
|
||||
}
|
||||
},
|
||||
},
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
} as AnyNodeDefinition
|
||||
}
|
||||
|
||||
function stairDef(): AnyNodeDefinition {
|
||||
return {
|
||||
kind: 'stair',
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal('stair') }) as any,
|
||||
category: 'structure',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities: {
|
||||
alignmentFootprint: (n: AnyNode, nodes?: Readonly<Record<string, AnyNode>>) => {
|
||||
const aabb = stairFootprintAABB(n as any, nodes)
|
||||
return aabb ? { shape: 'aabb', ...aabb } : null
|
||||
},
|
||||
},
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
} as AnyNodeDefinition
|
||||
}
|
||||
|
||||
function plainDef(kind: string): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
@@ -79,11 +128,16 @@ describe('footprintAABB', () => {
|
||||
expect(footprintAABB(node({ id: 'w1', type: 'wall', position: [0, 0, 0] }))).toBeNull()
|
||||
})
|
||||
|
||||
test('derives an elevator footprint from its width / depth (no floorPlaced needed)', () => {
|
||||
test('derives an elevator footprint from its OUTER SHAFT, not the cab', () => {
|
||||
// Aligns to the visible shaft outline: cab 2×4 + 0.09 m wall each side →
|
||||
// 2.18 × 4.18, centred at (10, 20). The cab corners alone would sit ~9 cm
|
||||
// inside the drawn edge (past the 8 cm snap), so a guide never appeared.
|
||||
// The footprint comes from the elevator's `alignmentFootprint` (box) cap.
|
||||
registerNode(elevatorDef())
|
||||
const aabb = footprintAABB(
|
||||
node({ id: 'e1', type: 'elevator', position: [10, 0, 20], width: 2, depth: 4, rotation: 0 }),
|
||||
)
|
||||
expect(aabb).toEqual({ minX: 9, minZ: 18, maxX: 11, maxZ: 22 })
|
||||
expect(aabb).toEqual({ minX: 8.91, minZ: 17.91, maxX: 11.09, maxZ: 22.09 })
|
||||
})
|
||||
|
||||
test('returns null when the kind predicate excludes the node', () => {
|
||||
@@ -214,4 +268,116 @@ describe('collectAlignmentAnchors', () => {
|
||||
expect(ids.filter((id) => id === 'wall')).toHaveLength(7) // endpoints + midpoint + 4 face corners
|
||||
expect(ids.filter((id) => id === 'slab')).toHaveLength(3) // polygon vertices
|
||||
})
|
||||
|
||||
test('levelId filter keeps only nodes resolving to that level (incl. nested)', () => {
|
||||
registerNode(floorPlacedDef('box'))
|
||||
registerNode(elevatorDef())
|
||||
const nodes = {
|
||||
b: node({ id: 'b', type: 'building' }),
|
||||
L1: node({ id: 'L1', type: 'level', parentId: 'b' }),
|
||||
L2: node({ id: 'L2', type: 'level', parentId: 'b' }),
|
||||
moving: node({ id: 'moving', type: 'box', parentId: 'L1', position: [0, 0, 0] }),
|
||||
sameFloor: node({ id: 'sameFloor', type: 'box', parentId: 'L1', position: [5, 0, 5] }),
|
||||
// Item resting on `sameFloor` — resolves to L1 through the parent chain.
|
||||
nested: node({ id: 'nested', type: 'box', parentId: 'sameFloor', position: [5, 0, 5] }),
|
||||
otherFloor: node({ id: 'otherFloor', type: 'box', parentId: 'L2', position: [5, 0, 5] }),
|
||||
// Building-scoped (parented to the building, no level ancestor) — spans
|
||||
// every floor, so it stays in the pool regardless of the active level.
|
||||
elevator: node({
|
||||
id: 'elevator',
|
||||
type: 'elevator',
|
||||
parentId: 'b',
|
||||
position: [9, 0, 9],
|
||||
width: 1.6,
|
||||
depth: 1.6,
|
||||
}),
|
||||
}
|
||||
const ids = collectAlignmentAnchors(nodes, 'moving', 'L1').map((a) => a.nodeId)
|
||||
expect(ids.filter((id) => id === 'sameFloor')).toHaveLength(4)
|
||||
expect(ids.filter((id) => id === 'nested')).toHaveLength(4)
|
||||
expect(ids.filter((id) => id === 'elevator')).toHaveLength(4)
|
||||
expect(ids).not.toContain('otherFloor')
|
||||
})
|
||||
|
||||
test('straight stair contributes its segment-chain footprint corners', () => {
|
||||
registerNode(stairDef())
|
||||
const nodes = {
|
||||
st: node({
|
||||
id: 'st',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
stairType: 'straight',
|
||||
width: 1,
|
||||
children: ['seg'],
|
||||
}),
|
||||
// Single 1×3 flight; origin at the run start, extending +Z by length.
|
||||
seg: node({
|
||||
id: 'seg',
|
||||
type: 'stair-segment',
|
||||
parentId: 'st',
|
||||
width: 1,
|
||||
length: 3,
|
||||
height: 2.5,
|
||||
attachmentSide: 'front',
|
||||
}),
|
||||
}
|
||||
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'st')
|
||||
expect(anchors).toHaveLength(4)
|
||||
expect(anchors.every((a) => a.kind === 'corner')).toBe(true)
|
||||
expect(new Set(anchors.map((a) => a.x))).toEqual(new Set([-0.5, 0.5]))
|
||||
expect(new Set(anchors.map((a) => a.z))).toEqual(new Set([0, 3]))
|
||||
})
|
||||
|
||||
test('curved stair contributes its sector bounding-box corners', () => {
|
||||
registerNode(stairDef())
|
||||
const nodes = {
|
||||
cs: node({
|
||||
id: 'cs',
|
||||
type: 'stair',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
stairType: 'curved',
|
||||
width: 1,
|
||||
innerRadius: 1,
|
||||
sweepAngle: Math.PI / 2,
|
||||
}),
|
||||
}
|
||||
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'cs')
|
||||
expect(anchors).toHaveLength(4)
|
||||
// outerRadius = inner(1) + width(1) = 2, sweep π/2 centred on +X. Outer rim
|
||||
// reaches X=2 at the bisector; min X is the inner rim's ±π/4 ends (cos45·1);
|
||||
// Z spans ±(outer·sin45).
|
||||
const xs = anchors.map((a) => a.x)
|
||||
const zs = anchors.map((a) => a.z)
|
||||
expect(Math.max(...xs)).toBeCloseTo(2, 5)
|
||||
expect(Math.min(...xs)).toBeCloseTo(Math.SQRT1_2, 5)
|
||||
expect(Math.max(...zs)).toBeCloseTo(Math.SQRT2, 5)
|
||||
expect(Math.min(...zs)).toBeCloseTo(-Math.SQRT2, 5)
|
||||
})
|
||||
|
||||
test('spiral stair contributes a full-circle bounding box', () => {
|
||||
registerNode(stairDef())
|
||||
const nodes = {
|
||||
sp: node({
|
||||
id: 'sp',
|
||||
type: 'stair',
|
||||
position: [5, 0, 5],
|
||||
rotation: 0,
|
||||
stairType: 'spiral',
|
||||
width: 1,
|
||||
innerRadius: 0.5,
|
||||
sweepAngle: Math.PI * 2,
|
||||
}),
|
||||
}
|
||||
const anchors = collectAlignmentAnchors(nodes, '').filter((a) => a.nodeId === 'sp')
|
||||
expect(anchors).toHaveLength(4)
|
||||
// outerRadius = inner(0.5) + width(1) = 1.5, a full revolution about (5, 5).
|
||||
const xs = anchors.map((a) => a.x)
|
||||
const zs = anchors.map((a) => a.z)
|
||||
expect(Math.max(...xs)).toBeCloseTo(6.5, 2)
|
||||
expect(Math.min(...xs)).toBeCloseTo(3.5, 2)
|
||||
expect(Math.max(...zs)).toBeCloseTo(6.5, 2)
|
||||
expect(Math.min(...zs)).toBeCloseTo(3.5, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,23 +59,53 @@ export function footprintAABBFrom(
|
||||
return { minX, minZ, maxX, maxZ }
|
||||
}
|
||||
|
||||
/** The floor-placed footprint config for a node, or null when it has none
|
||||
/** The relocatable box footprint for a node, or null when it has none
|
||||
* (walls / slabs / polygon kinds) or the kind's predicate excludes it
|
||||
* (e.g. a wall-attached item that doesn't rest on the floor). */
|
||||
* (e.g. a wall-attached item that doesn't rest on the floor).
|
||||
*
|
||||
* Box footprints come from one of two capabilities: `floorPlaced` (kinds
|
||||
* whose Y is also slab-lifted — columns, items) or `alignmentFootprint`
|
||||
* with a `box` shape (kinds that align by their footprint but aren't
|
||||
* floor-coupled — the elevator's outer shaft). A kind whose
|
||||
* `alignmentFootprint` is an `aabb` (stair) has no centred box, so it's
|
||||
* resolved directly in `nodeAlignmentAnchors`, not here. */
|
||||
function floorFootprint(
|
||||
node: AnyNode,
|
||||
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
const capabilities = nodeRegistry.get(node.type)?.capabilities
|
||||
const floorPlaced = capabilities?.floorPlaced
|
||||
if (floorPlaced) {
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) return null
|
||||
return floorPlaced.footprint(node)
|
||||
}
|
||||
// Elevator isn't a `floorPlaced` kind (no slab-elevation coupling) but it
|
||||
// does rest on the floor with a `width × depth` cab — give it a footprint
|
||||
// so it aligns like other boxes (the registry move tool reads this).
|
||||
if (node.type === 'elevator') {
|
||||
const e = node as { width?: number; depth?: number; rotation?: number }
|
||||
return { dimensions: [e.width ?? 1.6, 1, e.depth ?? 1.6], rotation: [0, e.rotation ?? 0, 0] }
|
||||
const alignment = capabilities?.alignmentFootprint?.(node)
|
||||
if (alignment?.shape === 'box') {
|
||||
return { dimensions: alignment.dimensions, rotation: alignment.rotation }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ bounding box a node occupies in plan, unifying the two non-structural
|
||||
* sources: a relocatable box (`floorFootprint`, covering floor-placed kinds
|
||||
* and the elevator's alignment box) and a kind that hands back an explicit
|
||||
* `aabb` because its plan shape isn't a centred rectangle (stair). Returns
|
||||
* null for kinds with neither.
|
||||
*/
|
||||
function alignmentAABB(
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): FootprintAABB | null {
|
||||
const box = footprintAABB(node)
|
||||
if (box) return box
|
||||
const alignment = nodeRegistry.get(node.type)?.capabilities?.alignmentFootprint?.(node, nodes)
|
||||
if (alignment?.shape === 'aabb') {
|
||||
return {
|
||||
minX: alignment.minX,
|
||||
minZ: alignment.minZ,
|
||||
maxX: alignment.maxX,
|
||||
maxZ: alignment.maxZ,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -178,11 +208,20 @@ export function polygonAnchors(
|
||||
|
||||
/**
|
||||
* Alignment anchors a node contributes to the candidate pool, dispatched by
|
||||
* kind: floor-placed footprints → corner anchors; walls / fences → segment
|
||||
* endpoints + midpoint; slabs / ceilings → polygon vertices. Kinds without a
|
||||
* kind: walls / fences → segment endpoints + midpoint; slabs / ceilings →
|
||||
* polygon vertices; everything else → the corners of its plan bounding box
|
||||
* (`alignmentAABB`, which covers floor-placed kinds, the elevator's
|
||||
* alignment box, and the stair's chain / sector footprint). Kinds with no
|
||||
* usable footprint contribute nothing.
|
||||
*
|
||||
* `nodes` is needed only by kinds whose footprint walks siblings / children
|
||||
* (a straight stair's `stair-segment` chain); every other kind derives its
|
||||
* anchors from `node` alone.
|
||||
*/
|
||||
export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] {
|
||||
export function nodeAlignmentAnchors(
|
||||
node: AnyNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): AlignmentAnchor[] {
|
||||
if (node.type === 'wall' || node.type === 'fence') {
|
||||
const seg = node as {
|
||||
id: string
|
||||
@@ -198,24 +237,57 @@ export function nodeAlignmentAnchors(node: AnyNode): AlignmentAnchor[] {
|
||||
const poly = (node as { polygon?: [number, number][] }).polygon
|
||||
return poly ? polygonAnchors(node.id, poly) : []
|
||||
}
|
||||
const aabb = footprintAABB(node)
|
||||
const aabb = alignmentAABB(node, nodes)
|
||||
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the level a node belongs to by walking its `parentId` chain, or
|
||||
* null when it isn't under a level. Inlined here (rather than importing the
|
||||
* spatial-grid `resolveLevelId`) to keep this services module free of
|
||||
* hook / store dependencies.
|
||||
*/
|
||||
function resolveNodeLevelId(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): string | null {
|
||||
let current: AnyNode | undefined = node
|
||||
while (current) {
|
||||
if (current.type === 'level') return current.id
|
||||
current = current.parentId ? nodes[current.parentId] : undefined
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchors from every alignable node except `excludeId` — the unified
|
||||
* candidate pool every move / placement tool resolves against, so any
|
||||
* draggable object can align to any other (items, walls, fences, slabs,
|
||||
* ceilings, columns).
|
||||
*
|
||||
* When `levelId` is given, nodes that belong to a *different* level are
|
||||
* dropped. Alignment is XZ-only, so without this a node directly below on
|
||||
* another floor (e.g. the ground floor while you place on the first) would
|
||||
* snap and draw a guide even though the two sit at different heights.
|
||||
* Building-/site-scoped nodes with no level ancestor (e.g. an elevator
|
||||
* shaft, which is parented to the building and spans every floor) resolve to
|
||||
* null and stay in the pool so they align on any floor. The 2D floor-plan
|
||||
* deliberately omits the filter — aligning a wall to the one directly below
|
||||
* in plan is the whole point of the reference floor.
|
||||
*/
|
||||
export function collectAlignmentAnchors(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
excludeId: string,
|
||||
levelId?: string | null,
|
||||
): AlignmentAnchor[] {
|
||||
const anchors: AlignmentAnchor[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.id === excludeId) continue
|
||||
anchors.push(...nodeAlignmentAnchors(node))
|
||||
if (levelId) {
|
||||
const nodeLevelId = resolveNodeLevelId(node, nodes)
|
||||
if (nodeLevelId !== null && nodeLevelId !== levelId) continue
|
||||
}
|
||||
anchors.push(...nodeAlignmentAnchors(node, nodes))
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
|
||||
|
||||
/**
|
||||
* Stair footprint geometry shared by the slab-opening sync and the
|
||||
* alignment-anchor adapters. A stair has no single box footprint: straight
|
||||
* stairs are a cumulative chain of `stair-segment` children, curved / spiral
|
||||
* stairs are an annular sector stored entirely on the parent. Both reduce to
|
||||
* an XZ bounding box here so callers that only need "where does the stair sit
|
||||
* in plan" (alignment guides) don't have to re-walk the geometry.
|
||||
*
|
||||
* All math is in the building-local XZ frame, matching `node.position`.
|
||||
*/
|
||||
|
||||
export type StairFootprintAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ rotation in the stair geometry convention (equivalent to rotating by
|
||||
* `-angle` in standard math): positive `angle` turns local +Z toward +X. Every
|
||||
* stair helper — slab openings, floor-plan emitter, this footprint — shares it,
|
||||
* so anchors line up with the rendered stair.
|
||||
*/
|
||||
export function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative per-segment transforms for a straight (segment-chained) stair.
|
||||
* Each flight attaches to the previous segment's end; `attachmentSide` rotates
|
||||
* the chain ±90° (left / right) or continues straight (front). Positions are in
|
||||
* the stair's local frame (before the stair's own `position` / `rotation`).
|
||||
*/
|
||||
export function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!segment) continue
|
||||
|
||||
if (index === 0) {
|
||||
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = segments[index - 1]
|
||||
if (!previous) continue
|
||||
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += deltaX
|
||||
currentY += previous.height
|
||||
currentZ += deltaZ
|
||||
currentRot += rotationDelta
|
||||
|
||||
transforms.push({ position: [currentX, currentY, currentZ], rotation: currentRot })
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
function emptyBox(): StairFootprintAABB {
|
||||
return {
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minZ: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxZ: Number.NEGATIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/** Grow `box` to include the world-plan point produced by rotating the
|
||||
* stair-local point by the stair's rotation and offsetting by its position. */
|
||||
function extendByLocal(box: StairFootprintAABB, stair: StairNode, localX: number, localZ: number) {
|
||||
const [wx, wz] = rotateXZ(localX, localZ, stair.rotation ?? 0)
|
||||
const x = stair.position[0] + wx
|
||||
const z = stair.position[2] + wz
|
||||
if (x < box.minX) box.minX = x
|
||||
if (x > box.maxX) box.maxX = x
|
||||
if (z < box.minZ) box.minZ = z
|
||||
if (z > box.maxZ) box.maxZ = z
|
||||
}
|
||||
|
||||
function finiteBox(box: StairFootprintAABB): StairFootprintAABB | null {
|
||||
return Number.isFinite(box.minX) && Number.isFinite(box.minZ) ? box : null
|
||||
}
|
||||
|
||||
/** Bounding box of a straight stair's segment chain, walking the children. */
|
||||
function straightStairAABB(
|
||||
stair: StairNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): StairFootprintAABB | null {
|
||||
const segments = (stair.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter(
|
||||
(segment): segment is StairSegmentNode =>
|
||||
segment?.type === 'stair-segment' && segment.visible !== false,
|
||||
)
|
||||
if (segments.length === 0) return null
|
||||
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
const box = emptyBox()
|
||||
segments.forEach((segment, index) => {
|
||||
const transform = transforms[index]
|
||||
if (!transform) return
|
||||
const halfWidth = segment.width / 2
|
||||
// Segment-local footprint: X across the flight, Z along the run from the
|
||||
// attachment edge (0) to the far edge (length).
|
||||
for (const [cornerX, cornerZ] of [
|
||||
[-halfWidth, 0],
|
||||
[halfWidth, 0],
|
||||
[halfWidth, segment.length],
|
||||
[-halfWidth, segment.length],
|
||||
] as const) {
|
||||
const [offsetX, offsetZ] = rotateXZ(cornerX, cornerZ, transform.rotation)
|
||||
extendByLocal(box, stair, transform.position[0] + offsetX, transform.position[2] + offsetZ)
|
||||
}
|
||||
})
|
||||
return finiteBox(box)
|
||||
}
|
||||
|
||||
const ARC_SAMPLES = 48
|
||||
|
||||
/** Bounding box of a curved / spiral stair's annular sector (plus the
|
||||
* integrated spiral top landing when present). */
|
||||
function arcStairAABB(stair: StairNode): StairFootprintAABB | null {
|
||||
const isSpiral = stair.stairType === 'spiral'
|
||||
const minInnerRadius = isSpiral ? 0.05 : 0.2
|
||||
const innerRadius = Math.max(minInnerRadius, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9))
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const outerRadius = innerRadius + width
|
||||
|
||||
let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
// A full revolution would make the arc degenerate; clamp just under 2π the
|
||||
// same way the floor-plan emitter does so the sampled box stays correct.
|
||||
if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
|
||||
const half = sweep / 2
|
||||
|
||||
const box = emptyBox()
|
||||
// Sample both rims across the sweep — the extremes can fall on either the
|
||||
// arc ends or an axis crossing in between, so we need the full sweep.
|
||||
for (let step = 0; step <= ARC_SAMPLES; step += 1) {
|
||||
const angle = -half + (sweep * step) / ARC_SAMPLES
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
extendByLocal(box, stair, cos * innerRadius, sin * innerRadius)
|
||||
extendByLocal(box, stair, cos * outerRadius, sin * outerRadius)
|
||||
}
|
||||
|
||||
// Integrated spiral top landing — a rectangle hung off the outer rim.
|
||||
if (isSpiral && stair.topLandingMode === 'integrated') {
|
||||
const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1)
|
||||
const halfWidth = width / 2
|
||||
for (const [cornerX, cornerZ] of [
|
||||
[outerRadius, -halfWidth],
|
||||
[outerRadius + depth, -halfWidth],
|
||||
[outerRadius + depth, halfWidth],
|
||||
[outerRadius, halfWidth],
|
||||
] as const) {
|
||||
extendByLocal(box, stair, cornerX, cornerZ)
|
||||
}
|
||||
}
|
||||
|
||||
return finiteBox(box)
|
||||
}
|
||||
|
||||
/**
|
||||
* XZ bounding box of a stair's plan footprint, or null when it can't be
|
||||
* determined (a straight stair whose segment children aren't in `nodes`).
|
||||
* Straight stairs need the children to walk the flight chain; curved / spiral
|
||||
* stairs are derived from the parent alone, so `nodes` is optional for them.
|
||||
*/
|
||||
export function stairFootprintAABB(
|
||||
stair: StairNode,
|
||||
nodes?: Readonly<Record<string, AnyNode>>,
|
||||
): StairFootprintAABB | null {
|
||||
if ((stair.stairType ?? 'straight') === 'straight') {
|
||||
return nodes ? straightStairAABB(stair, nodes) : null
|
||||
}
|
||||
return arcStairAABB(stair)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SurfaceHoleMetadata,
|
||||
} from '../../schema'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
|
||||
@@ -75,70 +76,8 @@ function normalizeExistingMetadata(
|
||||
}
|
||||
|
||||
// (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators)
|
||||
|
||||
function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return [x * cos + z * sin, -x * sin + z * cos]
|
||||
}
|
||||
|
||||
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!segment) continue
|
||||
|
||||
if (index === 0) {
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = segments[index - 1]
|
||||
if (!previous) continue
|
||||
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += deltaX
|
||||
currentY += previous.height
|
||||
currentZ += deltaZ
|
||||
currentRot += rotationDelta
|
||||
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
// `rotateXZ` + `computeSegmentTransforms` are shared with the alignment-anchor
|
||||
// footprint via `./stair-footprint` so both derive the chain identically.
|
||||
|
||||
function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>) {
|
||||
if (!levelId) return undefined
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { type AlignmentGuide, useAlignmentGuides } from '@pascal-app/core'
|
||||
import { type AlignmentGuide, sceneRegistry, useAlignmentGuides } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { BoxGeometry, CircleGeometry } from 'three'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, CircleGeometry, type Group } from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
@@ -20,7 +22,9 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
*
|
||||
* Guide coordinates are XZ meters in the building-local frame; this layer is
|
||||
* mounted inside ToolManager's building-local group so they render at the
|
||||
* right world position (and line up with the cursor).
|
||||
* right world position (and line up with the cursor). The whole ribbon is
|
||||
* lifted to the active level's building-local Y each frame so it lies on the
|
||||
* floor being edited — not the building base — when floors are stacked.
|
||||
*/
|
||||
|
||||
const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
|
||||
@@ -48,13 +52,28 @@ type Vec3 = [number, number, number]
|
||||
|
||||
export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
|
||||
const guides = useAlignmentGuides((s) => s.guides)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const groupRef = useRef<Group>(null)
|
||||
|
||||
// Guides carry only XZ (building-local plan coords); their Y has to track
|
||||
// the active level so the ground ribbon lies on the floor being edited,
|
||||
// not the building base. Read the level mesh's building-local Y each frame
|
||||
// — the same source `grid.tsx` uses, so the ribbon stays locked to the
|
||||
// grid plane (and lerps with it during a level switch).
|
||||
useFrame(() => {
|
||||
const group = groupRef.current
|
||||
if (!group) return
|
||||
const levelMesh = levelId ? sceneRegistry.nodes.get(levelId) : null
|
||||
group.position.y = levelMesh ? levelMesh.position.y : 0
|
||||
})
|
||||
|
||||
if (guides.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<group ref={groupRef}>
|
||||
{guides.map((guide, i) => (
|
||||
<GuideLine guide={guide} key={i} />
|
||||
))}
|
||||
</>
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type ElevatorNode,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
type GridEvent,
|
||||
type GuideNode,
|
||||
getRenderableSlabPolygon,
|
||||
@@ -28,7 +30,7 @@ import {
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type SiteNode,
|
||||
SlabNode,
|
||||
type SlabNode,
|
||||
type SpawnNode,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
@@ -87,6 +89,7 @@ import {
|
||||
} from '../editor-2d/floorplan-render-context'
|
||||
import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer'
|
||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
||||
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
||||
@@ -528,6 +531,7 @@ type ReferenceFloorData = {
|
||||
fenceEntries: FloorplanFenceEntry[]
|
||||
itemEntries: FloorplanItemEntry[]
|
||||
openingPolygons: OpeningPolygonEntry[]
|
||||
registryEntries: ReferenceFloorRegistryEntry[]
|
||||
slabPolygons: SlabPolygonEntry[]
|
||||
wallPolygons: WallPolygonEntry[]
|
||||
}
|
||||
@@ -538,6 +542,33 @@ type ReferenceFloorColumnEntry = {
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type ReferenceFloorRegistryEntry = {
|
||||
geometry: FloorplanGeometry
|
||||
node: AnyNode
|
||||
}
|
||||
|
||||
// Top-level structural kinds drawn on the *reference* (dimmed, below) floor
|
||||
// via their `def.floorplan` builder, so the symbol is identical to the active
|
||||
// floor. Walls / columns / slabs / fences / items / openings still have
|
||||
// bespoke reference rendering above; these five are the registry-driven kinds
|
||||
// that don't.
|
||||
//
|
||||
// This is a deliberate curation, NOT "every kind with a `def.floorplan`":
|
||||
// ~24 kinds expose one, including children (`roof-segment`, `stair-segment`),
|
||||
// containers (`level`, `building`), and surfaces (`ceiling`, `zone`) that
|
||||
// either render through a parent's builder or shouldn't appear as standalone
|
||||
// reference symbols — auto-deriving would double-draw or clutter the floor.
|
||||
// The list also can't live on `NodeDefinition`: "reference floor" is an
|
||||
// editor 2D-view concept that `packages/core` must stay unaware of (see
|
||||
// wiki/architecture/layers.md). New top-level structural kinds opt in here.
|
||||
const REFERENCE_REGISTRY_KINDS = new Set<AnyNode['type']>([
|
||||
'stair',
|
||||
'roof',
|
||||
'shelf',
|
||||
'spawn',
|
||||
'elevator',
|
||||
])
|
||||
|
||||
type FloorplanStairSegmentEntry = {
|
||||
centerLine: FloorplanLineSegment | null
|
||||
innerPoints: string
|
||||
@@ -3440,6 +3471,10 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.registryEntries.map(({ node, geometry }) => (
|
||||
<FloorplanGeometryRenderer geometry={geometry} key={node.id} />
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -4721,6 +4756,48 @@ export function FloorplanPanel() {
|
||||
]
|
||||
})
|
||||
|
||||
// Render reference-floor stairs and roofs through the SAME registry
|
||||
// builders the active level uses (`def.floorplan` → `buildStairFloorplan`
|
||||
// / `buildRoofFloorplan`), so the symbol is pixel-identical to a stair /
|
||||
// roof on the current floor. These are the only registry-driven kinds
|
||||
// the legacy reference layer doesn't already collect manually.
|
||||
// `viewState` is omitted so each builder emits its unselected appearance
|
||||
// (no selection chrome / resize handles). Both the reference layer and
|
||||
// the registry renderer draw in the same plan-meter space
|
||||
// (`toSvgX`/`toSvgY` are identity), so the geometry composes directly.
|
||||
const registryEntries = referenceDescendants.flatMap<ReferenceFloorRegistryEntry>((node) => {
|
||||
if (!REFERENCE_REGISTRY_KINDS.has(node.type)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const builder = nodeRegistry.get(node.type)?.floorplan
|
||||
if (!builder) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Each builder walks its own segment children (stair-segment /
|
||||
// roof-segment) and filters by type. Pass them in stored `children`
|
||||
// order — stair-segment transforms are cumulative, so order matters.
|
||||
const childIds = (node as { children?: AnyNodeId[] }).children ?? []
|
||||
const children = childIds.flatMap((childId) => {
|
||||
const child = referenceDescendantById.get(childId)
|
||||
return child ? [child] : []
|
||||
})
|
||||
const ctx: GeometryContext = {
|
||||
resolve: (rid) => referenceDescendantById.get(rid) as never,
|
||||
children,
|
||||
siblings: [],
|
||||
parent: referenceFloorLevel,
|
||||
viewState: undefined,
|
||||
}
|
||||
const geometry = builder(node, ctx)
|
||||
if (!geometry) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ geometry, node }]
|
||||
})
|
||||
|
||||
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||
const itemEntries = referenceDescendants.flatMap((node) => {
|
||||
if (
|
||||
@@ -4759,6 +4836,7 @@ export function FloorplanPanel() {
|
||||
fenceEntries,
|
||||
itemEntries,
|
||||
openingPolygons,
|
||||
registryEntries,
|
||||
slabPolygons,
|
||||
wallPolygons,
|
||||
}
|
||||
@@ -6359,26 +6437,6 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [clearDraft])
|
||||
|
||||
const createSlabOnCurrentLevel = useCallback(
|
||||
(points: WallPlanPoint[]) => {
|
||||
if (!levelId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const slabCount = Object.values(nodes).filter((node) => node.type === 'slab').length
|
||||
const slab = SlabNode.parse({
|
||||
name: `Slab ${slabCount + 1}`,
|
||||
polygon: points.map(([x, z]) => [x, z] as [number, number]),
|
||||
})
|
||||
|
||||
createNode(slab, levelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
setSelection({ selectedIds: [slab.id] })
|
||||
return slab.id
|
||||
},
|
||||
[levelId, setSelection],
|
||||
)
|
||||
const createZoneOnCurrentLevel = useCallback(
|
||||
(points: WallPlanPoint[]) => {
|
||||
if (!levelId) {
|
||||
@@ -7676,6 +7734,11 @@ export function FloorplanPanel() {
|
||||
],
|
||||
)
|
||||
|
||||
// Slab creation is owned by the registry-driven slab tool (parity with
|
||||
// ceiling): the click/double-click that closes the polygon is forwarded as
|
||||
// a grid event, and the 3D tool commits the node. These 2D handlers only
|
||||
// maintain the draft-preview state and clear it on close — they must NOT
|
||||
// create a node themselves, or every slab would be built twice.
|
||||
const handleSlabPlacementPoint = useCallback(
|
||||
(point: WallPlanPoint) => {
|
||||
const lastPoint = slabDraftPoints[slabDraftPoints.length - 1]
|
||||
@@ -7685,7 +7748,6 @@ export function FloorplanPanel() {
|
||||
|
||||
const firstPoint = slabDraftPoints[0]
|
||||
if (firstPoint && slabDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
|
||||
createSlabOnCurrentLevel(slabDraftPoints)
|
||||
clearDraft()
|
||||
return
|
||||
}
|
||||
@@ -7693,7 +7755,7 @@ export function FloorplanPanel() {
|
||||
setSlabDraftPoints((currentPoints) => [...currentPoints, point])
|
||||
setCursorPoint(point)
|
||||
},
|
||||
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
|
||||
[clearDraft, slabDraftPoints],
|
||||
)
|
||||
const handleSlabPlacementConfirm = useCallback(
|
||||
(point?: WallPlanPoint) => {
|
||||
@@ -7716,10 +7778,9 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
createSlabOnCurrentLevel(nextPoints)
|
||||
clearDraft()
|
||||
},
|
||||
[clearDraft, createSlabOnCurrentLevel, slabDraftPoints],
|
||||
[clearDraft, slabDraftPoints],
|
||||
)
|
||||
const handleCeilingPlacementPoint = useCallback(
|
||||
(point: WallPlanPoint) => {
|
||||
@@ -8063,6 +8124,9 @@ export function FloorplanPanel() {
|
||||
if (isZoneBuildActive) {
|
||||
handleZonePlacementConfirm(snappedPoint)
|
||||
} else {
|
||||
// Slab is registry-driven: forward the double-click so the 3D tool
|
||||
// commits the node (zone has no registry tool, so it commits locally).
|
||||
emitFloorplanGridEvent('double-click', planPoint, event)
|
||||
handleSlabPlacementConfirm(snappedPoint)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ArcResizeHandle,
|
||||
type Cursor,
|
||||
createSceneApi,
|
||||
emitter,
|
||||
type HandleDescriptor,
|
||||
type HandlePortal,
|
||||
type LinearResizeHandle,
|
||||
@@ -433,9 +434,25 @@ function NodeArrowHandlesForNode({
|
||||
// shader, and it's the reason the wall height arrow (which also stays on
|
||||
// SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
|
||||
// them on EDITOR_LAYER hides them from scenePass and the chevron renders
|
||||
// flat. Arrows are only mounted while a node is selected, so thumbnail
|
||||
// captures (which never have selection) don't need the layer-based
|
||||
// exclusion the wall arrow also goes without.
|
||||
// flat. Because they live on SCENE_LAYER, the thumbnail camera (which only
|
||||
// filters EDITOR_LAYER + GRID_LAYER) would otherwise capture them when a
|
||||
// node is selected at capture time — so the rig hides itself during a
|
||||
// snapshot via the same before/after-capture handshake SelectionManager
|
||||
// uses, then reappears.
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
if (outerRef.current) outerRef.current.visible = false
|
||||
}
|
||||
const show = () => {
|
||||
if (outerRef.current) outerRef.current.visible = true
|
||||
}
|
||||
emitter.on('thumbnail:before-capture', hide)
|
||||
emitter.on('thumbnail:after-capture', show)
|
||||
return () => {
|
||||
emitter.off('thumbnail:before-capture', hide)
|
||||
emitter.off('thumbnail:after-capture', show)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
if (outerRef.current && outerRide) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const DEFAULT_ELEVATOR_WIDTH = 1.6
|
||||
export const DEFAULT_ELEVATOR_DEPTH = 1.6
|
||||
export const DEFAULT_ELEVATOR_WIDTH = 1.84
|
||||
export const DEFAULT_ELEVATOR_DEPTH = 1.84
|
||||
export const DEFAULT_ELEVATOR_CAB_HEIGHT = 2.35
|
||||
export const DEFAULT_ELEVATOR_DOOR_WIDTH = 0.95
|
||||
export const DEFAULT_ELEVATOR_DOOR_HEIGHT = 2.1
|
||||
|
||||
@@ -157,7 +157,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. The elevator aligns by its ORIGIN point.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
|
||||
// Snap the elevator origin onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
@@ -252,7 +252,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
|
||||
rotationRef.current,
|
||||
onPlaced,
|
||||
)
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', levelId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
// The placed elevator's footprint now renders for real; drop the ghost
|
||||
// (the next grid:move re-publishes it for the following placement).
|
||||
|
||||
@@ -632,7 +632,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
let alignZ = 0
|
||||
const bypassAlign = event.nativeEvent?.altKey === true
|
||||
if (!bypassAlign && draft) {
|
||||
alignmentCandidates ??= collectAlignmentAnchors(useScene.getState().nodes, draft.id)
|
||||
alignmentCandidates ??= collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
draft.id,
|
||||
useViewer.getState().selection.levelId,
|
||||
)
|
||||
const ar = resolveAlignment({
|
||||
moving: movingFootprintAnchors(
|
||||
draft as unknown as AnyNode,
|
||||
|
||||
@@ -238,11 +238,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
|
||||
// Static alignment candidates — anchors of every OTHER alignable object
|
||||
// (items, walls, fences, slabs, ceilings, columns), gathered once at drag
|
||||
// start (the scene graph is stable during an imperative move). Coords are
|
||||
// building-local, the same frame as `event.localPosition` and the
|
||||
// rendered cursor, so the guide dots line up with the cursor.
|
||||
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, node.id)
|
||||
// (items, walls, fences, slabs, ceilings, columns) ON THE SAME LEVEL,
|
||||
// gathered once at drag start (the scene graph is stable during an
|
||||
// imperative move). Level-scoped so a node directly below on another
|
||||
// floor doesn't snap (alignment is XZ-only). Coords are building-local,
|
||||
// the same frame as `event.localPosition` and the rendered cursor, so
|
||||
// the guide dots line up with the cursor.
|
||||
const alignmentCandidates = collectAlignmentAnchors(
|
||||
useScene.getState().nodes,
|
||||
node.id,
|
||||
useViewer.getState().selection.levelId ?? node.parentId,
|
||||
)
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
let x = snapToGridStep(event.localPosition[0])
|
||||
|
||||
@@ -173,7 +173,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each roof commits. Both corners of the rectangle align.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
// Snap the drafted corner onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
@@ -291,7 +291,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
|
||||
@@ -154,7 +154,7 @@ export const StairTool: React.FC = () => {
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. The stair aligns by its ORIGIN point.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
// Snap the stair origin onto another object's nearest real anchor and
|
||||
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
|
||||
// point: resolving against the grid point would only ever catch anchors
|
||||
@@ -230,7 +230,7 @@ export const StairTool: React.FC = () => {
|
||||
event.nativeEvent?.altKey === true,
|
||||
)
|
||||
commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,21 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
// 2D body-move flow through `FloorplanRegistryMoveOverlay`'s
|
||||
// Path 2 — position[0] / position[2] update with a 0.5m grid snap.
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
// Align by the OUTER SHAFT (shaft + wall — what's drawn in plan and 3D),
|
||||
// not the cab `width × depth`: the cab is inset by the shaft wall +
|
||||
// clearance, so cab corners sit ~9 cm inside the visible edge — past the
|
||||
// 8 cm snap, which is why the elevator never surfaced a guide. A `box`
|
||||
// shape (not `aabb`) because the elevator is `movable`, so the anchor
|
||||
// bridge relocates this same footprint to the drag point.
|
||||
alignmentFootprint: (node) => {
|
||||
const e = node as ElevatorNodeType
|
||||
const wall = getElevatorShaftWallThickness(e)
|
||||
return {
|
||||
shape: 'box',
|
||||
dimensions: [getElevatorShaftWidth(e) + wall * 2, 1, getElevatorShaftDepth(e) + wall * 2],
|
||||
rotation: [0, e.rotation ?? 0, 0],
|
||||
}
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type NodeDefinition,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairNode as StairNodeType,
|
||||
stairFootprintAABB,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const MIN_CURVED_RISE = 0.3
|
||||
@@ -318,6 +319,15 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
// A stair has no centred box footprint: straight = a cumulative
|
||||
// `stair-segment` chain, curved / spiral = an annular sector. Hand the
|
||||
// alignment bridge the resolved plan `aabb` directly (not a `box`) — the
|
||||
// stair moves by its origin via `affordanceTools.move`, so it only ever
|
||||
// contributes static candidate anchors, never the relocatable box path.
|
||||
alignmentFootprint: (node, nodes) => {
|
||||
const aabb = stairFootprintAABB(node as StairNodeType, nodes)
|
||||
return aabb ? { shape: 'aabb', ...aabb } : null
|
||||
},
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user