From 4891f681f332249537bc90b516cfd356d5fc05e0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 14:12:45 -0400 Subject: [PATCH] Phase 5 batch kind: slab migrates to registry (always-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/ui/panels/slab-panel.tsx | 13 +- packages/nodes/src/index.ts | 3 + packages/nodes/src/slab/definition.ts | 88 +++++++++++++ packages/nodes/src/slab/index.ts | 2 + packages/nodes/src/slab/parametrics.ts | 17 +++ packages/nodes/src/slab/renderer.tsx | 116 ++++++++++++++++++ packages/nodes/src/slab/schema.ts | 1 + packages/nodes/src/slab/system.tsx | 20 +++ packages/viewer/src/index.ts | 4 + 9 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 packages/nodes/src/slab/definition.ts create mode 100644 packages/nodes/src/slab/index.ts create mode 100644 packages/nodes/src/slab/parametrics.ts create mode 100644 packages/nodes/src/slab/renderer.tsx create mode 100644 packages/nodes/src/slab/schema.ts create mode 100644 packages/nodes/src/slab/system.tsx diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index 0034c7ca..9dc330d6 100644 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -3,7 +3,7 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' 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 useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -14,7 +14,6 @@ import { PanelWrapper } from './panel-wrapper' export function SlabPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const editingHole = useEditor((s) => s.editingHole) const setEditingHole = useEditor((s) => s.setEditingHole) const setMovingNode = useEditor((s) => s.setMovingNode) @@ -23,12 +22,18 @@ export function SlabPanel() { 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( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 9bf989d2..436b3f6f 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,6 +1,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { fenceDefinition } from './fence' import { shelfDefinition } from './shelf' +import { slabDefinition } from './slab' import { spawnDefinition } from './spawn' import { wallDefinition } from './wall' @@ -30,10 +31,12 @@ export const builtinPlugin: Plugin = { spawnDefinition as unknown as AnyNodeDefinition, wallDefinition as unknown as AnyNodeDefinition, fenceDefinition as unknown as AnyNodeDefinition, + slabDefinition as unknown as AnyNodeDefinition, ], } export { fenceDefinition } from './fence' export { shelfDefinition } from './shelf' +export { slabDefinition } from './slab' export { spawnDefinition } from './spawn' export { wallDefinition } from './wall' diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts new file mode 100644 index 00000000..12fc1741 --- /dev/null +++ b/packages/nodes/src/slab/definition.ts @@ -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 = { + 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.', + }, +} diff --git a/packages/nodes/src/slab/index.ts b/packages/nodes/src/slab/index.ts new file mode 100644 index 00000000..ad8f5705 --- /dev/null +++ b/packages/nodes/src/slab/index.ts @@ -0,0 +1,2 @@ +export { slabDefinition } from './definition' +export { SlabNode } from './schema' diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts new file mode 100644 index 00000000..3490912a --- /dev/null +++ b/packages/nodes/src/slab/parametrics.ts @@ -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 = { + groups: [ + { + label: 'Elevation', + fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: 0.02, max: 1, step: 0.01 }], + }, + ], +} diff --git a/packages/nodes/src/slab/renderer.tsx b/packages/nodes/src/slab/renderer.tsx new file mode 100644 index 00000000..1364ea08 --- /dev/null +++ b/packages/nodes/src/slab/renderer.tsx @@ -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() + +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(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 ( + + ) +} + +export default SlabRenderer diff --git a/packages/nodes/src/slab/schema.ts b/packages/nodes/src/slab/schema.ts new file mode 100644 index 00000000..f75c5399 --- /dev/null +++ b/packages/nodes/src/slab/schema.ts @@ -0,0 +1 @@ +export { SlabNode } from '@pascal-app/core' diff --git a/packages/nodes/src/slab/system.tsx b/packages/nodes/src/slab/system.tsx new file mode 100644 index 00000000..8041007d --- /dev/null +++ b/packages/nodes/src/slab/system.tsx @@ -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. `` 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 +} + +export default SlabSystems diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 1f81fdf4..0a0c2b21 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -38,6 +38,10 @@ export { FenceSystem } from './systems/fence/fence-system' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' 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 { WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials'