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:
co-authored by
Claude Opus 4.7
parent
fc9a5d02a0
commit
4891f681f3
@@ -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(() => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { slabDefinition } from './definition'
|
||||||
|
export { SlabNode } from './schema'
|
||||||
@@ -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 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SlabNode } from '@pascal-app/core'
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user