@pascal-app/editor 0.7.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 (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  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 +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  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 +5837 -12006
  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 +234 -190
  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 +435 -158
  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 +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  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-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -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,32 +273,43 @@ 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 {
156
281
  const { nodes, selectedId, selectedMaterialTarget } = params
157
- if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
282
+ if (!(selectedId && selectedMaterialTarget) || selectedMaterialTarget.nodeId !== selectedId)
158
283
  return null
159
284
 
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
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolvePlanarCursorPosition } from './planar-cursor-placement'
3
+
4
+ const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
5
+
6
+ describe('resolvePlanarCursorPosition', () => {
7
+ test('absolute mode places the point directly at the snapped cursor', () => {
8
+ const result = resolvePlanarCursorPosition({
9
+ cursor: [1.24, -2.26],
10
+ original: [10, 10],
11
+ anchor: null,
12
+ mode: 'absolute',
13
+ snap: snapHalf,
14
+ })
15
+
16
+ expect(result.point).toEqual([1, -2.5])
17
+ expect(result.anchor).toBeNull()
18
+ })
19
+
20
+ test('relative mode preserves the original grab offset from the first cursor sample', () => {
21
+ const start = resolvePlanarCursorPosition({
22
+ cursor: [4.1, 6.1],
23
+ original: [10, 20],
24
+ anchor: null,
25
+ mode: 'relative',
26
+ snap: snapHalf,
27
+ })
28
+
29
+ expect(start.point).toEqual([10, 20])
30
+ expect(start.anchor).toEqual([4.1, 6.1])
31
+
32
+ const moved = resolvePlanarCursorPosition({
33
+ cursor: [4.9, 5.2],
34
+ original: [10, 20],
35
+ anchor: start.anchor,
36
+ mode: 'relative',
37
+ snap: snapHalf,
38
+ })
39
+
40
+ expect(moved.point).toEqual([11, 19])
41
+ expect(moved.anchor).toEqual([4.1, 6.1])
42
+ })
43
+ })
@@ -0,0 +1,42 @@
1
+ export type PlanarPoint = [number, number]
2
+
3
+ export type PlanarCursorPlacementMode = 'absolute' | 'relative'
4
+
5
+ type ResolvePlanarCursorPositionArgs = {
6
+ cursor: PlanarPoint
7
+ original: PlanarPoint
8
+ anchor: PlanarPoint | null
9
+ mode: PlanarCursorPlacementMode
10
+ snap?: (value: number) => number
11
+ }
12
+
13
+ type ResolvePlanarCursorPositionResult = {
14
+ point: PlanarPoint
15
+ anchor: PlanarPoint | null
16
+ }
17
+
18
+ const identity = (value: number) => value
19
+
20
+ export function resolvePlanarCursorPosition({
21
+ cursor,
22
+ original,
23
+ anchor,
24
+ mode,
25
+ snap = identity,
26
+ }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
27
+ if (mode === 'absolute') {
28
+ return {
29
+ point: [snap(cursor[0]), snap(cursor[1])],
30
+ anchor,
31
+ }
32
+ }
33
+
34
+ const resolvedAnchor = anchor ?? cursor
35
+ return {
36
+ point: [
37
+ original[0] + snap(cursor[0] - resolvedAnchor[0]),
38
+ original[1] + snap(cursor[1] - resolvedAnchor[1]),
39
+ ],
40
+ anchor: resolvedAnchor,
41
+ }
42
+ }
@@ -134,7 +134,7 @@ export function duplicateRoofSubtree(
134
134
  createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
135
135
  ? (createdParent.children as AnyNodeId[])
136
136
  : null
137
- if (!createdParent || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
137
+ if (!(createdParent && parentChildIds?.includes(createdRoof.id as AnyNodeId))) {
138
138
  throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
139
139
  }
140
140
 
@@ -175,7 +175,7 @@ export function duplicateRoofSubtree(
175
175
 
176
176
  export function clearRoofDuplicateMetadata(
177
177
  roofId: AnyNodeId,
178
- updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
178
+ updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata' | 'visible'>> = {},
179
179
  ) {
180
180
  const scene = useScene.getState()
181
181
  const roofNode = scene.nodes[roofId]
@@ -33,7 +33,7 @@ function extendPoint(
33
33
  z: unknown,
34
34
  ): void {
35
35
  if (typeof x !== 'number' || typeof z !== 'number') return
36
- if (!Number.isFinite(x) || !Number.isFinite(z)) return
36
+ if (!(Number.isFinite(x) && Number.isFinite(z))) return
37
37
  if (x < acc.minX) acc.minX = x
38
38
  if (x > acc.maxX) acc.maxX = x
39
39
  if (z < acc.minZ) acc.minZ = z