Phase 5 batch: door + window migrate to registry (always-on)

Both kinds share traits — hosted on walls, cuttable, animated open/
close state via a geometry system + animation system. Stage A
migration: register + wrap-export the legacy renderer + bundle both
per-kind systems. Pure geometry + floor-plan ports are later
milestones.

Files added (packages/nodes/src/door/, packages/nodes/src/window/):
 - schema.ts: re-export from core.
 - parametrics.ts: minimal — dimensions only. Door has 29 sliders +
   segmented controls + presets in its legacy panel; window has 15+
   sliders. Auto-inspector can't cover them at Stage A — legacy
   panel keeps rendering via panel-manager.tsx case fall-through.
   Stage E may extend parametrics or use parametrics.customPanel
   escape hatch.
 - definition.ts: capabilities (no `movable` — wall-bound drag is
   bespoke; capability-driven dispatch keeps legacy MoveDoorTool /
   MoveWindowTool), parametrics, renderer, system. defaults() uses
   `DoorNode.parse({...stub})` to leverage zod's schema-level
   `.default()` annotations — door has 40+ fields, window has 20+;
   listing them inline duplicates the schema.
 - renderer.tsx: wrap-export of legacy DoorRenderer / WindowRenderer
   (thin 33-36 lines each).
 - system.tsx: bundles each kind's TWO systems — DoorSystem +
   DoorAnimationSystem, WindowSystem + WindowAnimationSystem. Both
   per-kind systems mount via RegisteredSystems when the kind is
   registry-driven; `<LegacySystem kind="door|window">` wrappers
   around each individual system short-circuit.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: new public exports for DoorRenderer,
   DoorSystem, DoorAnimationSystem, WindowRenderer, WindowSystem,
   WindowAnimationSystem.
 - packages/nodes/src/index.ts: appends doorDefinition + windowDefinition.
 - packages/editor/src/components/ui/panels/door-panel.tsx + window-
   panel.tsx: panel slider-drag fix recipe applied. Drop the
   subscribed `updateNode` action, drop the `node` dep from
   handleUpdate / previewDoorUpdate / commitDoorPreview useCallbacks.
   Use useScene.getState() inside. Door panel has 29 SliderControls,
   window 15+ — both at high risk of the Maximum update depth
   cascade without the fix.

Phase 5 progress: shelf , spawn , wall , fence , slab , ceiling ,
door , window . Eight kinds on the registry. Item / stair / roof /
zone / containers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 15:10:17 -04:00
co-authored by Claude Opus 4.7
parent 2dd50fa5be
commit 9eced06f32
16 changed files with 306 additions and 12 deletions
@@ -109,7 +109,6 @@ function isSameDoorValue(current: unknown, next: unknown): boolean {
export function DoorPanel() { export function DoorPanel() {
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 deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{ const previewRef = useRef<{
@@ -124,9 +123,11 @@ export function DoorPanel() {
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
) )
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
// it, the 29+ SliderControls in this panel would loop on drag.
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => { (updates: Partial<DoorNode>) => {
if (!(selectedId && node)) return if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return if (liveNode?.type !== 'door') return
@@ -139,12 +140,12 @@ export function DoorPanel() {
if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) { if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) {
useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId)
} }
updateNode(selectedId as AnyNode['id'], updates) useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
const scene = useScene.getState() const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId) scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
}, },
[selectedId, node, updateNode], [selectedId],
) )
const previewDoorUpdate = useCallback( const previewDoorUpdate = useCallback(
@@ -188,10 +189,10 @@ export function DoorPanel() {
} }
previewRef.current = null previewRef.current = null
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>) useScene.getState().updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId) scene.dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId, updateNode], [selectedId],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -93,7 +93,6 @@ const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
export function WindowPanel() { export function WindowPanel() {
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 deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{ const previewRef = useRef<{
@@ -108,9 +107,11 @@ export function WindowPanel() {
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
) )
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
// it, the 15+ SliderControls in this panel would loop on drag.
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => { (updates: Partial<WindowNode>) => {
if (!(selectedId && node)) return if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return if (liveNode?.type !== 'window') return
@@ -120,12 +121,12 @@ export function WindowPanel() {
}) })
if (!hasChange) return if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates) useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
const scene = useScene.getState() const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId) scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
}, },
[selectedId, node, updateNode], [selectedId],
) )
const previewWindowUpdate = useCallback( const previewWindowUpdate = useCallback(
@@ -169,10 +170,12 @@ export function WindowPanel() {
} }
previewRef.current = null previewRef.current = null
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<WindowNode>) useScene.getState().updateNode(selectedId as AnyNode['id'], {
[key]: value,
} as Partial<WindowNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId) scene.dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId, updateNode], [selectedId],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
+76
View File
@@ -0,0 +1,76 @@
import type { NodeDefinition } from '@pascal-app/core'
import { doorParametrics } from './parametrics'
import { DoorNode } from './schema'
/**
* Door — Phase 5 batch kind. Hosted on walls, cuts holes in them,
* animated open/close state.
*
* Capabilities:
* - **No `movable`**: door's move is bespoke wall-bound drag (slide
* along the wall, snap to wall start/end). Capability-driven dispatch
* keeps legacy `MoveDoorTool`.
* - `selectable`, `duplicable`, `deletable` standard.
*
* Relations:
* - `parentId` references a wall — re-anchors on wall move (handled by
* `DoorSystem`'s cascade to parent wall).
* - `cascadeDelete: 'children'` — door has no children in v1.
*
* Renderer + system: wrap-export legacy `DoorRenderer` + bundle
* `DoorSystem` + `DoorAnimationSystem`.
*
* Tool field absent: door placement / move tools wired through editor
* state, not registry dispatch. Legacy DoorTool / MoveDoorTool continue.
*/
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
schemaVersion: 1,
schema: DoorNode,
category: 'structure',
// Leverage the schema's zod `.default()` annotations to compute the
// full default shape — door has 40+ fields, listing them inline would
// duplicate the schema. Parse a minimal stub, drop id/type, return rest.
defaults: () => {
const stub = DoorNode.parse({ id: 'door_default' as never, type: 'door' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: doorParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
// before wall mitering at 4).
priority: 3,
},
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Door',
description: 'A door cut into a wall. Animated open/close state.',
icon: { kind: 'iconify', name: 'lucide:door-open' },
paletteSection: 'structure',
paletteOrder: 50,
},
mcp: {
description: 'A door mounted on a wall, with type / dimensions / hardware options.',
},
}
+2
View File
@@ -0,0 +1,2 @@
export { doorDefinition } from './definition'
export { DoorNode } from './schema'
+32
View File
@@ -0,0 +1,32 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { DoorNode } from './schema'
/**
* Minimal inspector descriptor for door. The legacy `<DoorPanel>` has
* 29 SliderControls covering segments, hardware, hinges, panic bar,
* opening shape, etc. — too elaborate for the auto-inspector at Stage A.
* Legacy panel keeps rendering via the hardcoded `case 'door':` in
* panel-manager.tsx. This descriptor only exposes the simple dimension
* fields so the registry knows door has parametric data. Phase 5 Stage E
* (drop legacy panel) will extend this — likely via
* `parametrics.customPanel?` since door has too much non-numeric UI
* (segmented controls, presets) to fit the generic auto-UI.
*/
export const doorParametrics: ParametricDescriptor<DoorNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 },
{ key: 'height', kind: 'number', unit: 'm', min: 1.0, max: 4, step: 0.05 },
],
},
{
label: 'Frame',
fields: [
{ key: 'frameThickness', kind: 'number', unit: 'm', min: 0.01, max: 0.2, step: 0.005 },
{ key: 'frameDepth', kind: 'number', unit: 'm', min: 0.01, max: 0.3, step: 0.005 },
],
},
],
}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import { DoorRenderer } from '@pascal-app/viewer'
/**
* Wrap-export of the legacy `DoorRenderer`. The renderer is 33 lines
* (thin placeholder + register + dirty-on-mount) — could be duplicated
* but at Stage A re-export is sufficient. Phase 5 Stage F will inline
* it here and delete the viewer-side file.
*/
export default DoorRenderer
+1
View File
@@ -0,0 +1 @@
export { DoorNode } from '@pascal-app/core'
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer'
/**
* Registry-driven door system bundle. Door has TWO per-frame systems:
*
* - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware
* geometry from `dirtyNodes`. Cascades dirty to the parent wall so
* the wall cutout reflects the new door footprint.
* - **`DoorAnimationSystem`** — advances `operationState` (open/close
* angle for hinged, slide offset for sliding/pocket, fold angle for
* folding) at frame priority 2, then marks the door dirty so the
* geometry system rebuilds at priority 3.
*
* Both are wrapped in `<LegacySystem kind="door">` at the legacy mount
* point; with door registered, those wrappers short-circuit and this
* bundle takes over.
*
* Future Phase 5 Stage B: extract the geometry into a pure
* `buildDoorGeometry(node, ctx)` and migrate to `def.geometry`. The
* animation system stays as `def.system` (it's a real per-frame
* concern, not a geometry build).
*/
const DoorSystems = () => {
return (
<>
<DoorAnimationSystem />
<DoorSystem />
</>
)
}
export default DoorSystems
+6
View File
@@ -1,10 +1,12 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { ceilingDefinition } from './ceiling' import { ceilingDefinition } from './ceiling'
import { doorDefinition } from './door'
import { fenceDefinition } from './fence' import { fenceDefinition } from './fence'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { slabDefinition } from './slab' import { slabDefinition } from './slab'
import { spawnDefinition } from './spawn' import { spawnDefinition } from './spawn'
import { wallDefinition } from './wall' import { wallDefinition } from './wall'
import { windowDefinition } from './window'
/** /**
* Built-in plugin bundling every node kind shipped with the Pascal editor. * Built-in plugin bundling every node kind shipped with the Pascal editor.
@@ -34,12 +36,16 @@ export const builtinPlugin: Plugin = {
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, ceilingDefinition as unknown as AnyNodeDefinition,
doorDefinition as unknown as AnyNodeDefinition,
windowDefinition as unknown as AnyNodeDefinition,
], ],
} }
export { ceilingDefinition } from './ceiling' export { ceilingDefinition } from './ceiling'
export { doorDefinition } from './door'
export { fenceDefinition } from './fence' export { fenceDefinition } from './fence'
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
export { slabDefinition } from './slab' export { slabDefinition } from './slab'
export { spawnDefinition } from './spawn' export { spawnDefinition } from './spawn'
export { wallDefinition } from './wall' export { wallDefinition } from './wall'
export { windowDefinition } from './window'
+59
View File
@@ -0,0 +1,59 @@
import type { NodeDefinition } from '@pascal-app/core'
import { windowParametrics } from './parametrics'
import { WindowNode } from './schema'
/**
* Window — Phase 5 batch kind. Mirrors door's shape: hosted on walls,
* cuts holes in them, animated open/close state for opening windows.
*
* Capabilities: no `movable` (wall-bound drag is bespoke). Tool field
* absent (legacy WindowTool / MoveWindowTool continue).
*/
export const windowDefinition: NodeDefinition<typeof WindowNode> = {
kind: 'window',
schemaVersion: 1,
schema: WindowNode,
category: 'structure',
// Same schema-driven defaults trick as door: parse a stub, strip
// id/type. Window also has many fields with zod `.default()` set.
defaults: () => {
const stub = WindowNode.parse({ id: 'window_default' as never, type: 'window' })
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: windowParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
system: {
module: () => import('./system'),
priority: 3,
},
toolHints: [
{ key: 'Left click', label: 'Place window on wall' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Window',
description: 'A window cut into a wall. Animated open/close for opening windows.',
icon: { kind: 'iconify', name: 'lucide:rectangle-horizontal' },
paletteSection: 'structure',
paletteOrder: 60,
},
mcp: {
description: 'A window mounted on a wall, with type / dimensions / opening options.',
},
}
+2
View File
@@ -0,0 +1,2 @@
export { windowDefinition } from './definition'
export { WindowNode } from './schema'
+21
View File
@@ -0,0 +1,21 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { WindowNode } from './schema'
/**
* Minimal inspector descriptor for window. Like door, the legacy
* `<WindowPanel>` has 15+ SliderControls covering sashes, dividers,
* sill, frame, opening shape — too elaborate for the auto-inspector
* at Stage A. Legacy panel keeps rendering via panel-manager's
* `case 'window':`.
*/
export const windowParametrics: ParametricDescriptor<WindowNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 4, step: 0.05 },
{ key: 'height', kind: 'number', unit: 'm', min: 0.3, max: 4, step: 0.05 },
],
},
],
}
+9
View File
@@ -0,0 +1,9 @@
'use client'
import { WindowRenderer } from '@pascal-app/viewer'
/**
* Wrap-export of the legacy `WindowRenderer`. Thin (~36 lines); Phase 5
* Stage F inlines it here and deletes the viewer-side file.
*/
export default WindowRenderer
+1
View File
@@ -0,0 +1 @@
export { WindowNode } from '@pascal-app/core'
+27
View File
@@ -0,0 +1,27 @@
'use client'
import { WindowAnimationSystem, WindowSystem } from '@pascal-app/viewer'
/**
* Registry-driven window system bundle. Same shape as door — two
* per-frame systems wrapped together:
*
* - **`WindowSystem`** — rebuilds frame / sash / divider / sill /
* muntin geometry. Cascades dirty to parent wall for the cutout.
* - **`WindowAnimationSystem`** — advances sash/panel open state at
* frame priority 2, then marks the window dirty for the geometry
* rebuild at priority 3.
*
* Both wrapped in `<LegacySystem kind="window">` legacy mounts; with
* window registered, those short-circuit and this bundle takes over.
*/
const WindowSystems = () => {
return (
<>
<WindowAnimationSystem />
<WindowSystem />
</>
)
}
export default WindowSystems
+9
View File
@@ -8,7 +8,12 @@
// to duplicate at Stage A. Wrap-exported here, ported into the nodes // to duplicate at Stage A. Wrap-exported here, ported into the nodes
// folder in a later stage. // folder in a later stage.
export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer' export { CeilingRenderer } from './components/renderers/ceiling/ceiling-renderer'
// Door + window internals for registry-driven door / window definitions.
// Each kind has a geometry system + animation system to mount via
// RegisteredSystems when the kind is registry-driven.
export { DoorRenderer } from './components/renderers/door/door-renderer'
export { NodeRenderer } from './components/renderers/node-renderer' export { NodeRenderer } from './components/renderers/node-renderer'
export { WindowRenderer } from './components/renderers/window/window-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'
export { export {
@@ -38,6 +43,8 @@ export {
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' export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorAnimationSystem } from './systems/door/door-animation-system'
export { DoorSystem } from './systems/door/door-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.
@@ -57,3 +64,5 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
// 800+ lines of CSG / mitering logic during Phase 3. These exports are // 800+ lines of CSG / mitering logic during Phase 3. These exports are
// removed in Phase 6 when the legacy mount points are deleted. // removed in Phase 6 when the legacy mount points are deleted.
export { WallSystem } from './systems/wall/wall-system' export { WallSystem } from './systems/wall/wall-system'
export { WindowAnimationSystem } from './systems/window/window-animation-system'
export { WindowSystem } from './systems/window/window-system'