@pascal-app/editor 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -209,6 +209,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
209
209
  return baseSweepAngle
210
210
  }
211
211
 
212
+ function clampFloorplanCircularSweepAngle(sweepAngle: number) {
213
+ if (Math.abs(sweepAngle) >= Math.PI * 2) {
214
+ return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001)
215
+ }
216
+
217
+ return sweepAngle
218
+ }
219
+
212
220
  function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
213
221
  if (
214
222
  (stair.stairType ?? 'straight') !== 'spiral' ||
@@ -230,8 +238,11 @@ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
230
238
  function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
231
239
  const stairType = stair.stairType ?? 'straight'
232
240
  const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
241
+ const visualSweepAngle = clampFloorplanCircularSweepAngle(
242
+ sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle),
243
+ )
233
244
  const startAngle = -stair.rotation - sweepAngle / 2
234
- const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle)
245
+ const endAngle = startAngle + visualSweepAngle
235
246
  const center = {
236
247
  x: stair.position[0],
237
248
  y: stair.position[2],
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import { useScene } from '@pascal-app/core'
4
+ import { commitFreshPlacementSubtree } from './fresh-planar-placement'
5
+
6
+ type RafFn = (cb: (time: number) => void) => number
7
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
8
+ cb: (time: number) => void,
9
+ ) => {
10
+ cb(0)
11
+ return 0
12
+ }) as RafFn
13
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
14
+
15
+ const LEVEL_ID = 'level_test' as AnyNodeId
16
+ const SHELF_ID = 'shelf_draft' as AnyNodeId
17
+
18
+ function level(children: AnyNodeId[]): AnyNode {
19
+ return {
20
+ id: LEVEL_ID,
21
+ type: 'level',
22
+ object: 'node',
23
+ parentId: null,
24
+ visible: true,
25
+ metadata: {},
26
+ children,
27
+ level: 0,
28
+ } as AnyNode
29
+ }
30
+
31
+ function shelf(): AnyNode {
32
+ return {
33
+ id: SHELF_ID,
34
+ type: 'shelf',
35
+ object: 'node',
36
+ parentId: LEVEL_ID,
37
+ visible: false,
38
+ metadata: { isNew: true, label: 'draft' },
39
+ children: [],
40
+ position: [0, 0, 0],
41
+ rotation: [0, 0, 0],
42
+ width: 1.2,
43
+ depth: 0.3,
44
+ thickness: 0.04,
45
+ height: 0.9,
46
+ style: 'wall-shelf',
47
+ rows: 1,
48
+ columns: 1,
49
+ withBack: false,
50
+ withSides: true,
51
+ withBottom: false,
52
+ bracketStyle: 'minimal',
53
+ } as AnyNode
54
+ }
55
+
56
+ describe('commitFreshPlacementSubtree', () => {
57
+ beforeEach(() => {
58
+ useScene.setState({
59
+ nodes: {
60
+ [LEVEL_ID]: level([SHELF_ID]),
61
+ [SHELF_ID]: shelf(),
62
+ },
63
+ rootNodeIds: [LEVEL_ID],
64
+ collections: {},
65
+ dirtyNodes: new Set(),
66
+ } as never)
67
+ useScene.temporal.getState().clear()
68
+ useScene.temporal.getState().resume()
69
+ })
70
+
71
+ test('commits a fresh draft as one undoable clean subtree', () => {
72
+ useScene.temporal.getState().pause()
73
+
74
+ const committedId = commitFreshPlacementSubtree(SHELF_ID, {
75
+ position: [2, 0, 3],
76
+ visible: true,
77
+ } as Partial<AnyNode>)
78
+
79
+ expect(committedId).toBeTruthy()
80
+ expect(committedId).not.toBe(SHELF_ID)
81
+ const finalId = committedId as AnyNodeId
82
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
83
+
84
+ const committed = useScene.getState().nodes[finalId] as
85
+ | (AnyNode & { position: [number, number, number]; metadata?: Record<string, unknown> })
86
+ | undefined
87
+ expect(committed?.position).toEqual([2, 0, 3])
88
+ expect(committed?.visible).toBe(true)
89
+ expect(committed?.metadata?.isNew).toBeUndefined()
90
+ expect(committed?.metadata?.label).toBe('draft')
91
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
92
+ finalId,
93
+ ])
94
+
95
+ useScene.temporal.getState().resume()
96
+ useScene.temporal.getState().undo()
97
+
98
+ expect(useScene.getState().nodes[finalId]).toBeUndefined()
99
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
100
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
101
+ })
102
+ })
@@ -0,0 +1,63 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ cloneNodesInto,
5
+ collectSubtree,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+ import { stripPlacementMetadataFlags } from './placement-metadata'
9
+
10
+ function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
11
+ return {
12
+ ...node,
13
+ metadata: stripPlacementMetadataFlags(node.metadata),
14
+ } as N
15
+ }
16
+
17
+ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
18
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
19
+ return parentId ?? undefined
20
+ }
21
+
22
+ /**
23
+ * Finalises a fresh catalog/duplicate draft as a single undoable creation.
24
+ *
25
+ * Fresh drafts already exist in the scene so renderers and move tools can
26
+ * preview real geometry. On commit we delete that draft while history is
27
+ * paused, then create a clean clone at the final cursor position with history
28
+ * resumed. Undo therefore removes the placed node instead of resurrecting the
29
+ * hidden draft at its origin.
30
+ */
31
+ export function commitFreshPlacementSubtree(
32
+ rootId: AnyNodeId,
33
+ rootPatch: Partial<AnyNode>,
34
+ ): AnyNodeId | null {
35
+ const scene = useScene.getState()
36
+ const subtree = collectSubtree(scene.nodes, rootId)
37
+ if (!subtree) return null
38
+
39
+ const root = cleanPlacementMetadata({
40
+ ...subtree.root,
41
+ ...rootPatch,
42
+ } as AnyNode)
43
+ const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
44
+ const parentId = parentIdOf(root)
45
+ const cloned = cloneNodesInto([root, ...descendants], {
46
+ rootId,
47
+ parentId,
48
+ })
49
+
50
+ const temporal = useScene.temporal.getState()
51
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
52
+ if (wasTracking) temporal.pause()
53
+ useScene.getState().deleteNode(rootId)
54
+ temporal.resume()
55
+ useScene
56
+ .getState()
57
+ .createNodes(
58
+ cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
59
+ )
60
+ if (!wasTracking) temporal.pause()
61
+
62
+ return cloned.rootId
63
+ }
@@ -1,6 +1,7 @@
1
- import { useLiveTransforms, useScene } from '@pascal-app/core'
1
+ import { useLiveNodeOverrides, useLiveTransforms, useScene } from '@pascal-app/core'
2
2
 
3
3
  function refreshSceneAfterHistoryJump() {
4
+ useLiveNodeOverrides.getState().clearAll()
4
5
  useLiveTransforms.getState().clearAll()
5
6
 
6
7
  const state = useScene.getState()
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
33
33
  return true
34
34
  }
35
35
 
36
+ /**
37
+ * Material field keys per kind, used by the `structure` duplicate preset
38
+ * to strip materials from the cloned subtree. Lookup table replaces the
39
+ * legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
40
+ * in this file as the remaining per-kind dispatch outside the registry.
41
+ *
42
+ * Future: move this to a `capabilities.materialFields` declaration on
43
+ * each kind's `NodeDefinition` so adding a new kind with materials is a
44
+ * registry-only edit. Today the registry doesn't surface material fields
45
+ * in a uniform way (each kind's panel reads / writes them directly), so
46
+ * this map mirrors the legacy behavior 1:1.
47
+ */
48
+ const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
49
+ wall: [
50
+ 'material',
51
+ 'materialPreset',
52
+ 'interiorMaterial',
53
+ 'interiorMaterialPreset',
54
+ 'exteriorMaterial',
55
+ 'exteriorMaterialPreset',
56
+ ],
57
+ slab: ['material', 'materialPreset'],
58
+ ceiling: ['material', 'materialPreset'],
59
+ fence: ['material', 'materialPreset'],
60
+ shelf: ['material', 'materialPreset'],
61
+ 'roof-segment': ['material', 'materialPreset'],
62
+ 'stair-segment': ['material', 'materialPreset'],
63
+ window: ['material', 'materialPreset'],
64
+ door: ['material', 'materialPreset'],
65
+ roof: [
66
+ 'material',
67
+ 'materialPreset',
68
+ 'topMaterial',
69
+ 'topMaterialPreset',
70
+ 'edgeMaterial',
71
+ 'edgeMaterialPreset',
72
+ 'wallMaterial',
73
+ 'wallMaterialPreset',
74
+ ],
75
+ stair: [
76
+ 'material',
77
+ 'materialPreset',
78
+ 'railingMaterial',
79
+ 'railingMaterialPreset',
80
+ 'treadMaterial',
81
+ 'treadMaterialPreset',
82
+ 'sideMaterial',
83
+ 'sideMaterialPreset',
84
+ ],
85
+ }
86
+
36
87
  function stripMaterials(node: AnyNode): AnyNode {
88
+ const fields = MATERIAL_FIELDS_BY_KIND[node.type]
89
+ if (!fields) return node
37
90
  const next = { ...node } as Record<string, unknown>
38
-
39
- switch (node.type) {
40
- case 'wall':
41
- delete next.material
42
- delete next.materialPreset
43
- delete next.interiorMaterial
44
- delete next.interiorMaterialPreset
45
- delete next.exteriorMaterial
46
- delete next.exteriorMaterialPreset
47
- break
48
- case 'slab':
49
- case 'ceiling':
50
- case 'fence':
51
- case 'roof-segment':
52
- case 'stair-segment':
53
- case 'window':
54
- case 'door':
55
- delete next.material
56
- delete next.materialPreset
57
- break
58
- case 'roof':
59
- delete next.material
60
- delete next.materialPreset
61
- delete next.topMaterial
62
- delete next.topMaterialPreset
63
- delete next.edgeMaterial
64
- delete next.edgeMaterialPreset
65
- delete next.wallMaterial
66
- delete next.wallMaterialPreset
67
- break
68
- case 'stair':
69
- delete next.material
70
- delete next.materialPreset
71
- delete next.railingMaterial
72
- delete next.railingMaterialPreset
73
- delete next.treadMaterial
74
- delete next.treadMaterialPreset
75
- delete next.sideMaterial
76
- delete next.sideMaterialPreset
77
- break
78
- }
79
-
91
+ for (const field of fields) delete next[field]
80
92
  return next as AnyNode
81
93
  }
82
94
 
@@ -0,0 +1,11 @@
1
+ import type { LevelNode } from '@pascal-app/core'
2
+
3
+ export function getDefaultLevelName(level: number): string {
4
+ if (level === 0) return 'Ground Floor'
5
+ if (level > 0) return `Floor ${level}`
6
+ return `Basement ${-level}`
7
+ }
8
+
9
+ export function getLevelDisplayName(level: Pick<LevelNode, 'name' | 'level'>): string {
10
+ return level.name || getDefaultLevelName(level.level)
11
+ }
@@ -1,18 +1,28 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNode,
5
+ type AnyNodeId,
4
6
  type CeilingNode,
7
+ type ChimneyMaterialRole,
8
+ type ChimneyNode,
5
9
  type ColumnNode,
10
+ type DormerSurfaceMaterialRole,
6
11
  type FenceNode,
7
12
  getCatalogMaterialById,
8
13
  getEffectiveRoofSurfaceMaterial,
14
+ getEffectiveSegmentSurfaceMaterial,
9
15
  getEffectiveStairSurfaceMaterial,
10
16
  getEffectiveWallSurfaceMaterial,
11
17
  getLibraryMaterialIdFromRef,
12
18
  type MaterialSchema,
13
19
  type MaterialTarget,
20
+ nodeRegistry,
14
21
  type RoofNode,
22
+ type RoofSegmentNode,
23
+ type RoofSegmentSurfaceMaterialRole,
15
24
  type RoofSurfaceMaterialRole,
25
+ type ShelfNode,
16
26
  type SlabNode,
17
27
  type StairNode,
18
28
  type StairSurfaceMaterialRole,
@@ -22,7 +32,21 @@ import {
22
32
 
23
33
  export type PaintableMaterialTarget = Extract<
24
34
  MaterialTarget,
25
- 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
35
+ | 'wall'
36
+ | 'roof'
37
+ | 'stair'
38
+ | 'fence'
39
+ | 'column'
40
+ | 'slab'
41
+ | 'ceiling'
42
+ | 'shelf'
43
+ | 'chimney'
44
+ | 'dormer'
45
+ | 'box-vent'
46
+ | 'ridge-vent'
47
+ | 'turbine-vent'
48
+ | 'cupola'
49
+ | 'eyebrow-vent'
26
50
  >
27
51
 
28
52
  export type SingleSurfaceMaterialRole = 'surface'
@@ -104,6 +128,82 @@ export function buildRoofSurfaceMaterialPatch(
104
128
  }
105
129
  }
106
130
 
131
+ /**
132
+ * Build a per-segment paint patch for one of the three surface roles. The
133
+ * segment ends up with role-specific fields set (and the legacy catch-all
134
+ * `material` cleared) so subsequent reads pick the role override over any
135
+ * parent-roof fallback.
136
+ */
137
+ export function buildRoofSegmentSurfaceMaterialPatch(
138
+ node: RoofSegmentNode,
139
+ targetRole: RoofSegmentSurfaceMaterialRole,
140
+ material: MaterialSchema | undefined,
141
+ materialPreset: string | undefined,
142
+ ): Partial<RoofSegmentNode> {
143
+ const nextSurfaceMaterial = { material, materialPreset }
144
+ const nextTop =
145
+ targetRole === 'top' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'top')
146
+ const nextEdge =
147
+ targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'edge')
148
+ const nextWall =
149
+ targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'wall')
150
+
151
+ return {
152
+ topMaterial: nextTop.material,
153
+ topMaterialPreset: nextTop.materialPreset,
154
+ edgeMaterial: nextEdge.material,
155
+ edgeMaterialPreset: nextEdge.materialPreset,
156
+ wallMaterial: nextWall.material,
157
+ wallMaterialPreset: nextWall.materialPreset,
158
+ material: undefined,
159
+ materialPreset: undefined,
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Clear every painted material on a node back to its default. Works for any
165
+ * kind without per-type knowledge: it nulls the catch-all `material` /
166
+ * `materialPreset` plus any role field (`*Material` / `*MaterialPreset`) that
167
+ * the node actually carries. For a roof it also resets every child segment, so
168
+ * a single call defaults the whole roof system. `updateNode` merges patches
169
+ * shallowly without re-validation, so the `undefined` values land as cleared
170
+ * fields and the renderer falls back to the theme defaults.
171
+ */
172
+ export function buildResetSurfaceMaterialUpdates(
173
+ nodes: Record<string, AnyNode>,
174
+ node: AnyNode,
175
+ ): { id: AnyNodeId; data: Partial<AnyNode> }[] {
176
+ const clearPatch = (target: AnyNode): Partial<AnyNode> => {
177
+ const patch: Record<string, undefined> = {}
178
+ for (const key of Object.keys(target)) {
179
+ if (
180
+ key === 'material' ||
181
+ key === 'materialPreset' ||
182
+ key.endsWith('Material') ||
183
+ key.endsWith('MaterialPreset')
184
+ ) {
185
+ patch[key] = undefined
186
+ }
187
+ }
188
+ return patch as Partial<AnyNode>
189
+ }
190
+
191
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
192
+ { id: node.id as AnyNodeId, data: clearPatch(node) },
193
+ ]
194
+
195
+ if (node.type === 'roof') {
196
+ for (const segmentId of (node as RoofNode).children ?? []) {
197
+ const segment = nodes[segmentId as AnyNodeId]
198
+ if (segment?.type === 'roof-segment') {
199
+ updates.push({ id: segment.id as AnyNodeId, data: clearPatch(segment) })
200
+ }
201
+ }
202
+ }
203
+
204
+ return updates
205
+ }
206
+
107
207
  export function buildStairSurfaceMaterialPatch(
108
208
  node: StairNode,
109
209
  targetRole: StairSurfaceMaterialRole,
@@ -133,7 +233,7 @@ export function buildStairSurfaceMaterialPatch(
133
233
  }
134
234
 
135
235
  export function buildSingleSurfaceMaterialPatch<
136
- TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
236
+ TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
137
237
  >(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
138
238
  return {
139
239
  material,
@@ -141,6 +241,29 @@ export function buildSingleSurfaceMaterialPatch<
141
241
  } as Partial<TNode>
142
242
  }
143
243
 
244
+ // Chimney / dormer patch builders moved to
245
+ // `@pascal-app/nodes/<kind>/paint.ts` and are wired into the kind's
246
+ // `capabilities.paint.buildPatch`. The selection-manager invokes them
247
+ // through the registry; no editor-side helper needed here.
248
+ //
249
+ // `getEffectiveChimneyMaterial` below stays because
250
+ // `resolveActivePaintMaterialFromSelection` (also in this file) still
251
+ // has wall / roof / stair arms that follow the same shape — they all
252
+ // migrate together in a follow-up.
253
+
254
+ export function getEffectiveChimneyMaterial(
255
+ node: ChimneyNode,
256
+ role: ChimneyMaterialRole,
257
+ ): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
258
+ if (role === 'top') {
259
+ const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
260
+ if (hasTop) {
261
+ return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
262
+ }
263
+ }
264
+ return { material: node.material, materialPreset: node.materialPreset }
265
+ }
266
+
144
267
  export function resolveActivePaintMaterialFromSelection(params: {
145
268
  nodes: Record<string, any>
146
269
  selectedId: string | null
@@ -150,6 +273,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
150
273
  | WallSurfaceSide
151
274
  | StairSurfaceMaterialRole
152
275
  | RoofSurfaceMaterialRole
276
+ | ChimneyMaterialRole
277
+ | DormerSurfaceMaterialRole
153
278
  | SingleSurfaceMaterialRole
154
279
  } | null
155
280
  }): ActivePaintMaterial | null {
@@ -160,22 +285,31 @@ export function resolveActivePaintMaterialFromSelection(params: {
160
285
  const selectedNode = nodes[selectedId]
161
286
  if (!selectedNode) return null
162
287
 
163
- if (
164
- selectedNode.type === 'wall' &&
165
- (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
166
- ) {
167
- const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
168
- return hasActivePaintMaterial({
169
- material: surface.material,
170
- materialPreset: surface.materialPreset,
171
- sourceTarget: 'wall',
288
+ // Registry-driven path. Kinds that declare
289
+ // `capabilities.paint.getEffectiveMaterial` resolve their effective
290
+ // material here without an editor-side per-kind arm. Wall,
291
+ // chimney, dormer use this; roof / stair stay legacy below.
292
+ const paintCap = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
293
+ if (paintCap?.getEffectiveMaterial) {
294
+ const surface = paintCap.getEffectiveMaterial({
295
+ node: selectedNode,
296
+ role: selectedMaterialTarget.role as string,
297
+ nodes,
172
298
  })
173
- ? {
174
- material: surface.material,
175
- materialPreset: surface.materialPreset,
176
- sourceTarget: 'wall',
177
- }
178
- : null
299
+ if (surface) {
300
+ const sourceTarget = selectedNode.type as PaintableMaterialTarget
301
+ return hasActivePaintMaterial({
302
+ material: surface.material,
303
+ materialPreset: surface.materialPreset,
304
+ sourceTarget,
305
+ })
306
+ ? {
307
+ material: surface.material,
308
+ materialPreset: surface.materialPreset,
309
+ sourceTarget,
310
+ }
311
+ : null
312
+ }
179
313
  }
180
314
 
181
315
  if (
@@ -184,7 +318,27 @@ export function resolveActivePaintMaterialFromSelection(params: {
184
318
  selectedMaterialTarget.role === 'edge' ||
185
319
  selectedMaterialTarget.role === 'wall')
186
320
  ) {
187
- const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
321
+ let surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
322
+ if (
323
+ selectedMaterialTarget.role === 'top' &&
324
+ surface.material === undefined &&
325
+ surface.materialPreset === undefined
326
+ ) {
327
+ const roofNode = selectedNode as RoofNode
328
+ const fallbackSegment = (roofNode.children ?? [])
329
+ .map((id: AnyNodeId) => nodes[id as AnyNodeId] as RoofSegmentNode | undefined)
330
+ .find(
331
+ (segment: RoofSegmentNode | undefined) =>
332
+ segment?.type === 'roof-segment' &&
333
+ (segment.material !== undefined || segment.materialPreset !== undefined),
334
+ )
335
+ if (fallbackSegment) {
336
+ surface = {
337
+ material: fallbackSegment.material,
338
+ materialPreset: fallbackSegment.materialPreset,
339
+ }
340
+ }
341
+ }
188
342
  return hasActivePaintMaterial({
189
343
  material: surface.material,
190
344
  materialPreset: surface.materialPreset,
@@ -218,13 +372,19 @@ export function resolveActivePaintMaterialFromSelection(params: {
218
372
  : null
219
373
  }
220
374
 
375
+ // Wall / chimney / dormer flow through the registry-driven path
376
+ // at the top of this function.
377
+
221
378
  if (
222
379
  (selectedNode.type === 'fence' ||
223
380
  selectedNode.type === 'column' ||
224
381
  selectedNode.type === 'slab' ||
225
- selectedNode.type === 'ceiling') &&
382
+ selectedNode.type === 'ceiling' ||
383
+ selectedNode.type === 'shelf') &&
226
384
  selectedMaterialTarget.role === 'surface'
227
385
  ) {
386
+ // Roof vents previously lived here too; they now resolve via the
387
+ // registry-driven `getEffectiveMaterial` path at the top of this function.
228
388
  const target = selectedNode.type
229
389
  return hasActivePaintMaterial({
230
390
  material: selectedNode.material,
@@ -280,5 +440,37 @@ export function resolvePaintTargetFromSelection(params: {
280
440
  return 'ceiling'
281
441
  }
282
442
 
443
+ if (selectedNode.type === 'shelf') {
444
+ return 'shelf'
445
+ }
446
+
447
+ if (selectedNode.type === 'chimney') {
448
+ return 'chimney'
449
+ }
450
+
451
+ if (selectedNode.type === 'dormer') {
452
+ return 'dormer'
453
+ }
454
+
455
+ if (selectedNode.type === 'box-vent') {
456
+ return 'box-vent'
457
+ }
458
+
459
+ if (selectedNode.type === 'ridge-vent') {
460
+ return 'ridge-vent'
461
+ }
462
+
463
+ if (selectedNode.type === 'turbine-vent') {
464
+ return 'turbine-vent'
465
+ }
466
+
467
+ if (selectedNode.type === 'cupola') {
468
+ return 'cupola'
469
+ }
470
+
471
+ if (selectedNode.type === 'eyebrow-vent') {
472
+ return 'eyebrow-vent'
473
+ }
474
+
283
475
  return null
284
476
  }
@@ -0,0 +1,29 @@
1
+ export function getPlacementMetadataRecord(metadata: unknown): Record<string, unknown> {
2
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
3
+ return {}
4
+ }
5
+
6
+ return metadata as Record<string, unknown>
7
+ }
8
+
9
+ export function addFreshPlacementMetadata(metadata: unknown): Record<string, unknown> {
10
+ return {
11
+ ...getPlacementMetadataRecord(metadata),
12
+ isNew: true,
13
+ }
14
+ }
15
+
16
+ export function isFreshPlacementMetadata(metadata: unknown): boolean {
17
+ return getPlacementMetadataRecord(metadata).isNew === true
18
+ }
19
+
20
+ export function stripPlacementMetadataFlags(metadata: unknown): unknown {
21
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
22
+ return metadata
23
+ }
24
+
25
+ const nextMeta = { ...(metadata as Record<string, unknown>) }
26
+ delete nextMeta.isNew
27
+ delete nextMeta.isTransient
28
+ return nextMeta
29
+ }