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
@@ -0,0 +1,172 @@
import {
type AnyNode,
type AnyNodeId,
type DoorNode,
type FloorplanGeometry,
type GeometryContext,
isCurvedWall,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
/**
* Build placement-measurement dimension lines for a door / window
* being moved on a wall. Mirrors the legacy
* `movingOpeningPlacementMeasurements` in `floorplan-panel.tsx`:
*
* - Find the previous opening on the same wall (or the wall start
* if none) → distance from its right face to this opening's
* left face.
* - Find the next opening (or wall end) → distance from this
* opening's right face to its left face.
* - Each renders as a `dimension` primitive offset to the wall's
* outer face so the labels don't overlap the wall body.
*
* Returns an empty array if the parent isn't a wall, the wall is
* curved, or the opening is at wall length 0 (invalid).
*/
export function buildOpeningPlacementDimensions(
opening: DoorNode | WindowNode,
ctx: GeometryContext,
): FloorplanGeometry[] {
const wall = ctx.parent as WallNode | null
if (!wall || wall.type !== 'wall') return []
if (isCurvedWall(wall)) return []
const [x1, z1] = wall.start
const [x2, z2] = wall.end
const dx = x2 - x1
const dz = z2 - z1
const wallLength = Math.hypot(dx, dz)
if (wallLength < 1e-6) return []
const dirX = dx / wallLength
const dirZ = dz / wallLength
// Outward normal — chosen by the wall builder via the level
// centroid. We replicate that decision here so the dimension lines
// land on the same face. Walk wall's siblings (the level's other
// walls) via ctx.resolve to compute the centroid.
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
const halfWidth = opening.width / 2
const startDist = opening.position[0] - halfWidth
const endDist = opening.position[0] + halfWidth
// Walk wall.children to find adjacent openings (door OR window).
// ctx.siblings only includes same-kind nodes; doors + windows need
// each other so we go via the parent's children directly.
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
let leftBoundary: number | null = null
let rightBoundary: number | null = null
for (const childId of childIds) {
if (childId === opening.id) continue
const sibling = ctx.resolve(childId) as AnyNode | undefined
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
const sib = sibling as DoorNode | WindowNode
const sibStart = sib.position[0] - sib.width / 2
const sibEnd = sib.position[0] + sib.width / 2
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
leftBoundary = sibEnd
}
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
rightBoundary = sibStart
}
}
const leftFromDist = leftBoundary ?? 0
const rightToDist = rightBoundary ?? wallLength
// Place the dimension line at a constant offset from the wall's
// outer face — same value the legacy uses for its placement
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
// dimension's `start` / `end` are points on that outer face (not
// the wall centerline), so the extension lines stay short and the
// overall layout matches the legacy treatment 1:1.
const wallThickness = wall.thickness ?? 0.1
const halfThickness = wallThickness / 2
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
// Project a point on the wall axis at distance `along` onto the
// wall's outer face by adding `halfThickness * outwardNormal`.
const facePoint = (along: number): readonly [number, number] => [
x1 + dirX * along + outwardNormal[0] * halfThickness,
z1 + dirZ * along + outwardNormal[1] * halfThickness,
]
const out: FloorplanGeometry[] = []
const leftDistance = startDist - leftFromDist
if (leftDistance >= 0.01) {
out.push({
kind: 'dimension',
start: facePoint(leftFromDist),
end: facePoint(startDist),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
stroke: '#f97316',
})
}
const rightDistance = rightToDist - endDist
if (rightDistance >= 0.01) {
out.push({
kind: 'dimension',
start: facePoint(endDist),
end: facePoint(rightToDist),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
stroke: '#f97316',
})
}
return out
}
/**
* Choose the perpendicular wall normal that points away from the
* other walls' centroid — same logic the wall builder uses to place
* its own dimension overlay so left / right placement dimensions land
* on the same face the wall label is on.
*/
function computeOutwardNormal(
wall: WallNode,
ctx: GeometryContext,
dirX: number,
dirZ: number,
): readonly [number, number] {
const nx = -dirZ
const nz = dirX
// Find the level by walking up via wall.parentId.
const level = wall.parentId
? (ctx.resolve(wall.parentId as AnyNodeId) as AnyNode | undefined)
: null
const levelChildren = ((level as unknown as { children?: AnyNodeId[] })?.children ??
[]) as AnyNodeId[]
let sumX = 0
let sumZ = 0
let count = 0
for (const childId of levelChildren) {
const child = ctx.resolve(childId) as AnyNode | undefined
if (!child || child.type !== 'wall') continue
const w = child as WallNode
sumX += w.start[0] + w.end[0]
sumZ += w.start[1] + w.end[1]
count += 2
}
if (count === 0) return [nx, nz]
const centroidX = sumX / count
const centroidZ = sumZ / count
const wallMidX = (wall.start[0] + wall.end[0]) / 2
const wallMidZ = (wall.start[1] + wall.end[1]) / 2
const fromCentroidX = wallMidX - centroidX
const fromCentroidZ = wallMidZ - centroidZ
const facingAway = fromCentroidX * nx + fromCentroidZ * nz >= 0 ? 1 : -1
return [nx * facingAway, nz * facingAway]
}
@@ -0,0 +1,285 @@
import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
/**
* Shared "edit polygon" floor-plan affordances. Used by kinds whose
* primary editable shape is a `polygon: [number, number][]` field
* (slab, ceiling, site, zone) with optional `holes: [number, number][][]`.
*
* Each affordance accepts an optional `holeIndex` in its payload — when
* present, the operation targets `node.holes[holeIndex]`; otherwise it
* targets the outer `node.polygon`. The same factory wires both
* boundary and hole interactions without duplicating the math.
*
* Three affordances available:
*
* - `move-vertex` — drag an existing vertex.
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag
* it (click-without-drag reverts to the snapshot).
* - `move-edge` — drag a whole edge perpendicular to itself (both
* endpoints translate by `normal * projection`).
*/
export type PolygonVertexPayload = {
/** Target a hole's polygon instead of the boundary. */
holeIndex?: number
vertexIndex: number
}
export type AddVertexPayload = {
holeIndex?: number
edgeIndex: number
}
export type EdgeDragPayload = {
holeIndex?: number
edgeIndex: number
}
type PolygonShape = {
polygon: ReadonlyArray<readonly [number, number]>
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
}
function getRing(node: PolygonShape, holeIndex: number | undefined): [number, number][] | null {
if (holeIndex === undefined) {
return node.polygon.map(([x, y]) => [x, y] as [number, number])
}
const hole = node.holes?.[holeIndex]
if (!hole) return null
return hole.map(([x, y]) => [x, y] as [number, number])
}
/**
* Returns a patch object that, when applied to the node, updates the
* targeted ring (boundary polygon or specific hole) to `nextRing`. The
* cast through `unknown → Partial<unknown> → never` satisfies the
* generic `updateNodes` patch type without forcing every variant of
* the kind union into scope here.
*/
function buildRingPatch(
node: PolygonShape,
holeIndex: number | undefined,
nextRing: ReadonlyArray<[number, number]>,
): unknown {
if (holeIndex === undefined) {
return { polygon: nextRing }
}
const nextHoles = (node.holes ?? []).map((hole, i) =>
i === holeIndex ? nextRing : hole.map(([x, y]) => [x, y] as [number, number]),
)
return { holes: nextHoles }
}
export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const nextRing: [number, number][] = originalRing.map((p, i) =>
i === vertexIndex ? [snapped[0], snapped[1]] : p,
)
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
/**
* Companion to `createPolygonVertexAffordance`. Inserts a new vertex at
* the midpoint of edge `edgeIndex` (between vertices i and i+1) and
* then drags that new vertex with the pointer. The dispatcher's
* snapshot was taken **before** `start()` ran, so a pointer-up without
* movement reverts to the pre-insert ring — "click without drag" is a
* no-op, matching the legacy slab boundary editor.
*/
export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as AddVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const a = originalRing[edgeIndex]
const b = originalRing[(edgeIndex + 1) % originalRing.length]
if (!a || !b) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const midpoint: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
const newVertexIndex = edgeIndex + 1
const initialRing: [number, number][] = [
...originalRing.slice(0, newVertexIndex),
midpoint,
...originalRing.slice(newVertexIndex),
]
// Apply the insert immediately so the user sees the new vertex
// before they even move.
const initialPatch = buildRingPatch(node, holeIndex, initialRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: initialPatch as Partial<unknown> as never }])
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const nextRing: [number, number][] = initialRing.map((p, i) =>
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
)
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
/**
* Edge-drag: move a whole edge perpendicular to itself. Both endpoints
* translate by `edgeNormal * projectedDelta`. The other vertices of
* the ring stay put — adjacent edges effectively pivot around their
* far endpoints.
*
* Snap is grid-aligned on the projected scalar (so a Shift-free drag
* lands on grid lines along the edge normal).
*/
export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as EdgeDragPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const startVertex = originalRing[edgeIndex]
const endVertex = originalRing[(edgeIndex + 1) % originalRing.length]
if (!startVertex || !endVertex) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
const dx = endVertex[0] - startVertex[0]
const dy = endVertex[1] - startVertex[1]
const len = Math.hypot(dx, dy)
if (len < 1e-6) {
return {
affectedIds: [node.id],
apply() {},
canCommit() {
return false
},
}
}
// Perpendicular unit normal (rotate 90° CCW).
const normalX = -dy / len
const normalY = dx / len
const startX = initialPlanPoint[0]
const startY = initialPlanPoint[1]
const edgeStartIndex = edgeIndex
const edgeEndIndex = (edgeIndex + 1) % originalRing.length
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
// Project the pointer delta onto the edge normal — that's the
// signed perpendicular distance the edge should travel.
const deltaX = planPoint[0] - startX
const deltaY = planPoint[1] - startY
let projection = deltaX * normalX + deltaY * normalY
if (!modifiers.shiftKey) {
// Snap the projection scalar to a 0.5m grid (legacy uses the
// same half-meter snap for slab edges).
projection = Math.round(projection * 2) / 2
}
const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) {
return [p[0] + normalX * projection, p[1] + normalY * projection]
}
return [p[0], p[1]] as [number, number]
})
const patch = buildRingPatch(node, holeIndex, nextRing)
useScene
.getState()
.updateNodes([{ id: node.id, data: patch as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
if (!final || (final as unknown as { type: string }).type !== kind) return false
const finalRing = holeIndex === undefined ? final.polygon : (final.holes ?? [])[holeIndex]
return !!finalRing && finalRing.length >= 3
},
}
},
}
}
@@ -0,0 +1,129 @@
import { type AnyNode, type AnyNodeId, isCurvedWall, type WallNode } from '@pascal-app/core'
/**
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
* space (door, window, item with `attachTo === 'wall' | 'wall-side'`).
*
* The 3D move tools listen to R3F `WallEvent`s (mesh-hit with normal)
* for wall snapping. The 2D path doesn't have that — pointer events
* land on the SVG layer, not on the wall meshes. This helper does the
* equivalent plan-space projection: for each wall on the level, find
* the perpendicular projection of the pointer onto the wall line and
* pick the closest one within a reasonable range.
*
* Curved walls are excluded — the legacy door / window placement also
* rejects curved walls (mitering + arc + opening would tear in 3D).
*/
const WALL_SNAP_DISTANCE_M = 1.5
export type WallHit = {
wall: WallNode
/** Distance along the wall from `start` (clamped to [0, length]). */
localX: number
/** Signed perpendicular distance from the wall axis (+ on the "front" side). */
perpDistance: number
/** Which face of the wall the pointer was on. */
side: 'front' | 'back'
/** Wall direction unit vector, x. */
dirX: number
/** Wall direction unit vector, y (== z in plan). */
dirY: number
/** Wall length in metres. */
wallLength: number
/**
* Rotation around Y in **wall-local** space — 0 for the front face,
* π for the back. Matches the 3D `calculateItemRotation(normal)`
* convention (normal +Z → 0, normal -Z → π). Items / doors / windows
* are children of the wall mesh, so their `rotation.y` is in the
* wall's local frame; writing a world-space rotation here would mis-
* orient the node by `wallRotation` (off by 90° on vertical walls).
*/
itemRotation: number
}
/**
* Walk every wall under `parentLevelId` and return the closest one to
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
* `excludeWallId` skips a specific wall (e.g. the current parent during
* a re-parent flow if you want a "must change" guard).
*/
export function findClosestWallInPlan(
planPoint: readonly [number, number],
nodes: Record<AnyNodeId, AnyNode>,
parentLevelId: AnyNodeId | null,
excludeWallId?: AnyNodeId,
): WallHit | null {
if (!parentLevelId) return null
const level = nodes[parentLevelId]
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
if (!Array.isArray(childIds)) return null
let best: WallHit | null = null
for (const childId of childIds) {
const node = nodes[childId]
if (!node || node.type !== 'wall') continue
if (childId === excludeWallId) continue
const wall = node as WallNode
if (isCurvedWall(wall)) continue
const sx = wall.start[0]
const sy = wall.start[1]
const dx = wall.end[0] - sx
const dy = wall.end[1] - sy
const wallLength = Math.hypot(dx, dy)
if (wallLength < 1e-6) continue
const dirX = dx / wallLength
const dirY = dy / wallLength
// Project pointer onto wall axis.
const px = planPoint[0] - sx
const py = planPoint[1] - sy
const along = px * dirX + py * dirY
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
const clampedAlong = Math.max(0, Math.min(wallLength, along))
// Distance from the pointer to the wall segment (not just the line).
const closestPointX = sx + dirX * clampedAlong
const closestPointY = sy + dirY * clampedAlong
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
if (distance > WALL_SNAP_DISTANCE_M) continue
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
// Side determination, calibrated to the 3D wall convention. In
// wall-local space the wall extends along +X and its +Z axis is the
// front-face normal. After `mesh.rotation.y = -wallAngle`:
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
// maps to world +Z = plan +Y, so the front face is on plan +Y.
// `perpRaw = py` is positive → front.
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
// maps to world -X = plan -X, so the front face is on plan -X.
// `perpRaw = -px` is positive there → front.
// So `perpRaw >= 0` is consistently the front side. The earlier
// labelling had this flipped, which produced rotations that were
// off by 90° on non-horizontal walls.
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
// 0 when the item faces the front normal (+Z), π for the back. The
// node is parented to the wall, so this composes with the wall's
// own rotation when rendered. Don't return a world-space rotation
// here — the consumer writes this straight into `node.rotation[1]`.
const itemRotation = side === 'front' ? 0 : Math.PI
best = {
wall,
localX: clampedAlong,
perpDistance: perpRaw,
side,
dirX,
dirY,
wallLength,
itemRotation,
}
}
return best
}