@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
@@ -6,19 +6,28 @@ import type {
6
6
  GridEvent,
7
7
  ItemEvent,
8
8
  ItemNode,
9
+ RoofEvent,
10
+ RoofNode,
11
+ RoofSegmentNode,
12
+ RoofWallFaceId,
9
13
  ShelfEvent,
10
14
  ShelfNode,
11
15
  WallEvent,
12
16
  WallNode,
13
17
  } from '@pascal-app/core'
14
18
  import {
19
+ canHostOnTop,
20
+ clampRectToRoofWallFace,
21
+ getRoofSegmentWallFace,
15
22
  getScaledDimensions,
16
23
  isLowProfileItemSurface,
17
24
  nodeRegistry,
25
+ roofFacePointToSegment,
18
26
  sceneRegistry,
19
27
  useScene,
20
28
  } from '@pascal-app/core'
21
29
  import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
30
+ import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
22
31
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
23
32
  import {
24
33
  calculateCursorRotation,
@@ -56,6 +65,7 @@ function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
56
65
  }
57
66
 
58
67
  function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
68
+ if (!canHostOnTop(surfaceItem)) return null
59
69
  if (isLowProfileItemSurface(surfaceItem)) return null
60
70
  if (!isUpwardItemSurfaceHit(event)) return null
61
71
 
@@ -105,10 +115,13 @@ export const floorStrategy = {
105
115
  // is rotated; then project the world point back into building-local
106
116
  // for storage. Without this, a rotated building drags placement off
107
117
  // the world grid.
108
- const snappedWorldX = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
109
- const snappedWorldZ = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
110
- const { local } = snapWorldXZForActiveBuilding(snappedWorldX, snappedWorldZ, 0)
111
- const [x, z] = local
118
+ // Snapping is governed by the active mode (snapToGrid returns raw in Off /
119
+ // non-grid modes); Alt is force-place only and never bypasses snapping here.
120
+ const [x, z] = snapWorldXZForActiveBuilding(
121
+ snapToGrid(event.position[0], swapDims ? dimZ : dimX),
122
+ snapToGrid(event.position[2], swapDims ? dimX : dimZ),
123
+ 0,
124
+ ).local
112
125
  const y = ctx.gridPosition.y
113
126
 
114
127
  return {
@@ -132,6 +145,7 @@ export const floorStrategy = {
132
145
  ): CommitResult | null {
133
146
  if (ctx.state.surface !== 'floor') return null
134
147
  if (!(ctx.levelId && ctx.draftItem)) return null
148
+ if (ctx.draftItem.asset.attachTo) return null
135
149
 
136
150
  const pos: [number, number, number] = [
137
151
  ctx.gridPosition.x,
@@ -211,10 +225,13 @@ export const wallStrategy = {
211
225
  const adjustedY = validation.adjustedY ?? y
212
226
 
213
227
  return {
214
- stateUpdate: { surface: 'wall', wallId: event.node.id },
228
+ stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
215
229
  nodeUpdate: {
216
230
  position: [x, adjustedY, z],
217
231
  parentId: event.node.id,
232
+ // The draft may arrive from a roof-segment wall face.
233
+ roofSegmentId: undefined,
234
+ roofFace: undefined,
218
235
  side,
219
236
  rotation: [0, itemRotation, 0],
220
237
  },
@@ -313,6 +330,8 @@ export const wallStrategy = {
313
330
  nodeUpdate: {
314
331
  position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
315
332
  parentId: event.node.id,
333
+ roofSegmentId: undefined,
334
+ roofFace: undefined,
316
335
  side: ctx.draftItem.side,
317
336
  rotation: ctx.draftItem.rotation,
318
337
  metadata: stripTransient(ctx.draftItem.metadata),
@@ -342,6 +361,225 @@ export const wallStrategy = {
342
361
  },
343
362
  }
344
363
 
364
+ // ============================================================================
365
+ // ROOF WALL STRATEGY
366
+ // ============================================================================
367
+
368
+ type RoofWallTarget = {
369
+ segment: RoofSegmentNode
370
+ faceId: RoofWallFaceId
371
+ faceYaw: number
372
+ /** Stored node position: FACE-LOCAL, y = bottom edge. */
373
+ position: [number, number, number]
374
+ /** Face-coord center of the placed rect (for the overlap guard). */
375
+ centerU: number
376
+ centerV: number
377
+ width: number
378
+ height: number
379
+ cursorPosition: [number, number, number]
380
+ cursorRotationY: number
381
+ }
382
+
383
+ /**
384
+ * Resolve a roof pointer event to an item placement on a segment wall
385
+ * face. Items snap u / bottom-v to the 0.5m grid, then the rect is
386
+ * clamped inside the face profile (sliding under the gable slopes).
387
+ * Position frame matches wall hosting: y anchors the BOTTOM edge;
388
+ * `wall-side` items mount on the outer surface, `wall` items center in
389
+ * the wall thickness.
390
+ *
391
+ * `freePlace` mirrors the wall flow's Alt override (stubbed
392
+ * validators): the profile clamp is skipped, so the rect may overhang
393
+ * the face edges — placement follows the snapped cursor as-is.
394
+ */
395
+ function resolveRoofWallTarget(
396
+ ctx: PlacementContext,
397
+ event: RoofEvent,
398
+ freePlace = false,
399
+ ): RoofWallTarget | null {
400
+ const attachTo = ctx.asset.attachTo
401
+ if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
402
+
403
+ const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
404
+ if (!hit) return null
405
+
406
+ const rawDims = ctx.draftItem
407
+ ? getScaledDimensions(ctx.draftItem)
408
+ : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
409
+ const dims = getGridAlignedDimensions(rawDims, attachTo)
410
+ const [width, height] = dims
411
+
412
+ // Snap follows the active mode (snapToHalf returns raw in Off/non-grid);
413
+ // `freePlace` (Alt) is force-place — it only skips the face-fit validity gate.
414
+ const u = snapToHalf(hit.u)
415
+ const centerV = snapToHalf(hit.v) + height / 2
416
+ const fitted = freePlace ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
417
+ if (!fitted && !freePlace) return null
418
+ const finalU = fitted?.u ?? u
419
+ const finalV = fitted?.v ?? centerV
420
+
421
+ // FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes
422
+ // wall-side items to the outer surface, exactly like wall hosting).
423
+ // The renderer mounts the node inside the live face frame, so items
424
+ // track segment resizes without any re-anchoring.
425
+ const position: [number, number, number] = [finalU, finalV - height / 2, 0]
426
+
427
+ const segObj = sceneRegistry.nodes.get(hit.segment.id)
428
+ if (!segObj) return null
429
+ segObj.updateWorldMatrix(true, false)
430
+ const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position)
431
+ const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2]))
432
+
433
+ const nodes = useScene.getState().nodes
434
+ const roof = hit.segment.parentId
435
+ ? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined)
436
+ : undefined
437
+
438
+ return {
439
+ segment: hit.segment,
440
+ faceId: hit.face.id,
441
+ faceYaw: hit.face.yaw,
442
+ position,
443
+ centerU: finalU,
444
+ centerV: finalV,
445
+ width,
446
+ height,
447
+ cursorPosition: [worldPos.x, worldPos.y, worldPos.z],
448
+ cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw,
449
+ }
450
+ }
451
+
452
+ /** Validation half of `checkCanPlace` for the roof-wall surface. */
453
+ function canPlaceOnRoofWall(ctx: PlacementContext): boolean {
454
+ const segmentId = ctx.state.roofSegmentId
455
+ if (!(segmentId && ctx.draftItem)) return false
456
+ const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
457
+ if (segment?.type !== 'roof-segment') return false
458
+ const faceId = ctx.draftItem.roofFace
459
+ if (!faceId) return false
460
+ const face = getRoofSegmentWallFace(segment, faceId)
461
+
462
+ const dims = getGridAlignedDimensions(
463
+ getScaledDimensions(ctx.draftItem),
464
+ ctx.draftItem.asset.attachTo,
465
+ )
466
+ const [width, height] = dims
467
+ // gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z).
468
+ const u = ctx.gridPosition.x
469
+ const centerV = ctx.gridPosition.y + height / 2
470
+ const clamped = clampRectToRoofWallFace(face, u, centerV, width, height)
471
+ if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) {
472
+ return false
473
+ }
474
+ return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id)
475
+ }
476
+
477
+ export const roofWallStrategy = {
478
+ /**
479
+ * Handle roof:enter / first hover — transition onto a segment wall
480
+ * face. Returns null when the item doesn't wall-attach or the pointer
481
+ * isn't over a placeable face.
482
+ */
483
+ enter(ctx: PlacementContext, event: RoofEvent, freePlace = false): TransitionResult | null {
484
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
485
+ if (!target) return null
486
+
487
+ return {
488
+ stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
489
+ nodeUpdate: {
490
+ position: target.position,
491
+ parentId: target.segment.id,
492
+ roofSegmentId: target.segment.id,
493
+ roofFace: target.faceId,
494
+ wallId: undefined,
495
+ side: 'front',
496
+ rotation: [0, 0, 0],
497
+ },
498
+ cursorRotationY: target.cursorRotationY,
499
+ gridPosition: target.position,
500
+ cursorPosition: target.cursorPosition,
501
+ stopPropagation: true,
502
+ }
503
+ },
504
+
505
+ /**
506
+ * Handle roof:move while on a segment wall face. Returns null when the
507
+ * pointer resolves to a DIFFERENT segment (the coordinator re-enters —
508
+ * segment transitions inside one roof never re-fire roof:enter) or to
509
+ * no placeable face.
510
+ */
511
+ move(ctx: PlacementContext, event: RoofEvent, freePlace = false): PlacementResult | null {
512
+ if (ctx.state.surface !== 'roof-wall') return null
513
+ if (!ctx.draftItem) return null
514
+
515
+ const target = resolveRoofWallTarget(ctx, event, freePlace)
516
+ if (!target) return null
517
+ if (target.segment.id !== ctx.state.roofSegmentId) return null
518
+
519
+ return {
520
+ gridPosition: target.position,
521
+ cursorPosition: target.cursorPosition,
522
+ cursorRotationY: target.cursorRotationY,
523
+ nodeUpdate: {
524
+ position: target.position,
525
+ side: 'front',
526
+ rotation: [0, 0, 0],
527
+ roofFace: target.faceId,
528
+ },
529
+ stopPropagation: true,
530
+ // Items don't cut the roof — no geometry rebuild needed.
531
+ dirtyNodeId: null,
532
+ }
533
+ },
534
+
535
+ /**
536
+ * Handle roof:click — commit placement on the segment wall face.
537
+ */
538
+ click(ctx: PlacementContext, _event: RoofEvent, freePlace = false): CommitResult | null {
539
+ if (ctx.state.surface !== 'roof-wall') return null
540
+ if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
541
+ // Alt mirrors the wall flow's stubbed validators: skip profile-fit
542
+ // and overlap checks entirely.
543
+ if (!freePlace && !canPlaceOnRoofWall(ctx)) return null
544
+
545
+ return {
546
+ nodeUpdate: {
547
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
548
+ parentId: ctx.state.roofSegmentId,
549
+ roofSegmentId: ctx.state.roofSegmentId,
550
+ roofFace: ctx.draftItem.roofFace,
551
+ wallId: undefined,
552
+ side: 'front',
553
+ rotation: [0, 0, 0],
554
+ metadata: stripTransient(ctx.draftItem.metadata),
555
+ },
556
+ stopPropagation: true,
557
+ dirtyNodeId: null,
558
+ }
559
+ },
560
+
561
+ /**
562
+ * Handle roof:leave — transition back to floor surface.
563
+ */
564
+ leave(ctx: PlacementContext): TransitionResult | null {
565
+ if (ctx.state.surface !== 'roof-wall') return null
566
+
567
+ return {
568
+ stateUpdate: { surface: 'floor', roofSegmentId: null },
569
+ nodeUpdate: {
570
+ position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
571
+ parentId: ctx.levelId,
572
+ roofSegmentId: undefined,
573
+ roofFace: undefined,
574
+ },
575
+ cursorRotationY: 0,
576
+ gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
577
+ cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
578
+ stopPropagation: true,
579
+ }
580
+ },
581
+ }
582
+
345
583
  // ============================================================================
346
584
  // CEILING STRATEGY
347
585
  // ============================================================================
@@ -794,6 +1032,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
794
1032
  }
795
1033
 
796
1034
  if (attachTo === 'wall' || attachTo === 'wall-side') {
1035
+ if (ctx.state.surface === 'roof-wall') {
1036
+ return canPlaceOnRoofWall(ctx)
1037
+ }
797
1038
  if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
798
1039
  return validators.canPlaceOnWall(
799
1040
  ctx.levelId,
@@ -12,7 +12,13 @@ import type { Vector3 } from 'three'
12
12
  // PLACEMENT STATE
13
13
  // ============================================================================
14
14
 
15
- export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
15
+ export type SurfaceType =
16
+ | 'floor'
17
+ | 'wall'
18
+ | 'roof-wall'
19
+ | 'ceiling'
20
+ | 'item-surface'
21
+ | 'shelf-surface'
16
22
 
17
23
  /**
18
24
  * Tracks which surface the draft item is currently on.
@@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf
21
27
  export interface PlacementState {
22
28
  surface: SurfaceType
23
29
  wallId: string | null
30
+ /**
31
+ * Active roof-segment when `surface === 'roof-wall'` — wall-attach
32
+ * items also host on the vertical wall faces a roof segment generates
33
+ * (base walls + coplanar gable ends).
34
+ */
35
+ roofSegmentId: string | null
24
36
  ceilingId: string | null
25
37
  surfaceItemId: string | null
26
38
  /**
@@ -2,12 +2,14 @@ import {
2
2
  type AnyNodeId,
3
3
  type AssetInput,
4
4
  ItemNode,
5
+ resolveSupportSlabPatch,
5
6
  sceneRegistry,
6
7
  useScene,
7
8
  } from '@pascal-app/core'
8
9
  import { useViewer } from '@pascal-app/viewer'
9
10
  import { useCallback, useMemo, useRef } from 'react'
10
11
  import type { Vector3 } from 'three'
12
+ import usePlacementPreview from '../../../store/use-placement-preview'
11
13
  import { stripTransient } from './placement-math'
12
14
 
13
15
  interface OriginalState {
@@ -15,6 +17,10 @@ interface OriginalState {
15
17
  rotation: [number, number, number]
16
18
  side: ItemNode['side']
17
19
  parentId: string | null
20
+ // Roof-segment wall hosting — cleared/changed by surface transitions
21
+ // mid-move, so reverts must restore it alongside parentId.
22
+ roofSegmentId: ItemNode['roofSegmentId']
23
+ roofFace: ItemNode['roofFace']
18
24
  metadata: ItemNode['metadata']
19
25
  }
20
26
 
@@ -23,17 +29,25 @@ export interface DraftNodeHandle {
23
29
  readonly current: ItemNode | null
24
30
  /** Whether the current draft was adopted (move mode) vs created (create mode) */
25
31
  readonly isAdopted: boolean
26
- /** Create a new draft item at the given position. Returns the created node or null. */
32
+ /** Create a new draft item at the given position. Returns the created node or null.
33
+ * `slots` seeds painted slot overrides so duplicates keep their materials. */
27
34
  create: (
28
35
  gridPosition: Vector3,
29
36
  asset: AssetInput,
30
37
  rotation?: [number, number, number],
31
38
  scale?: [number, number, number],
39
+ slots?: ItemNode['slots'],
32
40
  ) => ItemNode | null
33
41
  /** Take ownership of an existing scene node as the draft (for move mode). */
34
42
  adopt: (node: ItemNode) => void
35
- /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
36
- commit: (finalUpdate: Partial<ItemNode>) => string | null
43
+ /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place.
44
+ * `supportElevationCap` (floor commits) is the pointer-decided surface
45
+ * elevation — it caps the persisted `supportSlabId` election so the
46
+ * commit lands on the surface the cursor pointed at. */
47
+ commit: (
48
+ finalUpdate: Partial<ItemNode>,
49
+ options?: { supportElevationCap?: number | null },
50
+ ) => string | null
37
51
  /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
38
52
  destroy: () => void
39
53
  }
@@ -57,6 +71,7 @@ export function useDraftNode(): DraftNodeHandle {
57
71
  asset: AssetInput,
58
72
  rotation?: [number, number, number],
59
73
  scale?: [number, number, number],
74
+ slots?: ItemNode['slots'],
60
75
  ): ItemNode | null => {
61
76
  const currentLevelId = useViewer.getState().selection.levelId
62
77
  if (!currentLevelId) return null
@@ -69,9 +84,13 @@ export function useDraftNode(): DraftNodeHandle {
69
84
  asset,
70
85
  parentId: currentLevelId,
71
86
  metadata: { isTransient: true },
87
+ ...(slots ? { slots } : {}),
72
88
  })
73
89
 
74
90
  useScene.getState().createNode(node, currentLevelId)
91
+ usePlacementPreview
92
+ .getState()
93
+ .set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
75
94
  draftRef.current = node
76
95
  adoptedRef.current = false
77
96
  originalStateRef.current = null
@@ -92,6 +111,8 @@ export function useDraftNode(): DraftNodeHandle {
92
111
  rotation: [...node.rotation] as [number, number, number],
93
112
  side: node.side,
94
113
  parentId: node.parentId,
114
+ roofSegmentId: node.roofSegmentId,
115
+ roofFace: node.roofFace,
95
116
  metadata: node.metadata,
96
117
  }
97
118
 
@@ -102,82 +123,142 @@ export function useDraftNode(): DraftNodeHandle {
102
123
  useScene.getState().updateNode(node.id, {
103
124
  metadata: { ...meta, isTransient: true },
104
125
  })
126
+ usePlacementPreview
127
+ .getState()
128
+ .set(
129
+ node,
130
+ node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] ?? null) : null,
131
+ )
105
132
  }, [])
106
133
 
107
- const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
108
- const draft = draftRef.current
109
- if (!draft) return null
134
+ const commit = useCallback(
135
+ (
136
+ finalUpdate: Partial<ItemNode>,
137
+ options?: { supportElevationCap?: number | null },
138
+ ): string | null => {
139
+ const draft = draftRef.current
140
+ if (!draft) return null
141
+
142
+ if (adoptedRef.current) {
143
+ // Move mode: update in place (single undoable action)
144
+ const { parentId: newParentId, ...updateProps } = finalUpdate
145
+ const parentId =
146
+ newParentId ??
147
+ originalStateRef.current?.parentId ??
148
+ useViewer.getState().selection.levelId
149
+ const original = originalStateRef.current!
150
+
151
+ // Restore original state while paused — so the undo baseline is clean
152
+ useScene.getState().updateNode(draft.id, {
153
+ position: original.position,
154
+ rotation: original.rotation,
155
+ side: original.side,
156
+ parentId: original.parentId,
157
+ roofSegmentId: original.roofSegmentId,
158
+ roofFace: original.roofFace,
159
+ metadata: original.metadata,
160
+ })
161
+
162
+ // Resume → tracked update (undo reverts to original)
163
+ useScene.temporal.getState().resume()
164
+
165
+ const effectiveNode = ItemNode.parse({
166
+ ...draft,
167
+ ...updateProps,
168
+ parentId,
169
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
170
+ })
110
171
 
111
- if (adoptedRef.current) {
112
- // Move mode: update in place (single undoable action)
172
+ useScene.getState().updateNode(draft.id, {
173
+ position: updateProps.position ?? draft.position,
174
+ rotation: updateProps.rotation ?? draft.rotation,
175
+ side: updateProps.side ?? draft.side,
176
+ metadata: updateProps.metadata ?? stripTransient(draft.metadata),
177
+ parentId: parentId as string,
178
+ // Forward the roof host explicitly: strategies set it on every
179
+ // commit (segment id on a roof face, undefined elsewhere), and
180
+ // dropping it here strands the item in the roof frame without
181
+ // the segment transform.
182
+ roofSegmentId: updateProps.roofSegmentId,
183
+ roofFace: updateProps.roofFace,
184
+ // Only when the strategy decided about wallId (roof commits clear
185
+ // it) — floor/ceiling commits never managed the field.
186
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
187
+ ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, {
188
+ maxElevation: options?.supportElevationCap,
189
+ }),
190
+ })
191
+
192
+ useScene.temporal.getState().pause()
193
+
194
+ const id = draft.id
195
+ if (usePlacementPreview.getState().node?.id === id) {
196
+ usePlacementPreview.getState().clear()
197
+ }
198
+ draftRef.current = null
199
+ adoptedRef.current = false
200
+ originalStateRef.current = null
201
+ return id
202
+ }
203
+
204
+ // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
113
205
  const { parentId: newParentId, ...updateProps } = finalUpdate
114
- const parentId =
115
- newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
116
- const original = originalStateRef.current!
206
+ const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
207
+ if (!parentId) return null
117
208
 
118
- // Restore original state while paused so the undo baseline is clean
119
- useScene.getState().updateNode(draft.id, {
120
- position: original.position,
121
- rotation: original.rotation,
122
- side: original.side,
123
- parentId: original.parentId,
124
- metadata: original.metadata,
125
- })
209
+ // Delete draft while paused (invisible to undo)
210
+ useScene.getState().deleteNode(draft.id)
211
+ draftRef.current = null
126
212
 
127
- // Resumetracked update (undo reverts to original)
213
+ // Briefly resume create fresh node (the single undoable action)
128
214
  useScene.temporal.getState().resume()
129
215
 
130
- useScene.getState().updateNode(draft.id, {
216
+ const finalNode = ItemNode.parse({
217
+ name: draft.name,
218
+ asset: draft.asset,
131
219
  position: updateProps.position ?? draft.position,
132
220
  rotation: updateProps.rotation ?? draft.rotation,
221
+ scale: updateProps.scale ?? draft.scale,
133
222
  side: updateProps.side ?? draft.side,
223
+ // Carry painted slot overrides so a duplicated item keeps its materials.
224
+ ...(draft.slots ? { slots: draft.slots } : {}),
225
+ // Roof host — see the move-mode commit above for why this must be
226
+ // forwarded explicitly.
227
+ roofSegmentId: updateProps.roofSegmentId,
228
+ roofFace: updateProps.roofFace,
229
+ ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
134
230
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
135
- parentId: parentId as string,
231
+ parentId,
136
232
  })
233
+ const nodes = useScene.getState().nodes
234
+ const committedNode = ItemNode.parse({
235
+ ...finalNode,
236
+ ...resolveSupportSlabPatch(
237
+ finalNode,
238
+ { ...nodes, [finalNode.id]: finalNode },
239
+ { maxElevation: options?.supportElevationCap },
240
+ ),
241
+ })
242
+ useScene.getState().createNode(committedNode, parentId)
243
+ if (usePlacementPreview.getState().node?.id === draft.id) {
244
+ usePlacementPreview.getState().clear()
245
+ }
137
246
 
247
+ // Re-pause for next draft cycle
138
248
  useScene.temporal.getState().pause()
139
249
 
140
- const id = draft.id
141
- draftRef.current = null
142
250
  adoptedRef.current = false
143
251
  originalStateRef.current = null
144
- return id
145
- }
146
-
147
- // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
148
- const { parentId: newParentId, ...updateProps } = finalUpdate
149
- const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
150
- if (!parentId) return null
151
-
152
- // Delete draft while paused (invisible to undo)
153
- useScene.getState().deleteNode(draft.id)
154
- draftRef.current = null
155
-
156
- // Briefly resume → create fresh node (the single undoable action)
157
- useScene.temporal.getState().resume()
158
-
159
- const finalNode = ItemNode.parse({
160
- name: draft.name,
161
- asset: draft.asset,
162
- position: updateProps.position ?? draft.position,
163
- rotation: updateProps.rotation ?? draft.rotation,
164
- scale: updateProps.scale ?? draft.scale,
165
- side: updateProps.side ?? draft.side,
166
- metadata: updateProps.metadata ?? stripTransient(draft.metadata),
167
- })
168
- useScene.getState().createNode(finalNode, parentId)
169
-
170
- // Re-pause for next draft cycle
171
- useScene.temporal.getState().pause()
172
-
173
- adoptedRef.current = false
174
- originalStateRef.current = null
175
- return finalNode.id
176
- }, [])
252
+ return committedNode.id
253
+ },
254
+ [],
255
+ )
177
256
 
178
257
  const destroy = useCallback(() => {
179
258
  if (!draftRef.current) return
180
259
 
260
+ const draftId = draftRef.current.id
261
+
181
262
  if (adoptedRef.current && originalStateRef.current) {
182
263
  // Move mode: restore original state instead of deleting — but only
183
264
  // if no other system has already committed a new position for this
@@ -199,6 +280,9 @@ export function useDraftNode(): DraftNodeHandle {
199
280
  draftRef.current = null
200
281
  adoptedRef.current = false
201
282
  originalStateRef.current = null
283
+ if (usePlacementPreview.getState().node?.id === draftId) {
284
+ usePlacementPreview.getState().clear()
285
+ }
202
286
  return
203
287
  }
204
288
 
@@ -207,6 +291,8 @@ export function useDraftNode(): DraftNodeHandle {
207
291
  rotation: original.rotation,
208
292
  side: original.side,
209
293
  parentId: original.parentId,
294
+ roofSegmentId: original.roofSegmentId,
295
+ roofFace: original.roofFace,
210
296
  metadata: original.metadata,
211
297
  })
212
298
 
@@ -227,6 +313,9 @@ export function useDraftNode(): DraftNodeHandle {
227
313
  draftRef.current = null
228
314
  adoptedRef.current = false
229
315
  originalStateRef.current = null
316
+ if (usePlacementPreview.getState().node?.id === draftId) {
317
+ usePlacementPreview.getState().clear()
318
+ }
230
319
  }, [])
231
320
 
232
321
  return useMemo(