Phase 5 batch kind: slab migrates to registry (always-on)

Same shape as fence — thin renderer + system re-export, capabilities
declared, panel slider-drag fix recipe applied. Pure geometry +
floor-plan ports are later milestones.

Files added (packages/nodes/src/slab/):
 - schema.ts: re-exports SlabNode from core.
 - parametrics.ts: elevation slider only. Polygon + holes edited
   via floor-plan boundary / hole editors, not number inputs.
 - definition.ts: capabilities (no `movable` — slab move is bespoke
   whole-translation through MoveSlabTool that integrates with the
   boundary editor; capability-driven dispatch keeps the legacy mover),
   surfaces.top with elevation-as-height for stacked items, relations
   (hosts: ['item'], cascadeDelete: 'descendants'), toolHints (trace /
   finish / cancel for the placement tool).
 - renderer.tsx: thin placeholder mesh + markDirty on mount + node
   events + cached material via the same getSlabMaterial pattern as
   the legacy renderer (preset apply on shared material instance).
 - system.tsx: re-exports the legacy SlabSystem from viewer.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: exports SlabSystem (already had
   DEFAULT_SLAB_MATERIAL, applyMaterialPresetToMaterials, createMaterial
   from earlier exports).
 - packages/nodes/src/index.ts: appends slabDefinition unconditionally
   to builtinPlugin.nodes.
 - packages/editor/src/components/ui/panels/slab-panel.tsx: applied
   the panel slider-drag fix recipe from plans/editor-node-registry.md
   prophylactically (nodeRef pattern, useScene.getState().updateNode
   inside handler, drop subscribed updateNode dep). Slab's elevation
   slider is the only drag-driven control in the panel — would have
   triggered the same Maximum update depth cascade as wall/fence.

No behavior change. Slab now mounts via the registry path, but the
legacy SlabSystem still does the actual polygon triangulation + hole
CSG work (re-exported, not duplicated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 14:12:45 -04:00
co-authored by Claude Opus 4.7
parent fc9a5d02a0
commit 4891f681f3
9 changed files with 260 additions and 4 deletions
@@ -3,7 +3,7 @@
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -14,7 +14,6 @@ import { PanelWrapper } from './panel-wrapper'
export function SlabPanel() { export function SlabPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
@@ -23,12 +22,18 @@ export function SlabPanel() {
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
) )
// See "Panel slider-drag fix recipe" in plans/editor-node-registry.md.
// Stable handler refs across re-renders so slider drags don't trigger
// a Maximum update depth cascade on the panel's SliderControls.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<SlabNode>) => { (updates: Partial<SlabNode>) => {
if (!selectedId) return if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates) useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
}, },
[selectedId, updateNode], [selectedId],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
+3
View File
@@ -1,6 +1,7 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { fenceDefinition } from './fence' import { fenceDefinition } from './fence'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { slabDefinition } from './slab'
import { spawnDefinition } from './spawn' import { spawnDefinition } from './spawn'
import { wallDefinition } from './wall' import { wallDefinition } from './wall'
@@ -30,10 +31,12 @@ export const builtinPlugin: Plugin = {
spawnDefinition as unknown as AnyNodeDefinition, spawnDefinition as unknown as AnyNodeDefinition,
wallDefinition as unknown as AnyNodeDefinition, wallDefinition as unknown as AnyNodeDefinition,
fenceDefinition as unknown as AnyNodeDefinition, fenceDefinition as unknown as AnyNodeDefinition,
slabDefinition as unknown as AnyNodeDefinition,
], ],
} }
export { fenceDefinition } from './fence' export { fenceDefinition } from './fence'
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
export { slabDefinition } from './slab'
export { spawnDefinition } from './spawn' export { spawnDefinition } from './spawn'
export { wallDefinition } from './wall' export { wallDefinition } from './wall'
+88
View File
@@ -0,0 +1,88 @@
import type { NodeDefinition } from '@pascal-app/core'
import { slabParametrics } from './parametrics'
import { SlabNode } from './schema'
/**
* Slab — Phase 5 batch kind, polygon-based.
*
* Capabilities:
* - **No `movable`**: slab's "move" today is whole-slab translation via
* legacy `MoveSlabTool`, which integrates with the floor-plan boundary /
* hole editors. Per the capability-driven dispatch rule, omitting
* `movable` keeps the legacy mover (preserves polygon-aware behavior).
* Migration to the generic mover is possible in a later milestone if
* the legacy mover proves equivalent.
* - **`surfaces.top`**: items host on the slab top at `elevation`.
* - `selectable`, `duplicable`, `deletable` standard.
*
* Relations:
* - `hosts: ['item']` — items mount on the slab top.
* - `cascadeDelete: 'descendants'` — deleting a slab removes hosted items.
*
* Renderer + system: thin renderer + re-export of the legacy `SlabSystem`.
* Same shape as wall / fence runtime port.
*
* Tool field absent: slab has 3 tools (slab-tool, boundary-editor, hole-
* editor) wired through editor state, not registry dispatch.
*/
export const slabDefinition: NodeDefinition<typeof SlabNode> = {
kind: 'slab',
schemaVersion: 1,
schema: SlabNode,
category: 'structure',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
polygon: [],
holes: [],
holeMetadata: [],
elevation: 0.05,
autoFromWalls: false,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
surfaces: {
top: { height: (n) => (n as SlabNode).elevation },
},
duplicable: true,
deletable: true,
},
relations: {
hosts: ['item'],
cascadeDelete: 'descendants',
},
parametrics: slabParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 4,
},
toolHints: [
{ key: 'Left click', label: 'Trace slab outline' },
{ key: 'Enter', label: 'Finish slab' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Slab',
description: 'A polygon-bounded floor surface that hosts items on top.',
icon: { kind: 'iconify', name: 'lucide:square' },
paletteSection: 'structure',
paletteOrder: 30,
},
mcp: {
description: 'A polygon-bounded slab (floor) with optional cutout holes.',
},
}
+2
View File
@@ -0,0 +1,2 @@
export { slabDefinition } from './definition'
export { SlabNode } from './schema'
+17
View File
@@ -0,0 +1,17 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { SlabNode } from './schema'
/**
* Inspector descriptor for slab. Polygon + holes are edited via the
* floor-plan boundary / hole editors — not number inputs. The inspector
* exposes only the per-instance scalars (elevation + auto-from-walls
* toggle).
*/
export const slabParametrics: ParametricDescriptor<SlabNode> = {
groups: [
{
label: 'Elevation',
fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }],
},
],
}
+116
View File
@@ -0,0 +1,116 @@
'use client'
import { getMaterialPresetByRef, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import {
applyMaterialPresetToMaterials,
createMaterial,
DEFAULT_SLAB_MATERIAL,
useNodeEvents,
} from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
/**
* Thin slab renderer. Mounts a placeholder mesh, registers it with
* `sceneRegistry`, and marks the node dirty so `SlabSystem` fills the
* geometry next frame.
*
* Behaviorally identical to the legacy `SlabRenderer` in
* `@pascal-app/viewer/components/renderers/slab/slab-renderer.tsx` —
* same placeholder geometry, same material cache, same render output.
*
* Material logic is preserved from legacy: slab can carry either a raw
* `material` or a `materialPreset` (preset takes precedence; preset
* apply mutates the cached material instance so async texture loads
* still hit the rendered mesh on re-mount).
*
* No `def.geometry` yet — slab polygon geometry depends on holes +
* triangulation that lives inside `SlabSystem`'s useFrame body. Future
* milestone can extract a pure builder if useful, but the system is
* already efficient (rebuilds only dirty nodes); no urgency.
*/
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function createEmptyGeometry() {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return geometry
}
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
) {
const cached = slabMaterialCache.get(cacheKey)
if (cached) return cached
const preset = getMaterialPresetByRef(params.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: params.material
? createMaterial(params.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
applyMaterialPresetToMaterials(slabMaterial, preset)
}
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, slabMaterial)
return slabMaterial
}
const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
const handlers = useNodeEvents(node, 'slab')
useRegistry(node.id, 'slab', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
const material = useMemo(() => {
const resolvedMaterial = node.material
const resolvedMaterialPreset = node.materialPreset
const cacheKey = JSON.stringify({
material: resolvedMaterial ?? null,
materialPreset: resolvedMaterialPreset ?? null,
})
return getSlabMaterial(cacheKey, {
material: resolvedMaterial,
materialPreset: resolvedMaterialPreset,
})
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return (
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
receiveShadow
ref={ref}
visible={node.visible}
{...handlers}
/>
)
}
export default SlabRenderer
+1
View File
@@ -0,0 +1 @@
export { SlabNode } from '@pascal-app/core'
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { SlabSystem } from '@pascal-app/viewer'
/**
* Registry-driven slab system bundle. Re-exports the legacy `SlabSystem`
* (still in viewer) so it mounts via `RegisteredSystems` when slab is
* registry-driven. `<LegacySystem kind="slab">` in viewer/components/
* viewer/index.tsx short-circuits whenever `nodeRegistry.has('slab')`
* is true — same shape wall and fence use.
*
* Future Phase 5+: extract polygon triangulation + hole CSG into a pure
* `buildSlabGeometry(node)` and migrate to `def.geometry`. The legacy
* system body has it well-isolated; should be a clean extraction.
*/
const SlabSystems = () => {
return <SlabSystem />
}
export default SlabSystems
+4
View File
@@ -38,6 +38,10 @@ export { FenceSystem } from './systems/fence/fence-system'
export { InteractiveSystem } from './systems/interactive/interactive-system' export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils' export { snapLevelsToTruePositions } from './systems/level/level-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials' export { getRoofMaterialArray } from './systems/roof/roof-materials'
// Slab system follows the wall + fence re-export pattern — composed into
// the registry-driven slab definition's `def.system`. Removed in Phase 6
// alongside the legacy slab mount point.
export { SlabSystem } from './systems/slab/slab-system'
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials' export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
export { WallCutout } from './systems/wall/wall-cutout' export { WallCutout } from './systems/wall/wall-cutout'
export { getVisibleWallMaterials } from './systems/wall/wall-materials' export { getVisibleWallMaterials } from './systems/wall/wall-materials'