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
+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>
}