diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index c46838de..599d1e21 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -109,7 +109,6 @@ function isSameDoorValue(current: unknown, next: unknown): boolean { export function DoorPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const previewRef = useRef<{ @@ -124,9 +123,11 @@ export function DoorPanel() { 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( (updates: Partial) => { - if (!(selectedId && node)) return + if (!selectedId) return const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'door') return @@ -139,12 +140,12 @@ export function DoorPanel() { if ('operationState' in updates || 'swingAngle' in updates || 'doorType' in updates) { useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) } - updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) const scene = useScene.getState() scene.dirtyNodes.add(selectedId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, - [selectedId, node, updateNode], + [selectedId], ) const previewDoorUpdate = useCallback( @@ -188,10 +189,10 @@ export function DoorPanel() { } previewRef.current = null - updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + useScene.getState().updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) scene.dirtyNodes.add(selectedId as AnyNodeId) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index b26cf0f0..24b3c5c7 100644 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -93,7 +93,6 @@ const silllessWindowTypes = new Set(['bay', 'bow']) export function WindowPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const previewRef = useRef<{ @@ -108,9 +107,11 @@ export function WindowPanel() { 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( (updates: Partial) => { - if (!(selectedId && node)) return + if (!selectedId) return const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] if (liveNode?.type !== 'window') return @@ -120,12 +121,12 @@ export function WindowPanel() { }) if (!hasChange) return - updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) const scene = useScene.getState() scene.dirtyNodes.add(selectedId as AnyNodeId) if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) }, - [selectedId, node, updateNode], + [selectedId], ) const previewWindowUpdate = useCallback( @@ -169,10 +170,12 @@ export function WindowPanel() { } previewRef.current = null - updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial) + useScene.getState().updateNode(selectedId as AnyNode['id'], { + [key]: value, + } as Partial) scene.dirtyNodes.add(selectedId as AnyNodeId) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts new file mode 100644 index 00000000..e5520243 --- /dev/null +++ b/packages/nodes/src/door/definition.ts @@ -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 = { + 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.', + }, +} diff --git a/packages/nodes/src/door/index.ts b/packages/nodes/src/door/index.ts new file mode 100644 index 00000000..364ea491 --- /dev/null +++ b/packages/nodes/src/door/index.ts @@ -0,0 +1,2 @@ +export { doorDefinition } from './definition' +export { DoorNode } from './schema' diff --git a/packages/nodes/src/door/parametrics.ts b/packages/nodes/src/door/parametrics.ts new file mode 100644 index 00000000..6e7dcdb7 --- /dev/null +++ b/packages/nodes/src/door/parametrics.ts @@ -0,0 +1,32 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { DoorNode } from './schema' + +/** + * Minimal inspector descriptor for door. The legacy `` 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 = { + 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 }, + ], + }, + ], +} diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx new file mode 100644 index 00000000..519fa4c5 --- /dev/null +++ b/packages/nodes/src/door/renderer.tsx @@ -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 diff --git a/packages/nodes/src/door/schema.ts b/packages/nodes/src/door/schema.ts new file mode 100644 index 00000000..1f4fac97 --- /dev/null +++ b/packages/nodes/src/door/schema.ts @@ -0,0 +1 @@ +export { DoorNode } from '@pascal-app/core' diff --git a/packages/nodes/src/door/system.tsx b/packages/nodes/src/door/system.tsx new file mode 100644 index 00000000..c03a1cb0 --- /dev/null +++ b/packages/nodes/src/door/system.tsx @@ -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 `` 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 ( + <> + + + + ) +} + +export default DoorSystems diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index f00a7004..1f9bea25 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,10 +1,12 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { ceilingDefinition } from './ceiling' +import { doorDefinition } from './door' import { fenceDefinition } from './fence' import { shelfDefinition } from './shelf' import { slabDefinition } from './slab' import { spawnDefinition } from './spawn' import { wallDefinition } from './wall' +import { windowDefinition } from './window' /** * 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, slabDefinition as unknown as AnyNodeDefinition, ceilingDefinition as unknown as AnyNodeDefinition, + doorDefinition as unknown as AnyNodeDefinition, + windowDefinition as unknown as AnyNodeDefinition, ], } export { ceilingDefinition } from './ceiling' +export { doorDefinition } from './door' export { fenceDefinition } from './fence' export { shelfDefinition } from './shelf' export { slabDefinition } from './slab' export { spawnDefinition } from './spawn' export { wallDefinition } from './wall' +export { windowDefinition } from './window' diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts new file mode 100644 index 00000000..0ac4eb6d --- /dev/null +++ b/packages/nodes/src/window/definition.ts @@ -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 = { + 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.', + }, +} diff --git a/packages/nodes/src/window/index.ts b/packages/nodes/src/window/index.ts new file mode 100644 index 00000000..e05a9db7 --- /dev/null +++ b/packages/nodes/src/window/index.ts @@ -0,0 +1,2 @@ +export { windowDefinition } from './definition' +export { WindowNode } from './schema' diff --git a/packages/nodes/src/window/parametrics.ts b/packages/nodes/src/window/parametrics.ts new file mode 100644 index 00000000..a671ab57 --- /dev/null +++ b/packages/nodes/src/window/parametrics.ts @@ -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 + * `` 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 = { + 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 }, + ], + }, + ], +} diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx new file mode 100644 index 00000000..9dcb7962 --- /dev/null +++ b/packages/nodes/src/window/renderer.tsx @@ -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 diff --git a/packages/nodes/src/window/schema.ts b/packages/nodes/src/window/schema.ts new file mode 100644 index 00000000..f1f47eaa --- /dev/null +++ b/packages/nodes/src/window/schema.ts @@ -0,0 +1 @@ +export { WindowNode } from '@pascal-app/core' diff --git a/packages/nodes/src/window/system.tsx b/packages/nodes/src/window/system.tsx new file mode 100644 index 00000000..58e488bb --- /dev/null +++ b/packages/nodes/src/window/system.tsx @@ -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 `` legacy mounts; with + * window registered, those short-circuit and this bundle takes over. + */ +const WindowSystems = () => { + return ( + <> + + + + ) +} + +export default WindowSystems diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 7ea2af6f..72f24ba8 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -8,7 +8,12 @@ // 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' +// 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 { WindowRenderer } from './components/renderers/window/window-renderer' export { default as Viewer } from './components/viewer' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { @@ -38,6 +43,8 @@ export { export { mergedOutline } from './lib/merged-outline-node' export { default as useViewer } from './store/use-viewer' 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 // registry-driven fence definition's `def.system`. Removed in Phase 6 // 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 // removed in Phase 6 when the legacy mount points are deleted. export { WallSystem } from './systems/wall/wall-system' +export { WindowAnimationSystem } from './systems/window/window-animation-system' +export { WindowSystem } from './systems/window/window-system'