Phase 5 Stage E: full kind migration into packages/nodes

Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.

Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
  (boundary editors, hole editors, placement tools, move tools,
  endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
  stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
  elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
  spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`

Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
  `roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
  `stair-segment/` packages with definition + schema + renderer / system
  / floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
  (ceiling, door, item, shelf, slab, window) — single registry-driven
  dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
  drags (ceiling, fence, slab, wall) — using the shared
  `polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
  (door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
  (door, item, slab, window).

Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
  hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
  shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
  `floorplan-render-context.tsx` updated for the registry layer's new
  contract (props, affordance dispatch, render context).

Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
  `wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
  the registry-only world.

Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.

Wiki: new `plugin-authoring.md` page, README index updated.

Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:14:12 -04:00
co-authored by Claude Opus 4.7
parent 11015ea1ed
commit d747d2f0ea
204 changed files with 6888 additions and 7877 deletions
+54
View File
@@ -0,0 +1,54 @@
import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core'
import { buildElevatorFloorplan } from './floorplan'
import { elevatorParametrics } from './parametrics'
import { ElevatorNode } from './schema'
/**
* 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',
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' },
duplicable: true,
deletable: true,
},
parametrics: elevatorParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
floorplan: buildElevatorFloorplan,
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.',
},
}
+313
View File
@@ -0,0 +1,313 @@
import {
type AnyNodeId,
type ElevatorNode,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
resolveElevatorServiceLevelIds,
useInteractive,
useLiveNodeOverrides,
} from '@pascal-app/core'
/**
* Stage C floor-plan emitter for elevator. Renders:
*
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
* - **Cab indicator** — inner rectangle showing the cab's position within
* the shaft. Highlighted when `runtime.currentLevelId` matches the
* active level (i.e. the car is *on this floor*).
* - **Door opening indicator** — a short marker on the front face
* spanning `doorWidth` so users can see which way the doors open.
* - **Selection / target / queued chrome** — selection stroke when
* the elevator is selected, accent stroke when the runtime targets
* this level (cab is travelling here) or this level is queued.
*
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
* (inspector edits) and `useInteractive.getState().elevators[id]`
* (runtime cab travel). Those reads are non-reactive on their own —
* `FloorplanRegistryLayer` subscribes to both stores so the layer
* re-renders when they change, propagating into this builder.
*
* Per-level served-level chips (the small floor-label badges on each
* shaft side) are not emitted yet — they need an HTML-overlay primitive
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
* the plan, which mangles label legibility). Tracked as follow-up; the
* legacy `<FloorplanElevatorLayer>` still renders the chips for
* pre-registry builds while we figure out the right primitive shape.
*/
const STAGE_LEVEL_FILTER_HIDE = true
export function buildElevatorFloorplan(
node: ElevatorNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
// Merge in any live overrides (inspector edits not yet committed).
const overrides = useLiveNodeOverrides.getState().get(node.id)
const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node
// Service-level gate. If the active level isn't one the elevator
// serves, render nothing — legacy behaviour. The level id comes via
// `ctx.parent` (the elevator's parent in the tree is the level it's
// hosted on, which is the active level when the registry layer walks
// from `levelId`).
const parentLevelId = ctx.parent?.id
if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) {
return null
}
}
const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04)
const cabWidth = Math.max(display.width, 0.8)
const cabDepth = Math.max(display.depth, 0.8)
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
const doorWidth = Math.min(Math.max(display.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
const center = { x: display.position[0], y: display.position[2] }
const cos = Math.cos(display.rotation)
const sin = Math.sin(display.rotation)
const rotate = (lx: number, ly: number): [number, number] => {
// Same clockwise convention as `rotatePlanVector` in editor — see
// `wiki/architecture/tools.md` for why every plan-space rotation
// uses this matrix and not the standard counter-clockwise one.
return [lx * cos + ly * sin, -lx * sin + ly * cos]
}
// Outer shaft footprint corners.
const outerCorners: Array<readonly [number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Cab inner rectangle. The cab sits flush against the front face
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
// away from shaft center.
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
const cabHalfW = cabWidth / 2
const cabHalfD = cabDepth / 2
const cabCorners: Array<readonly [number, number]> = [
[-cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY - cabHalfD],
[cabHalfW, cabCenterLocalY + cabHalfD],
[-cabHalfW, cabCenterLocalY + cabHalfD],
]
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
const [rx, ry] = rotate(lx, ly)
return [center.x + rx, center.y + ry]
})
// Runtime state — current level / target level / queued.
const runtime = useInteractive.getState().elevators[node.id]
const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false
const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false
const isQueuedLevel = parentLevelId
? (runtime?.queue.includes(parentLevelId as never) ?? false)
: false
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Stroke selection — selected wins, then runtime target / queued
// states get the accent palette colour so users can spot "the cab is
// coming here" at a glance.
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isTargetLevel || isQueuedLevel
? '#0ea5e9'
: '#475569'
// Shaft fill — orange when selected, light slate otherwise. When the
// car is *on this level*, the cab indicator inside gets the highlight
// instead of the whole shaft (more legible).
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
const children: FloorplanGeometry[] = []
// Outer shaft.
children.push({
kind: 'polygon',
points: outerPoints,
fill: shaftFill,
stroke,
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
strokeLinejoin: 'round',
opacity: 0.85,
})
// Cab inner rectangle.
children.push({
kind: 'polygon',
points: cabPoints,
fill: cabFill,
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
stroke: cabStroke,
strokeWidth: 0.018,
strokeLinejoin: 'round',
opacity: 0.92,
})
// Door opening indicator — a short line on the front edge centered
// on the cab. The legacy renders a more complex slide / center-open
// hint; this is the minimum useful signal.
const doorY = -halfDepth
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
children.push({
kind: 'line',
x1: center.x + doorStartX,
y1: center.y + doorStartY,
x2: center.x + doorEndX,
y2: center.y + doorEndY,
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
strokeWidth: 0.05,
strokeLinecap: 'round',
opacity: 0.92,
})
// Served-level chips — vertical column of marker circles + level
// numbers to the right of the shaft, only when selected and the
// elevator serves more than one level. Mirrors the legacy
// `<FloorplanElevatorLayer>` chip rendering (~line 6423 in
// floorplan-panel.tsx).
if (isSelected && parentLevelId) {
const sceneNodes = collectAllNodes(ctx)
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
if (serviceLevelIds.length > 1) {
const disabledLevelIds = new Set(display.disabledLevelIds ?? [])
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
const rangeStep = 0.18
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
const rangeX = center.x + rangeOffsetX
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
// Connector spine — single vertical line tying the chips to the
// shaft. Sky blue, semi-transparent.
children.push({
kind: 'line',
x1: rangeX,
y1: rangeTopY,
x2: rangeX,
y2: rangeBottomY,
stroke: '#0ea5e9',
strokeOpacity: 0.52,
strokeWidth: 0.018,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
// One chip per served level. Lowest level at the bottom of the
// column, index increases upward — matches legacy ordering.
serviceLevelIds.forEach((levelId, index) => {
const isCurrent = runtime?.currentLevelId === levelId
const isTarget = runtime?.targetLevelId === levelId
// `resolveElevatorServiceLevelIds` returns plain `string[]`, but
// the runtime queue is `AnyNodeId[]` (branded). The values agree
// at runtime — narrowing through `as never` keeps the includes
// call type-safe without dragging the brand into the helper's
// public return type.
const isQueued = runtime?.queue.includes(levelId as never) ?? false
const isDisabled = disabledLevelIds.has(levelId)
const isServiceOnly = serviceOnlyLevelIds.has(levelId)
const isUnavailable = isDisabled || isServiceOnly
const markerFill = isCurrent
? '#22c55e'
: isTarget || isQueued
? '#38bdf8'
: isUnavailable
? '#94a3b8'
: '#ffffff'
const markerStroke = isUnavailable ? '#64748b' : '#0369a1'
const labelColor = isUnavailable ? '#64748b' : '#075985'
const y = rangeBottomY - index * rangeStep
children.push({
kind: 'circle',
cx: rangeX,
cy: y,
r: 0.055,
fill: markerFill,
fillOpacity: isUnavailable ? 0.72 : 0.95,
stroke: markerStroke,
strokeWidth: 0.012,
})
children.push({
kind: 'text',
x: rangeX + 0.11,
y,
text: String(index + 1),
fontSize: 0.13,
fontWeight: 700,
fill: labelColor,
textAnchor: 'start',
dominantBaseline: 'middle',
})
})
}
}
if (isSelected) {
children.push({
kind: 'move-handle',
point: [display.position[0], display.position[2]],
})
}
return { kind: 'group', children }
}
/**
* `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but
* not the full nodes map. `resolveElevatorServiceLevelIds` wants a
* `Record<id, AnyNode>`; we rebuild it by walking the chain we DO have
* access to. For the elevator's service-level check we only need the
* elevator's parent (the level), its building, and any level siblings.
* This is the minimum graph the resolver needs.
*
* If a future use needs the full nodes map for a builder, we'd surface
* it through ctx — but doing so leaks the whole scene store into every
* `def.floorplan` call. Narrow opt-in is the better default.
*/
function collectAllNodes(ctx: GeometryContext): Record<string, never> {
// We need the building → levels graph for service-level resolution.
// Walk up from the elevator: parent (level) → its parent (building) →
// building.children (all levels). That's enough for the resolver.
const out: Record<string, unknown> = {}
const level = ctx.parent
if (level) {
out[level.id] = level
const building = (level as { parentId?: string }).parentId
? ctx.resolve((level as { parentId: string }).parentId as never)
: undefined
if (building) {
out[building.id] = building
const childIds = (building as unknown as { children?: string[] }).children
if (Array.isArray(childIds)) {
for (const cid of childIds) {
const child = ctx.resolve(cid as never)
if (child) out[child.id] = child
}
}
}
}
return out as Record<string, never>
}
+1
View File
@@ -0,0 +1 @@
export { elevatorDefinition } from './definition'
+940
View File
@@ -0,0 +1,940 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ElevatorNode,
ElevatorNode as ElevatorNodeSchema,
type LevelNode,
requestElevatorLevel,
useInteractive,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
MetricControl,
PanelSection,
PanelWrapper,
resolveElevatorNodeSupportY,
resolveElevatorSupportY,
SliderControl,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Send, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
if (!levelId) return null
return levels.some((level) => level.id === levelId) ? levelId : null
}
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
const servedIds = new Set(node.servedLevelIds)
return levels.filter((level) => servedIds.has(level.id))
}
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
if (!node) return levels[0]?.id ?? ''
const legacyServedLevels = getLegacyServedLevels(node, levels)
return (
findLevelId(levels, node.fromLevelId) ??
legacyServedLevels[0]?.id ??
findLevelId(levels, node.defaultLevelId) ??
levels[0]?.id ??
''
)
}
function getResolvedToLevelId(
node: ElevatorNode | undefined,
levels: LevelNode[],
fromLevelId: string,
) {
if (!node) return levels[0]?.id ?? ''
const explicitTo = findLevelId(levels, node.toLevelId)
if (explicitTo) return explicitTo
const legacyServedLevels = getLegacyServedLevels(node, levels)
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
if (legacyTo) return legacyTo
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
return levels[fallbackIndex]?.id ?? fromLevelId
}
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
const toIndex = levels.findIndex((level) => level.id === toLevelId)
if (fromIndex < 0 && toIndex < 0) return []
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
const resolvedToIndex =
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
return levels.slice(minIndex, maxIndex + 1)
}
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as ElevatorNode['metadata']
}
type ElevatorMetricKey =
| 'width'
| 'depth'
| 'shaftWidth'
| 'shaftDepth'
| 'shaftWallThickness'
| 'cabHeight'
| 'doorWidth'
| 'doorHeight'
type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds'
const DOOR_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorStyle']
}> = [
{ label: 'Center opening', value: 'center-opening' },
{ label: 'Single left', value: 'single-left' },
{ label: 'Single right', value: 'single-right' },
]
const DOOR_PANEL_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['doorPanelStyle']
}> = [
{ label: 'Glass frame', value: 'glass-frame' },
{ label: 'Solid panel', value: 'solid-panel' },
{ label: 'Segmented panel', value: 'segmented-panel' },
]
const SHAFT_STYLE_OPTIONS: Array<{
label: string
value: ElevatorNode['shaftStyle']
}> = [
{ label: 'Solid', value: 'solid' },
{ label: 'Glass', value: 'glass' },
]
function roundMeters(value: number) {
return Math.round(value * 100) / 100
}
function getResolvedShaftWidth(node: ElevatorNode) {
return Math.max(node.shaftWidth ?? node.width, node.width, 0.8)
}
function getResolvedShaftDepth(node: ElevatorNode) {
return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8)
}
function getResolvedShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? 0.09, 0.04)
}
function radiansToDegrees(radians: number) {
return Math.round((radians * 180) / Math.PI)
}
function degreesToRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
export default function ElevatorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const runtime = useInteractive(
useShallow((s) => {
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
if (!state) return null
return {
currentLevelId: state.currentLevelId,
queue: state.queue,
targetLevelId: state.targetLevelId,
}
}),
)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
)
const liveOverrides = useLiveNodeOverrides((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
const liveTransform = useLiveTransforms((s) =>
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
)
useEffect(() => {
return () => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}
}, [selectedId])
const levels = useScene(
useShallow((s) => {
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
const building = s.nodes[node.parentId as AnyNodeId]
if (building?.type !== 'building') return []
return building.children
.map((childId) => s.nodes[childId as AnyNodeId])
.filter((entry): entry is LevelNode => entry?.type === 'level')
.sort((left, right) => left.level - right.level)
}),
)
const handleUpdate = useCallback(
(updates: Partial<ElevatorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const clearLivePreview = useCallback(() => {
if (!selectedId) return
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
}, [selectedId])
useEffect(() => {
if (!(selectedId && node?.type === 'elevator')) return
const supportY = resolveElevatorNodeSupportY(node)
if (node.position[1] >= supportY - 1e-4) return
updateNode(selectedId as AnyNode['id'], {
position: [node.position[0], supportY, node.position[2]],
})
}, [
node?.defaultLevelId,
node?.fromLevelId,
node?.id,
node?.parentId,
node?.position[0],
node?.position[1],
node?.position[2],
node?.type,
selectedId,
updateNode,
])
const previewMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
},
[selectedId],
)
const commitMetric = useCallback(
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
if (!selectedId) return
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
if (hasChange) {
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
}
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
},
[node, selectedId, updateNode],
)
const previewTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!selectedId) return
useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation })
},
[selectedId],
)
const commitTransform = useCallback(
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
if (!(selectedId && node)) return
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
const positionChanged = node.position.some(
(value, index) => Math.abs(value - position[index]!) > 1e-6,
)
const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6
if (positionChanged || rotationChanged) {
updateNode(selectedId as AnyNode['id'], { position, rotation })
}
},
[node, selectedId, updateNode],
)
const getSupportedPosition = useCallback(
(x: number, z: number): ElevatorNode['position'] => {
if (!node) return [x, 0, z]
const supportY = resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
x,
z,
})
return [x, supportY, z]
},
[node],
)
const handleClose = useCallback(() => {
clearLivePreview()
setSelection({ selectedIds: [] })
}, [clearLivePreview, setSelection])
const handleMove = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
clearLivePreview()
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!(node && node.parentId)) return
triggerSFX('sfx:item-pick')
const duplicate = ElevatorNodeSchema.parse({
...structuredClone(node),
id: undefined,
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
})
createNode(duplicate, node.parentId as AnyNodeId)
clearLivePreview()
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('sfx:structure-delete')
clearLivePreview()
useScene.getState().deleteNode(selectedId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [clearLivePreview, selectedId, node, setSelection])
const requestLevel = useCallback(
(levelId: LevelNode['id']) => {
if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return
requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId)
},
[node],
)
const toggleLevelAccess = useCallback(
(field: ElevatorAccessField, levelId: LevelNode['id']) => {
if (!node) return
const disabledIds = new Set(node.disabledLevelIds ?? [])
const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? [])
const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds
if (targetSet.has(levelId)) {
targetSet.delete(levelId)
} else {
targetSet.add(levelId)
}
if (field === 'disabledLevelIds' && disabledIds.has(levelId)) {
serviceOnlyIds.delete(levelId)
}
if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) {
disabledIds.delete(levelId)
}
const nextServiceLevels = getServiceLevels(
levels,
getResolvedFromLevelId(node, levels),
getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)),
)
const nextDefaultLevelId =
node.defaultLevelId && !disabledIds.has(node.defaultLevelId)
? node.defaultLevelId
: (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ??
nextServiceLevels[0]?.id ??
null)
handleUpdate({
defaultLevelId: nextDefaultLevelId,
disabledLevelIds: Array.from(disabledIds),
serviceOnlyLevelIds: Array.from(serviceOnlyIds),
})
},
[handleUpdate, levels, node],
)
const handleServiceBoundaryChange = useCallback(
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
if (!node) return
const nextFromLevelId =
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
const nextToLevelId =
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
const currentDefaultIsServed = nextServedLevels.some(
(level) => level.id === node.defaultLevelId,
)
handleUpdate({
[field]: levelId || null,
defaultLevelId: currentDefaultIsServed
? node.defaultLevelId
: nextFromLevelId || nextServedLevels[0]?.id || null,
...(field === 'fromLevelId'
? {
position: [
node.position[0],
resolveElevatorSupportY({
buildingId: node.parentId,
preferredLevelId: nextFromLevelId,
x: node.position[0],
z: node.position[2],
}),
node.position[2],
] as ElevatorNode['position'],
}
: {}),
servedLevelIds: undefined,
} as Partial<ElevatorNode>)
},
[node, levels, handleUpdate],
)
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
const displayPosition = liveTransform?.position ?? displayNode.position
const displayRotation = liveTransform?.rotation ?? displayNode.rotation
const displayRotationDegrees = radiansToDegrees(displayRotation)
const displayShaftWidth = getResolvedShaftWidth(displayNode)
const displayShaftDepth = getResolvedShaftDepth(displayNode)
const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode)
const fromLevelId = getResolvedFromLevelId(node, levels)
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
const servedLevelIdSet = new Set<string>(servedLevels.map((level) => level.id))
const disabledLevelIds = new Set(
(node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const serviceOnlyLevelIds = new Set(
(node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
)
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
const defaultLevelOptions =
enabledServedLevels.length > 0
? enabledServedLevels
: servedLevels.length > 0
? servedLevels
: levels
const selectedDefaultLevelId = defaultLevelOptions.some(
(level) => level.id === node.defaultLevelId,
)
? (node.defaultLevelId ?? '')
: fromLevelId
const activeLevelId =
runtime?.currentLevelId ??
(servedLevels.some((level) => level.id === node.defaultLevelId)
? node.defaultLevelId
: fromLevelId || levels[0]?.id) ??
null
const destinationOrderByLevelId = new Map<string, number>()
const orderedDestinationIds: string[] = []
if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId)
for (const levelId of runtime?.queue ?? []) {
if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId)
}
orderedDestinationIds.forEach((levelId, index) => {
destinationOrderByLevelId.set(levelId, index + 1)
})
return (
<PanelWrapper
icon="/icons/elevator.svg"
onClose={handleClose}
title={node.name || 'Elevator'}
width={300}
>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={handleDuplicate}
/>
<ActionButton
className="text-destructive hover:text-destructive"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDelete}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(value, displayPosition[2])
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[0])}
/>
<SliderControl
label="Y"
max={50}
min={-50}
onChange={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position: ElevatorNode['position'] = [
displayPosition[0],
value,
displayPosition[2],
]
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[1])}
/>
<SliderControl
label="Z"
max={50}
min={-50}
onChange={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
previewTransform(position, displayRotation)
}}
onCommit={(value) => {
const position = getSupportedPosition(displayPosition[0], value)
commitTransform(position, displayRotation)
}}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={roundMeters(displayPosition[2])}
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label="Yaw"
max={180}
min={-180}
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
precision={0}
restoreOnCommit={false}
step={1}
unit="°"
value={displayRotationDegrees}
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation - Math.PI / 4)
}}
/>
<ActionButton
label="+45°"
onClick={() => {
triggerSFX('sfx:item-rotate')
commitTransform(displayPosition, displayRotation + Math.PI / 4)
}}
/>
</div>
</PanelSection>
<PanelSection title="Cab">
<MetricControl
label="Width"
max={4}
min={0.8}
onChange={(value) => previewMetric('width', value)}
onCommit={(value) => commitMetric('width', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.width}
/>
<MetricControl
label="Depth"
max={4}
min={0.8}
onChange={(value) => previewMetric('depth', value)}
onCommit={(value) => commitMetric('depth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.depth}
/>
<MetricControl
label="Cab Height"
max={4}
min={1.8}
onChange={(value) => previewMetric('cabHeight', value)}
onCommit={(value) => commitMetric('cabHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.cabHeight}
/>
</PanelSection>
<PanelSection title="Shaft">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Shaft Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
}
value={displayNode.shaftStyle ?? 'solid'}
>
{SHAFT_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Shaft Width"
max={5}
min={displayNode.width}
onChange={(value) => previewMetric('shaftWidth', Math.max(value, displayNode.width))}
onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftWidth}
/>
<MetricControl
label="Shaft Depth"
max={5}
min={displayNode.depth}
onChange={(value) => previewMetric('shaftDepth', Math.max(value, displayNode.depth))}
onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayShaftDepth}
/>
<MetricControl
label="Wall Thickness"
max={0.4}
min={0.04}
onChange={(value) => previewMetric('shaftWallThickness', value)}
onCommit={(value) => commitMetric('shaftWallThickness', value)}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={displayShaftWallThickness}
/>
</PanelSection>
<PanelSection title="Doors">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Opening Style
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
}
value={displayNode.doorStyle ?? 'center-opening'}
>
{DOOR_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Door Type
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) =>
handleUpdate({
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
})
}
value={displayNode.doorPanelStyle ?? 'glass-frame'}
>
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<MetricControl
label="Door Width"
max={Math.max(displayNode.width - 0.1, 0.5)}
min={0.45}
onChange={(value) => previewMetric('doorWidth', value)}
onCommit={(value) => commitMetric('doorWidth', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorWidth}
/>
<MetricControl
label="Door Height"
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
min={1.2}
onChange={(value) => previewMetric('doorHeight', value)}
onCommit={(value) => commitMetric('doorHeight', value)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={displayNode.doorHeight}
/>
</PanelSection>
<PanelSection title="Service">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('fromLevelId', event.target.value)}
value={fromLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
value={toLevelId}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Default Floor
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
value={selectedDefaultLevelId}
>
{defaultLevelOptions.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level}`}
</option>
))}
</select>
</div>
</PanelSection>
<PanelSection title="Access">
<div className="space-y-2">
{servedLevels.map((level) => {
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<div
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
key={level.id}
>
<span className="min-w-0 truncate text-sm">
{level.name || `Level ${level.level}`}
</span>
<div className="flex shrink-0 gap-1.5">
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isServiceOnly
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
disabled={isDisabled}
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
type="button"
>
Service
</button>
<button
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
isDisabled
? 'border-red-300/45 bg-red-400/15 text-red-100'
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
}`}
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
type="button"
>
Disabled
</button>
</div>
</div>
)
})}
</div>
</PanelSection>
<PanelSection title="Destination">
<div className="grid grid-cols-2 gap-1.5">
{servedLevels.map((level) => {
const isActive = activeLevelId === level.id
const stopOrder = destinationOrderByLevelId.get(level.id)
const isDisabled = disabledLevelIds.has(level.id)
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
return (
<button
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
isDisabled
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
: isActive
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
}`}
disabled={isDisabled}
key={level.id}
onClick={() => requestLevel(level.id)}
type="button"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
{isDisabled ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Disabled
</span>
) : isServiceOnly ? (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Service
</span>
) : (
stopOrder && (
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
Stop {stopOrder}
</span>
)
)}
</span>
<span
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
}`}
>
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
</span>
</button>
)
})}
</div>
</PanelSection>
<PanelSection title="Motion">
<SliderControl
label="Speed"
max={8}
min={0.5}
onChange={(value) => handleUpdate({ speed: value })}
precision={1}
step={0.1}
unit="m/s"
value={node.speed}
/>
<SliderControl
label="Door Time"
max={2200}
min={300}
onChange={(value) => handleUpdate({ doorDurationMs: value })}
step={50}
unit="ms"
value={node.doorDurationMs}
/>
<SliderControl
label="Dwell"
max={5000}
min={300}
onChange={(value) => handleUpdate({ dwellMs: value })}
step={100}
unit="ms"
value={node.dwellMs}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,6 @@
import type { ElevatorNode, ParametricDescriptor } from '@pascal-app/core'
export const elevatorParametrics: ParametricDescriptor<ElevatorNode> = {
groups: [],
customPanel: () => import('./panel'),
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { ElevatorNode } from '@pascal-app/core'
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { ElevatorInteractionSystem } from '@pascal-app/viewer'
/**
* Composite system for elevator — bundles three per-frame systems:
* `ElevatorRuntimeSystem` (cab travel + door state machine),
* `ElevatorInteractionSystem` (call buttons / cab UI), and
* `ElevatorOpeningSystem` (wall + slab cutout cascade).
*/
export default function ElevatorSystem() {
return (
<>
<ElevatorRuntimeSystem />
<ElevatorInteractionSystem />
<ElevatorOpeningSystem />
</>
)
}