Files
editor/packages/nodes/src/elevator/definition.ts
T
Wassim SAMADandClaude Opus 4.8 1199c04fac feat(paint-slots): migrate elevator onto the unified slot model
Elevator exposes four finish slots — cab / doors / shaft / glass (glass only
when shaft or door style is glass) — on node.slots. The finish materials now
resolve through a per-node material set (context provider) keyed on node.slots
+ scene materials: textures-off joinery role -> node.slots ref -> declared
default (cab preset-softwhite, doors metal-steel, shaft preset-lightgrey,
glass preset-glass), with glass transparency flags re-applied. Cab/door/shaft/
glass meshes tag userData.slotId; buttons, indicators, control panels, and
queue strips stay untagged (functional UI, not paintable). Interaction system
untouched. Monochrome unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:35:41 -04:00

272 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ElevatorNode as ElevatorNodeSchema,
type ElevatorNode as ElevatorNodeType,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
type HandleDescriptor,
type NodeDefinition,
resolveElevatorLevels,
} from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
import { elevatorParametrics } from './parametrics'
import { elevatorPaint } from './paint'
import { ElevatorNode } from './schema'
import { elevatorSlots } from './slots'
const SIDE_HANDLE_OFFSET = 0.22
const HEIGHT_HANDLE_OFFSET = 0.3
const MOVE_FRONT_OFFSET = 0.35
const MIN_ELEVATOR_DIM = 0.6
const MIN_CAB_HEIGHT = 1.4
const ROTATE_CORNER_OFFSET = 0.4
const ROTATE_RING_OFFSET = 0.08
// Symmetric width / depth arrows around the cab footprint. The descriptor
// edits the CAB dimension (`width` / `depth`) — but the arrow must sit
// outside the SHAFT shell so it doesn't disappear inside the rendered
// elevator. Placement uses the shaft's outer extent + wall thickness +
// padding so the arrow clears the visible body on both `solid` and
// `glass` shafts. `anchor: 'center'` means dragging outward grows the
// full span 2× the pointer delta; node.position stays put.
function elevatorAxisHandle(axis: 'x' | 'z'): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'linear-resize',
axis,
anchor: 'center',
min: MIN_ELEVATOR_DIM,
currentValue: (n) => (axis === 'x' ? n.width : n.depth),
apply: (_n, newValue) => (axis === 'x' ? { width: newValue } : { depth: newValue }),
placement: {
position: (n) => {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
const outerHalf =
axis === 'x'
? getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness
: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
return axis === 'x'
? [outerHalf + SIDE_HANDLE_OFFSET, yMid, 0]
: [0, yMid, outerHalf + SIDE_HANDLE_OFFSET]
},
},
}
}
// Cab-height arrow — `anchor: 'min'` keeps the cab floor fixed and grows
// the cab upward. The arrow itself sits above the full SHAFT top (not
// just the cab) so a multi-level elevator's arrow appears outside the
// rendered body rather than buried inside the shaft. `resolveElevatorLevels`
// walks the building's level chain to find shaftTopY; the fallback
// (cabHeight + 0.3) matches the renderer's own when no service levels
// are configured yet.
function elevatorCabHeightHandle(): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
min: MIN_CAB_HEIGHT,
currentValue: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT),
apply: (_n, newValue) => ({ cabHeight: newValue }),
placement: {
position: (n, scene) => {
const { shaftTopY } = resolveElevatorLevels(n, scene.nodes())
const cabTop = Math.max(n.cabHeight, MIN_CAB_HEIGHT) + 0.3
const top = Math.max(shaftTopY, cabTop)
return [0, top + HEIGHT_HANDLE_OFFSET, 0]
},
},
}
}
function elevatorOuterHalfExtents(n: ElevatorNodeType): { halfX: number; halfZ: number } {
const cabWidth = getElevatorCabWidth(n)
const cabDepth = getElevatorCabDepth(n)
const wallThickness = getElevatorShaftWallThickness(n)
return {
halfX: getElevatorShaftWidth(n, cabWidth) / 2 + wallThickness,
halfZ: getElevatorShaftDepth(n, cabDepth) / 2 + wallThickness,
}
}
// Rotation handle — sits at the front-right corner of the shaft
// footprint. `arc-resize` does the angular drag math (raycasts a
// horizontal plane at the arrow's Y, measures cursor angle around the
// elevator's local origin, returns the delta to apply). On hover or
// drag the decoration ring traces the shaft's bounding circle through
// all four corners — same idiom as the column radius ring.
function elevatorRotateHandle(): HandleDescriptor<ElevatorNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
// The cursor delta `atan2(hit.z - center.z, hit.x - center.x)` ticks
// up in the opposite handedness from three.js's Y-rotation (positive
// Ry takes +X → -Z, while atan2(z,x) increases as we go +X → +Z).
// Negate so dragging the cursor CCW around the elevator (as seen
// from above) actually rotates the elevator CCW.
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
// Offset along +Z only so the gizmo sticks out the front of the
// shaft rather than diagonally at the corner — matches the column's
// one-direction rotate placement.
position: (n) => {
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
const yMid = Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
},
// Fixed 45° tilt — leans the curve clockwise (as seen from above)
// toward the shaft's front face.
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
// Bounding circle through the shaft corners — drawn slightly larger
// so it sits outside the visible shell.
radius: (n) => {
const { halfX, halfZ } = elevatorOuterHalfExtents(n)
return Math.hypot(halfX, halfZ) + ROTATE_RING_OFFSET
},
y: (n) => Math.max(n.cabHeight, MIN_CAB_HEIGHT) / 2,
},
}
}
function elevatorMoveHandle(): HandleDescriptor<ElevatorNodeType> {
return {
// Tap-to-engage: hand the elevator to its move tool (same path the
// floating action menu's Move button takes via `setMovingNode`) so the
// 3D grip and the floating-UI button share one move flow — green
// bounding box, alignment guides, R/T rotation, click-to-commit.
kind: 'tap-action',
shape: 'move-cross',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMove(node),
placement: {
position: (n) => {
const { halfZ } = elevatorOuterHalfExtents(n)
return [0, 0.02, halfZ + MOVE_FRONT_OFFSET]
},
},
}
}
const elevatorHandles: HandleDescriptor<ElevatorNodeType>[] = [
elevatorAxisHandle('x'),
elevatorAxisHandle('z'),
elevatorCabHeightHandle(),
elevatorRotateHandle(),
elevatorMoveHandle(),
]
/**
* Elevator — Stage A registration. Wrap-exports the legacy renderer +
* the three legacy systems (runtime / interaction / opening) bundled
* as one `def.system`. Move / inspector still go through legacy
* (`MoveElevatorTool`, `<ElevatorPanel>`) via panel-manager's
* hardcoded switch.
*/
export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
kind: 'elevator',
schemaVersion: 1,
schema: ElevatorNode,
category: 'structure',
surfaceRole: 'joinery',
defaults: () => {
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
// Generic XZ translate so the floating action menu's Move button
// (and the side move-arrows emitted from `def.floorplan`) drive the
// 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 { halfX, halfZ } = elevatorOuterHalfExtents(e)
return {
shape: 'box',
dimensions: [halfX * 2, 1, halfZ * 2],
rotation: [0, e.rotation ?? 0, 0],
}
},
// Drag box wraps just the OUTER SHAFT × full shaft height — same footprint
// alignment uses, same height the rendered shell occupies. Without this
// override, `DragBoundingBox` would measure the whole mesh tree (per-level
// landing assemblies, cab interior, buttons) and the box would feel
// vertically off-centre when the elevator's lowest served level isn't the
// building origin.
dragBounds: (node, nodes) => {
const e = node as ElevatorNodeType
const { halfX, halfZ } = elevatorOuterHalfExtents(e)
const { shaftBaseY, totalHeight } = resolveElevatorLevels(e, nodes ?? {})
const cabHeight = Math.max(e.cabHeight, 1.4)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
return {
size: [halfX * 2, shaftHeight, halfZ * 2],
centerY: shaftBaseY + shaftHeight / 2,
}
},
duplicable: true,
deletable: true,
slots: (node) => elevatorSlots(node as ElevatorNodeType),
paint: elevatorPaint,
},
parametrics: elevatorParametrics,
handles: elevatorHandles,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
floorplan: buildElevatorFloorplan,
// Elevators are parented to the building (siblings of levels), so the
// floor-plan layer's level-rooted DFS never reaches them. Declaring
// `floorplanScope: 'building'` tells `FloorplanRegistryLayer` to walk
// building-scoped kinds separately and synthesise `ctx.parent` as the
// active level — that's what `buildElevatorFloorplan` reads via
// `ctx.parent?.id` to decide whether this floor is in the elevator's
// service range.
floorplanScope: 'building',
// 2D drag affordance for the rotate-arrow emitted at the elevator's
// front-right corner. Body move uses the generic move-arrow / move-
// handle path emitted by the floor-plan builder.
floorplanAffordances: {
'elevator-resize': elevatorResizeAffordance,
'elevator-rotate': elevatorRotateAffordance,
},
presentation: {
label: 'Elevator',
description: 'A multi-level elevator shaft with configurable openings per level.',
icon: { kind: 'url', src: '/icons/wallcut.png' },
paletteSection: 'structure',
paletteOrder: 80,
},
mcp: {
description: 'A multi-level elevator with shaft + openings per level.',
},
}