@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -82,7 +82,7 @@ export function getItemFloorplanTransform(
82
82
  )
83
83
  const wallLocalZ =
84
84
  item.asset.attachTo === 'wall-side'
85
- ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
85
+ ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'front' ? 1 : -1)
86
86
  : item.position[2]
87
87
  const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
88
88
 
@@ -164,7 +164,10 @@ type Point = {
164
164
 
165
165
  function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
166
166
  const [width, , depth] = getScaledDimensions(item)
167
- const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
167
+ // Wall-side items extend depth-ward away from the wall (into the room); push
168
+ // the footprint centre a half-depth out along local +Z. A negative offset
169
+ // would lay the box across the wall onto the far side (mirrored from 3D).
170
+ const centerLocalZ = item.asset.attachTo === 'wall-side' ? depth / 2 : 0
168
171
  const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
169
172
 
170
173
  return getRotatedRectanglePolygon(
@@ -0,0 +1,21 @@
1
+ import type { FloorplanAffordancePoint } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Convert client (screen) coordinates into floor-plan plan coordinates via
5
+ * the mounted floor-plan scene `<g>`'s screen CTM. The scene `<g>` maps plan
6
+ * X/Z directly to SVG x/y (Z stored as the Y axis on screen — same convention
7
+ * as `toSvgPlanPoint`), so the returned point is in the same level-frame
8
+ * meters node placements use. Null when no floor plan is mounted.
9
+ */
10
+ export function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
11
+ const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
12
+ const svg = target?.ownerSVGElement
13
+ if (!(svg && target)) return null
14
+ const ctm = target.getScreenCTM()
15
+ if (!ctm) return null
16
+ const point = svg.createSVGPoint()
17
+ point.x = clientX
18
+ point.y = clientY
19
+ const transformed = point.matrixTransform(ctm.inverse())
20
+ return [transformed.x, transformed.y]
21
+ }
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  CeilingNode,
3
+ ColumnNode,
3
4
  DoorNode,
5
+ ElevatorNode,
4
6
  ItemNode,
5
7
  Point2D,
6
8
  RoofNode,
@@ -53,6 +55,16 @@ type CeilingEntry = {
53
55
  holes: Point2D[][]
54
56
  }
55
57
 
58
+ type ColumnEntry = {
59
+ column: ColumnNode
60
+ polygon: Point2D[]
61
+ }
62
+
63
+ type ElevatorEntry = {
64
+ elevator: ElevatorNode
65
+ polygon: Point2D[]
66
+ }
67
+
56
68
  type RoofEntry = {
57
69
  roof: RoofNode
58
70
  segments: Array<{
@@ -71,6 +83,8 @@ type FloorplanSelectionToolContext = {
71
83
  walls: WallEntry[]
72
84
  slabs: SlabEntry[]
73
85
  ceilings: CeilingEntry[]
86
+ columns: ColumnEntry[]
87
+ elevators: ElevatorEntry[]
74
88
  roofs: RoofEntry[]
75
89
  openingHitTolerance: number
76
90
  wallHitTolerance: number
@@ -123,6 +137,20 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
123
137
  return stairHit.stair.id
124
138
  }
125
139
 
140
+ const elevatorHit = context.elevators.find(({ polygon }) =>
141
+ isPointInsidePolygon(context.point, polygon),
142
+ )
143
+ if (elevatorHit) {
144
+ return elevatorHit.elevator.id
145
+ }
146
+
147
+ const columnHit = context.columns.find(({ polygon }) =>
148
+ isPointInsidePolygon(context.point, polygon),
149
+ )
150
+ if (columnHit) {
151
+ return columnHit.column.id
152
+ }
153
+
126
154
  const wallHit = context.walls.find(
127
155
  ({ wall, polygon }) =>
128
156
  isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +194,8 @@ type FloorplanSelectionBoundsContext = {
166
194
  openings: OpeningPolygonEntry[]
167
195
  slabs: SlabEntry[]
168
196
  ceilings: CeilingEntry[]
197
+ columns: ColumnEntry[]
198
+ elevators: ElevatorEntry[]
169
199
  stairs: StairEntry[]
170
200
  roofs: RoofEntry[]
171
201
  }
@@ -179,6 +209,8 @@ export function getFloorplanSelectionIdsInBounds({
179
209
  openings,
180
210
  slabs,
181
211
  ceilings,
212
+ columns,
213
+ elevators,
182
214
  stairs,
183
215
  roofs,
184
216
  }: FloorplanSelectionBoundsContext) {
@@ -204,6 +236,12 @@ export function getFloorplanSelectionIdsInBounds({
204
236
  const ceilingIds = ceilings
205
237
  .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
206
238
  .map(({ ceiling }) => ceiling.id)
239
+ const columnIds = columns
240
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
241
+ .map(({ column }) => column.id)
242
+ const elevatorIds = elevators
243
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
244
+ .map(({ elevator }) => elevator.id)
207
245
  const stairIds = stairs
208
246
  .filter((stair) =>
209
247
  getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +262,8 @@ export function getFloorplanSelectionIdsInBounds({
224
262
  ...openingIds,
225
263
  ...slabIds,
226
264
  ...ceilingIds,
265
+ ...columnIds,
266
+ ...elevatorIds,
227
267
  ...stairIds,
228
268
  ...roofIds,
229
269
  ]),
@@ -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,339 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ CabinetModuleNode,
7
+ CabinetNode,
8
+ findLevelAncestorId,
9
+ nodeRegistry,
10
+ registerNode,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { z } from 'zod'
14
+ import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
15
+
16
+ type RafFn = (cb: (time: number) => void) => number
17
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
18
+ cb: (time: number) => void,
19
+ ) => {
20
+ cb(0)
21
+ return 0
22
+ }) as RafFn
23
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
24
+
25
+ const LEVEL_ID = 'level_test' as AnyNodeId
26
+ const SHELF_ID = 'shelf_draft' as AnyNodeId
27
+ const CABINET_RUN_ID = 'cabinet_original-run' as AnyNodeId
28
+ const CABINET_LEFT_ID = 'cabinet-module_original-left' as AnyNodeId
29
+ const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId
30
+ const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId
31
+ const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId
32
+
33
+ function registerCabinetClonePrepTestKind() {
34
+ if (nodeRegistry.has('cabinet')) return
35
+
36
+ registerNode({
37
+ kind: 'cabinet',
38
+ schemaVersion: 1,
39
+ schema: z.any() as never,
40
+ category: 'furnish',
41
+ defaults: () => ({}),
42
+ capabilities: {
43
+ duplicable: {
44
+ subtree: true,
45
+ prepareSubtreeClone: ({ root, descendants, rootId, nodes }) => {
46
+ const parent = root.parentId ? nodes[root.parentId as AnyNodeId] : null
47
+ if (root.type !== 'cabinet' || parent?.type !== 'cabinet') {
48
+ return { root, descendants }
49
+ }
50
+ const parentPosition = (parent as { position: [number, number, number] }).position
51
+ const parentRotation = (parent as { rotation: number }).rotation
52
+ const childPosition = (root as { position: [number, number, number] }).position
53
+ const childRotation = (root as { rotation: number }).rotation
54
+ const cos = Math.cos(parentRotation)
55
+ const sin = Math.sin(parentRotation)
56
+ const {
57
+ cabinetCornerDerivedRun: _derived,
58
+ nodeSelectionProxyId: _proxy,
59
+ ...metadata
60
+ } = (root.metadata ?? {}) as Record<string, unknown>
61
+ return {
62
+ root: {
63
+ ...root,
64
+ parentId: findLevelAncestorId(rootId, nodes),
65
+ position: [
66
+ parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin,
67
+ parentPosition[1] + childPosition[1],
68
+ parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos,
69
+ ],
70
+ rotation: parentRotation + childRotation,
71
+ metadata,
72
+ } as AnyNode,
73
+ descendants,
74
+ parentId: findLevelAncestorId(rootId, nodes),
75
+ }
76
+ },
77
+ },
78
+ },
79
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
80
+ } as AnyNodeDefinition)
81
+ }
82
+
83
+ function level(children: AnyNodeId[]): AnyNode {
84
+ return {
85
+ id: LEVEL_ID,
86
+ type: 'level',
87
+ object: 'node',
88
+ parentId: null,
89
+ visible: true,
90
+ metadata: {},
91
+ children,
92
+ level: 0,
93
+ } as AnyNode
94
+ }
95
+
96
+ function shelf(): AnyNode {
97
+ return {
98
+ id: SHELF_ID,
99
+ type: 'shelf',
100
+ object: 'node',
101
+ parentId: LEVEL_ID,
102
+ visible: false,
103
+ metadata: { isNew: true, label: 'draft' },
104
+ children: [],
105
+ position: [0, 0, 0],
106
+ rotation: [0, 0, 0],
107
+ width: 1.2,
108
+ depth: 0.3,
109
+ thickness: 0.04,
110
+ height: 0.9,
111
+ style: 'wall-shelf',
112
+ rows: 1,
113
+ columns: 1,
114
+ withBack: false,
115
+ withSides: true,
116
+ withBottom: false,
117
+ bracketStyle: 'minimal',
118
+ } as AnyNode
119
+ }
120
+
121
+ function seedCabinetRun() {
122
+ const run = CabinetNode.parse({
123
+ id: CABINET_RUN_ID,
124
+ parentId: LEVEL_ID,
125
+ position: [0, 0, 0],
126
+ rotation: 0,
127
+ children: [CABINET_LEFT_ID, CABINET_RIGHT_ID],
128
+ showPlinth: true,
129
+ withCountertop: true,
130
+ })
131
+ const left = CabinetModuleNode.parse({
132
+ id: CABINET_LEFT_ID,
133
+ parentId: CABINET_RUN_ID,
134
+ position: [-0.45, 0.1, 0],
135
+ width: 0.9,
136
+ })
137
+ const right = CabinetModuleNode.parse({
138
+ id: CABINET_RIGHT_ID,
139
+ parentId: CABINET_RUN_ID,
140
+ position: [0.45, 0.1, 0],
141
+ width: 0.9,
142
+ })
143
+
144
+ useScene.setState({
145
+ nodes: {
146
+ [LEVEL_ID]: level([CABINET_RUN_ID]),
147
+ [CABINET_RUN_ID]: run as AnyNode,
148
+ [CABINET_LEFT_ID]: left as AnyNode,
149
+ [CABINET_RIGHT_ID]: right as AnyNode,
150
+ },
151
+ rootNodeIds: [LEVEL_ID],
152
+ collections: {},
153
+ dirtyNodes: new Set(),
154
+ } as never)
155
+ }
156
+
157
+ describe('commitFreshPlacementSubtree', () => {
158
+ beforeEach(() => {
159
+ useScene.setState({
160
+ nodes: {
161
+ [LEVEL_ID]: level([SHELF_ID]),
162
+ [SHELF_ID]: shelf(),
163
+ },
164
+ rootNodeIds: [LEVEL_ID],
165
+ collections: {},
166
+ dirtyNodes: new Set(),
167
+ } as never)
168
+ useScene.temporal.getState().clear()
169
+ useScene.temporal.getState().resume()
170
+ })
171
+
172
+ test('commits a fresh draft as one undoable clean subtree', () => {
173
+ useScene.temporal.getState().pause()
174
+
175
+ const committedId = commitFreshPlacementSubtree(SHELF_ID, {
176
+ position: [2, 0, 3],
177
+ visible: true,
178
+ } as Partial<AnyNode>)
179
+
180
+ expect(committedId).toBeTruthy()
181
+ expect(committedId).not.toBe(SHELF_ID)
182
+ const finalId = committedId as AnyNodeId
183
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
184
+
185
+ const committed = useScene.getState().nodes[finalId] as
186
+ | (AnyNode & { position: [number, number, number]; metadata?: Record<string, unknown> })
187
+ | undefined
188
+ expect(committed?.position).toEqual([2, 0, 3])
189
+ expect(committed?.visible).toBe(true)
190
+ expect(committed?.metadata?.isNew).toBeUndefined()
191
+ expect(committed?.metadata?.label).toBe('draft')
192
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
193
+ finalId,
194
+ ])
195
+
196
+ useScene.temporal.getState().resume()
197
+ useScene.temporal.getState().undo()
198
+
199
+ expect(useScene.getState().nodes[finalId]).toBeUndefined()
200
+ expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
201
+ expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
202
+ })
203
+
204
+ test('commits a duplicated cabinet draft without deleting the original modules', () => {
205
+ seedCabinetRun()
206
+ useScene.temporal.getState().clear()
207
+ useScene.temporal.getState().pause()
208
+
209
+ const draftId = createFreshPlacementSubtree(CABINET_RUN_ID)
210
+ expect(draftId).toBeTruthy()
211
+ expect(draftId).not.toBe(CABINET_RUN_ID)
212
+
213
+ const draft = useScene.getState().nodes[draftId as AnyNodeId] as
214
+ | (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
215
+ | undefined
216
+ expect(draft?.metadata?.isNew).toBe(true)
217
+ expect(draft?.children).toHaveLength(2)
218
+ expect(draft?.children).not.toContain(CABINET_LEFT_ID)
219
+ expect(draft?.children).not.toContain(CABINET_RIGHT_ID)
220
+
221
+ const finalId = commitFreshPlacementSubtree(
222
+ draftId as AnyNodeId,
223
+ {
224
+ position: [2, 0, 3],
225
+ visible: true,
226
+ } as Partial<AnyNode>,
227
+ )
228
+ expect(finalId).toBeTruthy()
229
+
230
+ const nodes = useScene.getState().nodes
231
+ expect(nodes[CABINET_RUN_ID]).toBeDefined()
232
+ expect(nodes[CABINET_LEFT_ID]).toBeDefined()
233
+ expect(nodes[CABINET_RIGHT_ID]).toBeDefined()
234
+
235
+ const finalRun = nodes[finalId as AnyNodeId] as
236
+ | (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
237
+ | undefined
238
+ expect(finalRun?.children).toHaveLength(2)
239
+ expect(finalRun?.metadata?.isNew).toBeUndefined()
240
+ for (const childId of finalRun?.children ?? []) {
241
+ const child = nodes[childId]
242
+ expect(child?.type).toBe('cabinet-module')
243
+ expect(child?.parentId).toBe(finalId)
244
+ }
245
+ })
246
+
247
+ test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => {
248
+ registerCabinetClonePrepTestKind()
249
+ const parentRun = {
250
+ id: CABINET_RUN_ID,
251
+ type: 'cabinet',
252
+ object: 'node',
253
+ visible: true,
254
+ metadata: {},
255
+ parentId: LEVEL_ID,
256
+ position: [4, 0, 5],
257
+ rotation: Math.PI / 2,
258
+ children: [CABINET_LEFT_ID, CABINET_CHILD_RUN_ID],
259
+ width: 0.6,
260
+ depth: 0.58,
261
+ carcassHeight: 0.72,
262
+ plinthHeight: 0.1,
263
+ toeKickDepth: 0.075,
264
+ boardThickness: 0.018,
265
+ countertopThickness: 0.02,
266
+ countertopOverhang: 0.02,
267
+ countertopBackOverhang: 0,
268
+ withFinishedBack: false,
269
+ withWaterfall: false,
270
+ frontThickness: 0.018,
271
+ frontGap: 0.003,
272
+ frontStyle: 'slab',
273
+ handleStyle: 'bar',
274
+ handlePosition: 'auto',
275
+ frontOverlay: 'full',
276
+ withBottomPanel: true,
277
+ showPlinth: true,
278
+ withCountertop: true,
279
+ operationState: 0,
280
+ runTier: 'base',
281
+ } as AnyNode
282
+ const parentModule = CabinetModuleNode.parse({
283
+ id: CABINET_LEFT_ID,
284
+ parentId: CABINET_RUN_ID,
285
+ position: [0.5, 0.1, 0],
286
+ width: 1,
287
+ })
288
+ const childRun = CabinetNode.parse({
289
+ id: CABINET_CHILD_RUN_ID,
290
+ parentId: CABINET_RUN_ID,
291
+ position: [1, 0, 0.25],
292
+ rotation: Math.PI / 2,
293
+ children: [CABINET_CHILD_MODULE_ID],
294
+ metadata: {
295
+ cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: CABINET_RUN_ID },
296
+ nodeSelectionProxyId: CABINET_RUN_ID,
297
+ },
298
+ })
299
+ const childModule = CabinetModuleNode.parse({
300
+ id: CABINET_CHILD_MODULE_ID,
301
+ parentId: CABINET_CHILD_RUN_ID,
302
+ position: [0, 0.1, 0],
303
+ width: 0.9,
304
+ })
305
+ useScene.setState({
306
+ nodes: {
307
+ [LEVEL_ID]: level([CABINET_RUN_ID]),
308
+ [CABINET_RUN_ID]: parentRun as AnyNode,
309
+ [CABINET_LEFT_ID]: parentModule as AnyNode,
310
+ [CABINET_CHILD_RUN_ID]: childRun as AnyNode,
311
+ [CABINET_CHILD_MODULE_ID]: childModule as AnyNode,
312
+ },
313
+ rootNodeIds: [LEVEL_ID],
314
+ collections: {},
315
+ dirtyNodes: new Set(),
316
+ } as never)
317
+
318
+ const draftId = createFreshPlacementSubtree(CABINET_CHILD_RUN_ID)
319
+ const draft = useScene.getState().nodes[draftId as AnyNodeId] as
320
+ | (AnyNode & {
321
+ children: AnyNodeId[]
322
+ metadata?: Record<string, unknown>
323
+ position: [number, number, number]
324
+ rotation: number
325
+ })
326
+ | undefined
327
+
328
+ expect(draft).toBeDefined()
329
+ expect(draft?.parentId).toBe(LEVEL_ID)
330
+ expect(draft?.position[0]).toBeCloseTo(4.25)
331
+ expect(draft?.position[2]).toBeCloseTo(4)
332
+ expect(draft?.rotation).toBeCloseTo(Math.PI)
333
+ expect(draft?.metadata?.isNew).toBe(true)
334
+ expect(draft?.metadata?.cabinetCornerDerivedRun).toBeUndefined()
335
+ expect(draft?.metadata?.nodeSelectionProxyId).toBeUndefined()
336
+ expect(draft?.children).toHaveLength(1)
337
+ expect(draft?.children).not.toContain(CABINET_CHILD_MODULE_ID)
338
+ })
339
+ })
@@ -0,0 +1,130 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ cloneNodesInto,
5
+ collectSubtree,
6
+ type DuplicableConfig,
7
+ nodeRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
11
+
12
+ function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
13
+ return {
14
+ ...node,
15
+ metadata: stripPlacementMetadataFlags(node.metadata),
16
+ } as N
17
+ }
18
+
19
+ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
20
+ const parentId = (node as { parentId?: AnyNodeId | null }).parentId
21
+ return parentId ?? undefined
22
+ }
23
+
24
+ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
25
+ const duplicable = nodeRegistry.get(node.type)?.capabilities?.duplicable
26
+ return duplicable && typeof duplicable === 'object' ? duplicable : null
27
+ }
28
+
29
+ export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
30
+ const children = (node as { children?: unknown }).children
31
+ return (
32
+ duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Creates a fresh draft copy of a live subtree, with every child reference
38
+ * rewired before move mode starts.
39
+ */
40
+ export function createFreshPlacementSubtree(
41
+ rootId: AnyNodeId,
42
+ rootPatch: Partial<AnyNode> = {},
43
+ ): AnyNodeId | null {
44
+ const scene = useScene.getState()
45
+ const subtree = collectSubtree(scene.nodes, rootId)
46
+ if (!subtree) return null
47
+
48
+ const baseRoot = {
49
+ ...subtree.root,
50
+ ...rootPatch,
51
+ } as AnyNode
52
+ const prepared = duplicableConfigFor(subtree.root)?.prepareSubtreeClone?.({
53
+ root: baseRoot,
54
+ descendants: subtree.descendants,
55
+ rootId,
56
+ rootPatch,
57
+ nodes: scene.nodes,
58
+ })
59
+ const preparedRoot = prepared?.root ?? baseRoot
60
+ const root = {
61
+ ...preparedRoot,
62
+ metadata: {
63
+ ...getPlacementMetadataRecord(stripPlacementMetadataFlags(preparedRoot.metadata)),
64
+ isNew: true,
65
+ },
66
+ } as AnyNode
67
+ const descendants = (prepared?.descendants ?? subtree.descendants).map((node: AnyNode) => ({
68
+ ...node,
69
+ metadata: stripPlacementMetadataFlags(node.metadata),
70
+ })) as AnyNode[]
71
+ const parentId =
72
+ prepared && Object.hasOwn(prepared, 'parentId')
73
+ ? (prepared.parentId ?? undefined)
74
+ : parentIdOf(root)
75
+ const cloned = cloneNodesInto([root, ...descendants], {
76
+ rootId,
77
+ parentId,
78
+ })
79
+
80
+ useScene
81
+ .getState()
82
+ .createNodes(
83
+ cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
84
+ )
85
+
86
+ return cloned.rootId
87
+ }
88
+
89
+ /**
90
+ * Finalises a fresh catalog/duplicate draft as a single undoable creation.
91
+ *
92
+ * Fresh drafts already exist in the scene so renderers and move tools can
93
+ * preview real geometry. On commit we delete that draft while history is
94
+ * paused, then create a clean clone at the final cursor position with history
95
+ * resumed. Undo therefore removes the placed node instead of resurrecting the
96
+ * hidden draft at its origin.
97
+ */
98
+ export function commitFreshPlacementSubtree(
99
+ rootId: AnyNodeId,
100
+ rootPatch: Partial<AnyNode>,
101
+ ): AnyNodeId | null {
102
+ const scene = useScene.getState()
103
+ const subtree = collectSubtree(scene.nodes, rootId)
104
+ if (!subtree) return null
105
+
106
+ const root = cleanPlacementMetadata({
107
+ ...subtree.root,
108
+ ...rootPatch,
109
+ } as AnyNode)
110
+ const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
111
+ const parentId = parentIdOf(root)
112
+ const cloned = cloneNodesInto([root, ...descendants], {
113
+ rootId,
114
+ parentId,
115
+ })
116
+
117
+ const temporal = useScene.temporal.getState()
118
+ const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
119
+ if (wasTracking) temporal.pause()
120
+ useScene.getState().deleteNode(rootId)
121
+ temporal.resume()
122
+ useScene
123
+ .getState()
124
+ .createNodes(
125
+ cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
126
+ )
127
+ if (!wasTracking) temporal.pause()
128
+
129
+ return cloned.rootId
130
+ }