@pascal-app/editor 0.9.1 → 1.0.0-beta.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 (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -1,6 +1,18 @@
1
1
  import type { Point2D } from '@pascal-app/core'
2
2
  import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
3
3
 
4
+ // Baseline rotation (deg) that orients the plan-local scene "north up" on
5
+ // screen. The on-screen floor-plan scene `<g>` is rotated by
6
+ // `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export
7
+ // uses the same user rotation so its orientation matches the live plan.
8
+ //
9
+ // North is world −Z: with a 0 baseline a rotation-0 reference image (top =
10
+ // −Z) reads upright in the north-aligned view, and "align north" maps to a
11
+ // 3D camera azimuth of 0. The old 90° baseline made north world −X, so
12
+ // north-up floorplan scans displayed sideways whenever the compass claimed
13
+ // the view was aligned.
14
+ export const FLOORPLAN_VIEW_ROTATION_DEG = 0
15
+
4
16
  export function clampPlanValue(value: number, min: number, max: number) {
5
17
  return Math.min(Math.max(value, min), max)
6
18
  }
@@ -11,6 +23,46 @@ export function rotatePlanVector(x: number, y: number, rotation: number): [numbe
11
23
  return [x * cos + y * sin, -x * sin + y * cos]
12
24
  }
13
25
 
26
+ // Converts a world X/Z point into the floor-plan-local (building-local)
27
+ // frame used by the SVG scene `<g>` and every stored node position. The
28
+ // inverse of `floorplanLocalToWorldPoint`. Shared so the floor-plan panel
29
+ // and the 2D move overlay resolve the same frame — feeding a world-space
30
+ // `original` into a local-space cursor solver lands the drop off by the
31
+ // building's world X/Z (worse for an off-origin building).
32
+ export function worldToFloorplanLocalPoint(
33
+ worldX: number,
34
+ worldZ: number,
35
+ buildingPosition: readonly [number, number, number],
36
+ buildingRotationY: number,
37
+ ): Point2D {
38
+ const dx = worldX - buildingPosition[0]
39
+ const dz = worldZ - buildingPosition[2]
40
+ const cos = Math.cos(buildingRotationY)
41
+ const sin = Math.sin(buildingRotationY)
42
+
43
+ return {
44
+ x: dx * cos - dz * sin,
45
+ y: dx * sin + dz * cos,
46
+ }
47
+ }
48
+
49
+ // Inverse of `worldToFloorplanLocalPoint`: floor-plan-local X/Y → world X/Z.
50
+ export function floorplanLocalToWorldPoint(
51
+ point: Point2D | [number, number],
52
+ buildingPosition: readonly [number, number, number],
53
+ buildingRotationY: number,
54
+ ): { x: number; z: number } {
55
+ const localX = Array.isArray(point) ? point[0] : point.x
56
+ const localY = Array.isArray(point) ? point[1] : point.y
57
+ const cos = Math.cos(buildingRotationY)
58
+ const sin = Math.sin(buildingRotationY)
59
+
60
+ return {
61
+ x: buildingPosition[0] + localX * cos + localY * sin,
62
+ z: buildingPosition[2] - localX * sin + localY * cos,
63
+ }
64
+ }
65
+
14
66
  export function getRotatedRectanglePolygon(
15
67
  center: Point2D,
16
68
  width: number,
@@ -8,6 +8,8 @@ export {
8
8
  export {
9
9
  clampPlanValue,
10
10
  doesPolygonIntersectSelectionBounds,
11
+ FLOORPLAN_VIEW_ROTATION_DEG,
12
+ floorplanLocalToWorldPoint,
11
13
  getDistanceToWallSegment,
12
14
  getFloorplanSelectionBounds,
13
15
  getPlanPointDistance,
@@ -20,6 +22,7 @@ export {
20
22
  movePlanPointTowards,
21
23
  pointMatchesWallPlanPoint,
22
24
  rotatePlanVector,
25
+ worldToFloorplanLocalPoint,
23
26
  } from './geometry'
24
27
  export {
25
28
  buildFloorplanItemEntry,
@@ -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,7 +1,24 @@
1
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'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ CabinetModuleNode,
7
+ CabinetNode,
8
+ findLevelAncestorId,
9
+ nodeRegistry,
10
+ registerNode,
11
+ type SceneCommit,
12
+ subscribeSceneCommits,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { z } from 'zod'
16
+ import {
17
+ commitFreshPlacementSubtree,
18
+ createFreshPlacementSubtree,
19
+ duplicatesAsFreshSubtree,
20
+ prepareFreshPlacementRootDuplicate,
21
+ } from './fresh-planar-placement'
5
22
 
6
23
  type RafFn = (cb: (time: number) => void) => number
7
24
  ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
@@ -14,6 +31,61 @@ type RafFn = (cb: (time: number) => void) => number
14
31
 
15
32
  const LEVEL_ID = 'level_test' as AnyNodeId
16
33
  const SHELF_ID = 'shelf_draft' as AnyNodeId
34
+ const CABINET_RUN_ID = 'cabinet_original-run' as AnyNodeId
35
+ const CABINET_LEFT_ID = 'cabinet-module_original-left' as AnyNodeId
36
+ const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId
37
+ const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId
38
+ const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId
39
+
40
+ function registerCabinetClonePrepTestKind() {
41
+ if (nodeRegistry.has('cabinet')) return
42
+
43
+ registerNode({
44
+ kind: 'cabinet',
45
+ schemaVersion: 1,
46
+ schema: z.any() as never,
47
+ category: 'furnish',
48
+ defaults: () => ({}),
49
+ capabilities: {
50
+ duplicable: {
51
+ subtree: true,
52
+ prepareSubtreeClone: ({ root, descendants, rootId, nodes }) => {
53
+ const parent = root.parentId ? nodes[root.parentId as AnyNodeId] : null
54
+ if (root.type !== 'cabinet' || parent?.type !== 'cabinet') {
55
+ return { root, descendants }
56
+ }
57
+ const parentPosition = (parent as { position: [number, number, number] }).position
58
+ const parentRotation = (parent as { rotation: number }).rotation
59
+ const childPosition = (root as { position: [number, number, number] }).position
60
+ const childRotation = (root as { rotation: number }).rotation
61
+ const cos = Math.cos(parentRotation)
62
+ const sin = Math.sin(parentRotation)
63
+ const {
64
+ cabinetCornerDerivedRun: _derived,
65
+ nodeSelectionProxyId: _proxy,
66
+ ...metadata
67
+ } = (root.metadata ?? {}) as Record<string, unknown>
68
+ return {
69
+ root: {
70
+ ...root,
71
+ parentId: findLevelAncestorId(rootId, nodes),
72
+ position: [
73
+ parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin,
74
+ parentPosition[1] + childPosition[1],
75
+ parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos,
76
+ ],
77
+ rotation: parentRotation + childRotation,
78
+ metadata,
79
+ } as AnyNode,
80
+ descendants,
81
+ parentId: findLevelAncestorId(rootId, nodes),
82
+ }
83
+ },
84
+ },
85
+ },
86
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
87
+ } as AnyNodeDefinition)
88
+ }
17
89
 
18
90
  function level(children: AnyNodeId[]): AnyNode {
19
91
  return {
@@ -53,6 +125,42 @@ function shelf(): AnyNode {
53
125
  } as AnyNode
54
126
  }
55
127
 
128
+ function seedCabinetRun() {
129
+ const run = CabinetNode.parse({
130
+ id: CABINET_RUN_ID,
131
+ parentId: LEVEL_ID,
132
+ position: [0, 0, 0],
133
+ rotation: 0,
134
+ children: [CABINET_LEFT_ID, CABINET_RIGHT_ID],
135
+ showPlinth: true,
136
+ withCountertop: true,
137
+ })
138
+ const left = CabinetModuleNode.parse({
139
+ id: CABINET_LEFT_ID,
140
+ parentId: CABINET_RUN_ID,
141
+ position: [-0.45, 0.1, 0],
142
+ width: 0.9,
143
+ })
144
+ const right = CabinetModuleNode.parse({
145
+ id: CABINET_RIGHT_ID,
146
+ parentId: CABINET_RUN_ID,
147
+ position: [0.45, 0.1, 0],
148
+ width: 0.9,
149
+ })
150
+
151
+ useScene.setState({
152
+ nodes: {
153
+ [LEVEL_ID]: level([CABINET_RUN_ID]),
154
+ [CABINET_RUN_ID]: run as AnyNode,
155
+ [CABINET_LEFT_ID]: left as AnyNode,
156
+ [CABINET_RIGHT_ID]: right as AnyNode,
157
+ },
158
+ rootNodeIds: [LEVEL_ID],
159
+ collections: {},
160
+ dirtyNodes: new Set(),
161
+ } as never)
162
+ }
163
+
56
164
  describe('commitFreshPlacementSubtree', () => {
57
165
  beforeEach(() => {
58
166
  useScene.setState({
@@ -69,12 +177,15 @@ describe('commitFreshPlacementSubtree', () => {
69
177
  })
70
178
 
71
179
  test('commits a fresh draft as one undoable clean subtree', () => {
180
+ const commits: SceneCommit[] = []
181
+ const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
72
182
  useScene.temporal.getState().pause()
73
183
 
74
184
  const committedId = commitFreshPlacementSubtree(SHELF_ID, {
75
185
  position: [2, 0, 3],
76
186
  visible: true,
77
187
  } as Partial<AnyNode>)
188
+ unsubscribe()
78
189
 
79
190
  expect(committedId).toBeTruthy()
80
191
  expect(committedId).not.toBe(SHELF_ID)
@@ -91,6 +202,12 @@ describe('commitFreshPlacementSubtree', () => {
91
202
  expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
92
203
  finalId,
93
204
  ])
205
+ expect(commits).toHaveLength(1)
206
+ expect(commits[0]?.origin).toBe('local')
207
+ expect(commits[0]?.before.nodes[SHELF_ID]).toBeUndefined()
208
+ expect(commits[0]?.before.nodes[finalId]).toBeUndefined()
209
+ expect(commits[0]?.current.nodes[SHELF_ID]).toBeUndefined()
210
+ expect(commits[0]?.current.nodes[finalId]).toEqual(committed)
94
211
 
95
212
  useScene.temporal.getState().resume()
96
213
  useScene.temporal.getState().undo()
@@ -99,4 +216,167 @@ describe('commitFreshPlacementSubtree', () => {
99
216
  expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
100
217
  expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
101
218
  })
219
+
220
+ test('uses the subtree contract for childless variants and never aliases root-only children', () => {
221
+ registerCabinetClonePrepTestKind()
222
+ const childlessCabinet = {
223
+ ...shelf(),
224
+ type: 'cabinet',
225
+ children: [],
226
+ metadata: { isTransient: true, label: 'source' },
227
+ } as AnyNode
228
+ expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)
229
+
230
+ const source = {
231
+ ...shelf(),
232
+ children: ['item_original' as AnyNodeId],
233
+ metadata: { isTransient: true, label: 'source' },
234
+ } as AnyNode
235
+ const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
236
+ children: AnyNodeId[]
237
+ id?: AnyNodeId
238
+ metadata?: Record<string, unknown>
239
+ }
240
+
241
+ expect(duplicate.id).toBeUndefined()
242
+ expect(duplicate.children).toEqual([])
243
+ expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
244
+ expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
245
+ })
246
+
247
+ test('commits a duplicated cabinet draft without deleting the original modules', () => {
248
+ seedCabinetRun()
249
+ useScene.temporal.getState().clear()
250
+ useScene.temporal.getState().pause()
251
+
252
+ const draftId = createFreshPlacementSubtree(CABINET_RUN_ID)
253
+ expect(draftId).toBeTruthy()
254
+ expect(draftId).not.toBe(CABINET_RUN_ID)
255
+
256
+ const draft = useScene.getState().nodes[draftId as AnyNodeId] as
257
+ | (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
258
+ | undefined
259
+ expect(draft?.metadata?.isNew).toBe(true)
260
+ expect(draft?.children).toHaveLength(2)
261
+ expect(draft?.children).not.toContain(CABINET_LEFT_ID)
262
+ expect(draft?.children).not.toContain(CABINET_RIGHT_ID)
263
+
264
+ const finalId = commitFreshPlacementSubtree(
265
+ draftId as AnyNodeId,
266
+ {
267
+ position: [2, 0, 3],
268
+ visible: true,
269
+ } as Partial<AnyNode>,
270
+ )
271
+ expect(finalId).toBeTruthy()
272
+
273
+ const nodes = useScene.getState().nodes
274
+ expect(nodes[CABINET_RUN_ID]).toBeDefined()
275
+ expect(nodes[CABINET_LEFT_ID]).toBeDefined()
276
+ expect(nodes[CABINET_RIGHT_ID]).toBeDefined()
277
+
278
+ const finalRun = nodes[finalId as AnyNodeId] as
279
+ | (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
280
+ | undefined
281
+ expect(finalRun?.children).toHaveLength(2)
282
+ expect(finalRun?.metadata?.isNew).toBeUndefined()
283
+ for (const childId of finalRun?.children ?? []) {
284
+ const child = nodes[childId]
285
+ expect(child?.type).toBe('cabinet-module')
286
+ expect(child?.parentId).toBe(finalId)
287
+ }
288
+ })
289
+
290
+ test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => {
291
+ registerCabinetClonePrepTestKind()
292
+ const parentRun = {
293
+ id: CABINET_RUN_ID,
294
+ type: 'cabinet',
295
+ object: 'node',
296
+ visible: true,
297
+ metadata: {},
298
+ parentId: LEVEL_ID,
299
+ position: [4, 0, 5],
300
+ rotation: Math.PI / 2,
301
+ children: [CABINET_LEFT_ID, CABINET_CHILD_RUN_ID],
302
+ width: 0.6,
303
+ depth: 0.58,
304
+ carcassHeight: 0.72,
305
+ plinthHeight: 0.1,
306
+ toeKickDepth: 0.075,
307
+ boardThickness: 0.018,
308
+ countertopThickness: 0.02,
309
+ countertopOverhang: 0.02,
310
+ countertopBackOverhang: 0,
311
+ withFinishedBack: false,
312
+ withWaterfall: false,
313
+ frontThickness: 0.018,
314
+ frontGap: 0.003,
315
+ frontStyle: 'slab',
316
+ handleStyle: 'bar',
317
+ handlePosition: 'auto',
318
+ frontOverlay: 'full',
319
+ withBottomPanel: true,
320
+ showPlinth: true,
321
+ withCountertop: true,
322
+ operationState: 0,
323
+ runTier: 'base',
324
+ } as AnyNode
325
+ const parentModule = CabinetModuleNode.parse({
326
+ id: CABINET_LEFT_ID,
327
+ parentId: CABINET_RUN_ID,
328
+ position: [0.5, 0.1, 0],
329
+ width: 1,
330
+ })
331
+ const childRun = CabinetNode.parse({
332
+ id: CABINET_CHILD_RUN_ID,
333
+ parentId: CABINET_RUN_ID,
334
+ position: [1, 0, 0.25],
335
+ rotation: Math.PI / 2,
336
+ children: [CABINET_CHILD_MODULE_ID],
337
+ metadata: {
338
+ cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: CABINET_RUN_ID },
339
+ nodeSelectionProxyId: CABINET_RUN_ID,
340
+ },
341
+ })
342
+ const childModule = CabinetModuleNode.parse({
343
+ id: CABINET_CHILD_MODULE_ID,
344
+ parentId: CABINET_CHILD_RUN_ID,
345
+ position: [0, 0.1, 0],
346
+ width: 0.9,
347
+ })
348
+ useScene.setState({
349
+ nodes: {
350
+ [LEVEL_ID]: level([CABINET_RUN_ID]),
351
+ [CABINET_RUN_ID]: parentRun as AnyNode,
352
+ [CABINET_LEFT_ID]: parentModule as AnyNode,
353
+ [CABINET_CHILD_RUN_ID]: childRun as AnyNode,
354
+ [CABINET_CHILD_MODULE_ID]: childModule as AnyNode,
355
+ },
356
+ rootNodeIds: [LEVEL_ID],
357
+ collections: {},
358
+ dirtyNodes: new Set(),
359
+ } as never)
360
+
361
+ const draftId = createFreshPlacementSubtree(CABINET_CHILD_RUN_ID)
362
+ const draft = useScene.getState().nodes[draftId as AnyNodeId] as
363
+ | (AnyNode & {
364
+ children: AnyNodeId[]
365
+ metadata?: Record<string, unknown>
366
+ position: [number, number, number]
367
+ rotation: number
368
+ })
369
+ | undefined
370
+
371
+ expect(draft).toBeDefined()
372
+ expect(draft?.parentId).toBe(LEVEL_ID)
373
+ expect(draft?.position[0]).toBeCloseTo(4.25)
374
+ expect(draft?.position[2]).toBeCloseTo(4)
375
+ expect(draft?.rotation).toBeCloseTo(Math.PI)
376
+ expect(draft?.metadata?.isNew).toBe(true)
377
+ expect(draft?.metadata?.cabinetCornerDerivedRun).toBeUndefined()
378
+ expect(draft?.metadata?.nodeSelectionProxyId).toBeUndefined()
379
+ expect(draft?.children).toHaveLength(1)
380
+ expect(draft?.children).not.toContain(CABINET_CHILD_MODULE_ID)
381
+ })
102
382
  })
@@ -3,9 +3,11 @@ import {
3
3
  type AnyNodeId,
4
4
  cloneNodesInto,
5
5
  collectSubtree,
6
+ type DuplicableConfig,
7
+ nodeRegistry,
6
8
  useScene,
7
9
  } from '@pascal-app/core'
8
- import { stripPlacementMetadataFlags } from './placement-metadata'
10
+ import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
9
11
 
10
12
  function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
11
13
  return {
@@ -19,6 +21,88 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
19
21
  return parentId ?? undefined
20
22
  }
21
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
+ return duplicableConfigFor(node)?.subtree === true
31
+ }
32
+
33
+ /**
34
+ * Prepares a non-subtree duplicate without retaining ownership of the
35
+ * original node's children. Subtree-capable kinds take the path above and
36
+ * receive fresh descendant IDs; every other kind duplicates only its root.
37
+ */
38
+ export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
39
+ const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
40
+ id?: AnyNodeId
41
+ children?: unknown
42
+ metadata?: unknown
43
+ }
44
+ delete duplicate.id
45
+ if (Array.isArray(duplicate.children)) duplicate.children = []
46
+ duplicate.metadata = {
47
+ ...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
48
+ isNew: true,
49
+ }
50
+ return duplicate as unknown as AnyNode
51
+ }
52
+
53
+ /**
54
+ * Creates a fresh draft copy of a live subtree, with every child reference
55
+ * rewired before move mode starts.
56
+ */
57
+ export function createFreshPlacementSubtree(
58
+ rootId: AnyNodeId,
59
+ rootPatch: Partial<AnyNode> = {},
60
+ ): AnyNodeId | null {
61
+ const scene = useScene.getState()
62
+ const subtree = collectSubtree(scene.nodes, rootId)
63
+ if (!subtree) return null
64
+
65
+ const baseRoot = {
66
+ ...subtree.root,
67
+ ...rootPatch,
68
+ } as AnyNode
69
+ const prepared = duplicableConfigFor(subtree.root)?.prepareSubtreeClone?.({
70
+ root: baseRoot,
71
+ descendants: subtree.descendants,
72
+ rootId,
73
+ rootPatch,
74
+ nodes: scene.nodes,
75
+ })
76
+ const preparedRoot = prepared?.root ?? baseRoot
77
+ const root = {
78
+ ...preparedRoot,
79
+ metadata: {
80
+ ...getPlacementMetadataRecord(stripPlacementMetadataFlags(preparedRoot.metadata)),
81
+ isNew: true,
82
+ },
83
+ } as AnyNode
84
+ const descendants = (prepared?.descendants ?? subtree.descendants).map((node: AnyNode) => ({
85
+ ...node,
86
+ metadata: stripPlacementMetadataFlags(node.metadata),
87
+ })) as AnyNode[]
88
+ const parentId =
89
+ prepared && Object.hasOwn(prepared, 'parentId')
90
+ ? (prepared.parentId ?? undefined)
91
+ : parentIdOf(root)
92
+ const cloned = cloneNodesInto([root, ...descendants], {
93
+ rootId,
94
+ parentId,
95
+ })
96
+
97
+ useScene
98
+ .getState()
99
+ .createNodes(
100
+ cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
101
+ )
102
+
103
+ return cloned.rootId
104
+ }
105
+
22
106
  /**
23
107
  * Finalises a fresh catalog/duplicate draft as a single undoable creation.
24
108
  *