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

Structurally identical to slab. Stage A migration: registers the kind,
wraps the legacy renderer + system, applies the panel slider-drag fix
recipe.

Files added (packages/nodes/src/ceiling/):
 - schema.ts: re-exports CeilingNode from core.
 - parametrics.ts: height slider only. Polygon + holes via floor-plan
   editors.
 - definition.ts: capabilities (no `movable`, `surfaces.top` mapped to
   `height`), relations (hosts: ['item'] for ceiling-mounted lights /
   fans, cascadeDelete: 'descendants'), toolHints, parametrics.
 - renderer.tsx: wrap-export of legacy CeilingRenderer. The legacy
   renderer uses TSL shader code for grid-line patterns (~100 lines);
   not worth duplicating at Stage A. Per-stage migration plan in
   plans/editor-node-registry.md moves the renderer body into this
   folder at Stage B/F.
 - system.tsx: re-exports legacy CeilingSystem.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: new public exports for CeilingRenderer
   + CeilingSystem.
 - packages/nodes/src/index.ts: appends ceilingDefinition.
 - packages/editor/src/components/ui/panels/ceiling-panel.tsx: panel
   slider-drag fix recipe applied (nodeRef pattern, useScene.getState()
   inside handler, drop subscribed updateNode dep) so the height
   slider doesn't trigger the same cascade fence + wall + slab fixed.

Phase 5 progress: shelf , spawn , wall , fence , slab , ceiling .
Six kinds on the registry. Door / window / item / stair / roof / zone
follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 14:32:01 -04:00
co-authored by Claude Opus 4.7
parent 4891f681f3
commit 2dd50fa5be
9 changed files with 151 additions and 4 deletions
@@ -3,7 +3,7 @@
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, 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 CeilingPanel() { export function CeilingPanel() {
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,17 @@ export function CeilingPanel() {
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
) )
// Panel slider-drag fix recipe (plans/editor-node-registry.md): stable
// handler refs so slider drags don't trigger Maximum update depth.
const nodeRef = useRef(node)
nodeRef.current = node
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => { (updates: Partial<CeilingNode>) => {
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(() => {
+80
View File
@@ -0,0 +1,80 @@
import type { NodeDefinition } from '@pascal-app/core'
import { ceilingParametrics } from './parametrics'
import { CeilingNode } from './schema'
/**
* Ceiling — Phase 5 batch kind, polygon-based. Structurally identical
* to slab but mounted at `height` rather than `elevation`.
*
* Capabilities:
* - **No `movable`**: ceiling move is bespoke via legacy `MoveCeilingTool`
* + the floor-plan boundary / hole editors. Capability-driven dispatch
* keeps the legacy mover (preserves polygon-aware behavior).
* - **`surfaces.top`**: items host on the ceiling at `height`.
* - `selectable`, `duplicable`, `deletable` standard.
*
* Relations: `hosts: ['item']` for ceiling-mounted items (lights, fans).
* `cascadeDelete: 'descendants'` removes hosted items on ceiling delete.
*/
export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
kind: 'ceiling',
schemaVersion: 1,
schema: CeilingNode,
category: 'structure',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
polygon: [],
holes: [],
holeMetadata: [],
height: 2.5,
autoFromWalls: false,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
surfaces: {
top: { height: (n) => (n as CeilingNode).height },
},
duplicable: true,
deletable: true,
},
relations: {
hosts: ['item'],
cascadeDelete: 'descendants',
},
parametrics: ceilingParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 4,
},
toolHints: [
{ key: 'Left click', label: 'Trace ceiling outline' },
{ key: 'Enter', label: 'Finish ceiling' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Ceiling',
description: 'A polygon-bounded ceiling surface that hosts ceiling-mounted items.',
icon: { kind: 'iconify', name: 'lucide:square-dashed' },
paletteSection: 'structure',
paletteOrder: 40,
},
mcp: {
description: 'A polygon-bounded ceiling with optional cutout holes.',
},
}
+2
View File
@@ -0,0 +1,2 @@
export { ceilingDefinition } from './definition'
export { CeilingNode } from './schema'
+16
View File
@@ -0,0 +1,16 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { CeilingNode } from './schema'
/**
* Inspector descriptor for ceiling. Polygon + holes are edited via the
* floor-plan boundary / hole editors — not number inputs. Inspector
* exposes only the per-instance scalar (height).
*/
export const ceilingParametrics: ParametricDescriptor<CeilingNode> = {
groups: [
{
label: 'Dimensions',
fields: [{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }],
},
],
}
+15
View File
@@ -0,0 +1,15 @@
'use client'
import { CeilingRenderer } from '@pascal-app/viewer'
/**
* Wrap-export of the legacy `CeilingRenderer`.
*
* Ceiling's renderer uses TSL shader code for the grid-line pattern
* (~100 lines incl. material setup) — too much to duplicate at Stage A.
* The legacy file stays in viewer; the registry imports it through the
* public export. Phase 5 Stage B/F (per-kind migration stages, see
* plans/editor-node-registry.md) moves the renderer body into this
* folder and deletes the legacy file.
*/
export default CeilingRenderer
+1
View File
@@ -0,0 +1 @@
export { CeilingNode } from '@pascal-app/core'
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { CeilingSystem } from '@pascal-app/viewer'
/**
* Registry-driven ceiling system bundle. Re-exports the legacy
* `CeilingSystem` so it mounts via `RegisteredSystems` when ceiling is
* registry-driven. `<LegacySystem kind="ceiling">` in viewer/components/
* viewer/index.tsx short-circuits whenever `nodeRegistry.has('ceiling')`
* is true — same shape wall / fence / slab use.
*
* Future Phase 5+: extract polygon triangulation + hole CSG into a pure
* `buildCeilingGeometry(node)` and migrate to `def.geometry`.
*/
const CeilingSystems = () => {
return <CeilingSystem />
}
export default CeilingSystems
+3
View File
@@ -1,4 +1,5 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { ceilingDefinition } from './ceiling'
import { fenceDefinition } from './fence' import { fenceDefinition } from './fence'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { slabDefinition } from './slab' import { slabDefinition } from './slab'
@@ -32,9 +33,11 @@ export const builtinPlugin: Plugin = {
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, slabDefinition as unknown as AnyNodeDefinition,
ceilingDefinition as unknown as AnyNodeDefinition,
], ],
} }
export { ceilingDefinition } from './ceiling'
export { fenceDefinition } from './fence' export { fenceDefinition } from './fence'
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
export { slabDefinition } from './slab' export { slabDefinition } from './slab'
+7
View File
@@ -2,6 +2,12 @@
// renderers (wall renders doors/windows, slab renders hosted items). // renderers (wall renders doors/windows, slab renders hosted items).
// Public so registry-driven kinds can compose children without reaching // Public so registry-driven kinds can compose children without reaching
// into viewer's internal paths. // into viewer's internal paths.
// Ceiling internals re-exported for the registry-driven ceiling definition.
// The ceiling renderer uses TSL shaders for a grid pattern — too much code
// to duplicate at Stage A. Wrap-exported here, ported into the nodes
// folder in a later stage.
export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer'
export { NodeRenderer } from './components/renderers/node-renderer' export { NodeRenderer } from './components/renderers/node-renderer'
export { default as Viewer } from './components/viewer' export { default as Viewer } from './components/viewer'
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
@@ -31,6 +37,7 @@ export {
} from './lib/materials' } from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node' export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer' export { default as useViewer } from './store/use-viewer'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
// Fence system follows the wall re-export pattern — composed into the // Fence system follows the wall re-export pattern — composed into the
// registry-driven fence definition's `def.system`. Removed in Phase 6 // registry-driven fence definition's `def.system`. Removed in Phase 6
// alongside the legacy fence mount point. // alongside the legacy fence mount point.