@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -6,20 +6,28 @@ import {
6
6
  type AnyNodeId,
7
7
  bboxAnchors,
8
8
  bboxCornerAnchors,
9
+ beginSceneHistoryDraft,
10
+ beginSceneHistoryPauseSession,
11
+ cascadeDirty,
12
+ collectDescendants,
9
13
  createSceneApi,
10
14
  emitter,
11
15
  type FloorplanMoveTargetSession,
12
16
  type GroupMoveSnapResult,
17
+ getEffectiveNode,
13
18
  type MovableConfig,
14
19
  nodeRegistry,
15
- pauseSceneHistory,
16
- resumeSceneHistory,
20
+ runAsSingleSceneHistoryStep,
21
+ runSceneHistoryDraftWrite,
22
+ sceneHistoryDraftRevertUpdates,
23
+ settleSceneHistoryDrafts,
17
24
  useLiveNodeOverrides,
18
25
  useLiveTransforms,
19
26
  useScene,
20
27
  } from '@pascal-app/core'
28
+ import { nodeLevelFrame } from '@pascal-app/core/procedural-items'
21
29
  import { useViewer } from '@pascal-app/viewer'
22
- import { useEffect } from 'react'
30
+ import { useEffect, useMemo } from 'react'
23
31
  import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
24
32
  import { isHistoryShortcut } from '../../lib/history'
25
33
  import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
@@ -30,6 +38,7 @@ import {
30
38
  } from '../../lib/rigid-plan-svg-transform'
31
39
  import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
32
40
  import { sfxEmitter } from '../../lib/sfx-bus'
41
+ import { surfaceAttachmentId } from '../../lib/surface-attachment'
33
42
  import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
34
43
  import useAlignmentGuides from '../../store/use-alignment-guides'
35
44
  import useEditor, {
@@ -37,7 +46,11 @@ import useEditor, {
37
46
  isGridSnapActive,
38
47
  isMagneticSnapActive,
39
48
  } from '../../store/use-editor'
40
- import { useMovingNode } from '../../store/use-interaction-scope'
49
+ import useInteractionScope, {
50
+ isInteractionSubtreeDraft,
51
+ useMovingNode,
52
+ } from '../../store/use-interaction-scope'
53
+ import usePlacementPreview from '../../store/use-placement-preview'
41
54
  import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
42
55
 
43
56
  // Figma-style alignment snap threshold. Meters in world space; 8cm gives
@@ -67,7 +80,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
67
80
  * cursor → meters accounts for pan / zoom / building rotation.
68
81
  */
69
82
  export function FloorplanRegistryMoveOverlay() {
70
- const movingNode = useMovingNode()
83
+ const source = useMovingNode()
84
+ const movingNode = useMemo(
85
+ () =>
86
+ source && isInteractionSubtreeDraft(source.id)
87
+ ? (useScene.getState().nodes[source.id] ?? source)
88
+ : source,
89
+ [source],
90
+ )
71
91
  const setMovingNode = useEditor((s) => s.setMovingNode)
72
92
  const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
73
93
 
@@ -93,7 +113,15 @@ export function FloorplanRegistryMoveOverlay() {
93
113
  return [m.x, m.y]
94
114
  }
95
115
 
96
- const isPointerOverFloorplanScene = (clientX: number, clientY: number): boolean => {
116
+ const isPointerOverFloorplanScene = (event: PointerEvent): boolean => {
117
+ // View controls overlap the SVG viewport; clicking one must not place the draft.
118
+ if (
119
+ (event.target as Element | null)?.closest?.(
120
+ 'button, input, select, textarea, [role="button"]',
121
+ )
122
+ )
123
+ return false
124
+ const { clientX, clientY } = event
97
125
  // The scene's `<g>` only covers painted SVG elements, so hovers over
98
126
  // empty grid background often target the parent SVG. Bounds keep the
99
127
  // cursor active anywhere inside the floor-plan viewport.
@@ -108,6 +136,8 @@ export function FloorplanRegistryMoveOverlay() {
108
136
  )
109
137
  }
110
138
 
139
+ const ownsSubtree = useInteractionScope.getState().adoptSubtree(movingNode.id)
140
+
111
141
  // ── Path 1 — kind-owned `floorplanMoveTarget` ───────────────────
112
142
  if (hasMoveTarget && def?.floorplanMoveTarget) {
113
143
  const sceneNodes = useScene.getState().nodes
@@ -120,15 +150,55 @@ export function FloorplanRegistryMoveOverlay() {
120
150
  }) => FloorplanMoveTargetSession
121
151
  )({ node: movingNode, nodes: sceneNodes, sceneApi })
122
152
 
123
- // Capture snapshots of every affected node BEFORE the first apply
124
- // so the single-undo dance has a clean baseline to revert to.
125
- const snapshots = session.affectedIds
126
- .map((id) => sceneNodes[id])
127
- .filter((n): n is AnyNode => !!n)
128
- .map((n) => snapshotNode(n))
153
+ const initialAffectedIds = session.affectedIds.filter((id) => sceneNodes[id])
129
154
 
130
- pauseSceneHistory(useScene)
155
+ // History is paused only around this overlay's own writes (`ownWrite`): every affected
156
+ // node is a carried draft (core's history-drafts), so writes others make mid-move record
157
+ // as their own steps and space detection reconciles them. The drop writes through a
158
+ // session keyed by the moving node, which the 3D mover shares in split view.
159
+ const draftEnds = initialAffectedIds.map((id) =>
160
+ beginSceneHistoryDraft(
161
+ id,
162
+ isFreshPlacementMetadata(sceneNodes[id]!.metadata) ? null : sceneNodes[id]!,
163
+ ),
164
+ )
131
165
  let historyPaused = true
166
+ const endDrafts = () => {
167
+ for (const end of draftEnds) end()
168
+ historyPaused = false
169
+ }
170
+ const ownWrite = runSceneHistoryDraftWrite
171
+ // Puts back only the fields this move wrote and still holds; a rename or any other write
172
+ // someone made mid-move stays.
173
+ // The drop ends the carry: a transient marker (the 3D mover's, in split view) goes even
174
+ // when someone else edited the metadata meanwhile and the move no longer holds it.
175
+ const endTransientMarkers = () => {
176
+ const nodes = useScene.getState().nodes
177
+ const updates = session.affectedIds.flatMap((id) => {
178
+ const metadata = nodes[id]?.metadata as Record<string, unknown> | undefined
179
+ if (!metadata?.isTransient) return []
180
+ const { isTransient: _transient, ...rest } = metadata
181
+ return [{ id, data: { metadata: rest } }]
182
+ })
183
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
184
+ }
185
+ const revertOwnWrites = () =>
186
+ ownWrite(() => {
187
+ const updates = sceneHistoryDraftRevertUpdates(session.affectedIds)
188
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
189
+ })
190
+ const recordDrop = (write: () => void) => {
191
+ const drop = beginSceneHistoryPauseSession(useScene, { gesture: movingNode.id })
192
+ try {
193
+ // The move's write and its transient cleanup are one undo entry and one commit.
194
+ drop.commitStep(() => runAsSingleSceneHistoryStep(useScene, write))
195
+ // Only a successful drop ends ownership; a rejected write can still be retried.
196
+ settleSceneHistoryDrafts(session.affectedIds)
197
+ endDrafts()
198
+ } finally {
199
+ drop.end()
200
+ }
201
+ }
132
202
 
133
203
  const clearLivePreviews = () => {
134
204
  const liveTransforms = useLiveTransforms.getState()
@@ -137,7 +207,12 @@ export function FloorplanRegistryMoveOverlay() {
137
207
  for (const id of session.affectedIds) {
138
208
  liveTransforms.clear(id)
139
209
  liveOverrides.clear(id)
140
- scene.markDirty(id)
210
+ const api = createSceneApi(useScene)
211
+ for (const dirtyId of new Set([
212
+ ...cascadeDirty(id, { scene: api }),
213
+ ...collectDescendants(id, { scene: api }),
214
+ ]))
215
+ scene.markDirty(dirtyId)
141
216
  }
142
217
  }
143
218
 
@@ -161,9 +236,29 @@ export function FloorplanRegistryMoveOverlay() {
161
236
  // the 2D pane the overlay claimed R forever and the 3D flip went dead.
162
237
  let pointerOverFloorplan = false
163
238
  const onPointerTrack = (event: PointerEvent) => {
164
- pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
239
+ pointerOverFloorplan = isPointerOverFloorplanScene(event)
165
240
  }
166
241
 
242
+ let rejectionIndicator: SVGCircleElement | null = null
243
+ let lastPlanPoint: [number, number] | null = null
244
+ const clearRejection = () => {
245
+ rejectionIndicator?.remove()
246
+ rejectionIndicator = null
247
+ }
248
+ const showRejection = () => {
249
+ if (!lastPlanPoint) return
250
+ clearRejection()
251
+ rejectionIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
252
+ rejectionIndicator.setAttribute('cx', String(lastPlanPoint[0]))
253
+ rejectionIndicator.setAttribute('cy', String(lastPlanPoint[1]))
254
+ rejectionIndicator.setAttribute('r', '0.15')
255
+ rejectionIndicator.setAttribute('fill', 'none')
256
+ rejectionIndicator.setAttribute('stroke', '#ef4444')
257
+ rejectionIndicator.setAttribute('stroke-width', '1.5')
258
+ rejectionIndicator.setAttribute('vector-effect', 'non-scaling-stroke')
259
+ rejectionIndicator.setAttribute('pointer-events', 'none')
260
+ scene.appendChild(rejectionIndicator)
261
+ }
167
262
  const onMove = (event: PointerEvent) => {
168
263
  // Skip 3D-canvas / other-UI cursor moves so the overlay only
169
264
  // tracks pointer events that actually correspond to a floor-plan
@@ -172,25 +267,36 @@ export function FloorplanRegistryMoveOverlay() {
172
267
  // hovers over empty grid background — without it, the cursor
173
268
  // only updated the shelf when it happened to brush over an
174
269
  // existing SVG entry, leaving the move feeling "stuck" elsewhere.
175
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
270
+ if (!isPointerOverFloorplanScene(event)) return
176
271
  const planPoint = toMeters(event.clientX, event.clientY)
177
272
  if (!planPoint) return
178
273
  hasMovedSinceStart = true
179
- session.apply({
180
- planPoint,
181
- modifiers: {
182
- shiftKey: event.shiftKey,
183
- altKey: event.altKey,
184
- ctrlKey: event.ctrlKey,
185
- metaKey: event.metaKey,
186
- },
187
- })
274
+ lastPlanPoint = planPoint
275
+ clearRejection()
276
+ ownWrite(() =>
277
+ session.apply({
278
+ planPoint,
279
+ modifiers: {
280
+ shiftKey: event.shiftKey,
281
+ altKey: event.altKey,
282
+ ctrlKey: event.ctrlKey,
283
+ metaKey: event.metaKey,
284
+ },
285
+ }),
286
+ )
188
287
  // Move "tick" — same feedback the 3D move gives, which fires whenever the
189
288
  // resolved position changes (any snapping mode, not just grid), so it
190
289
  // ticks as the item lands on each new snapped/free position.
191
290
  const movedId = session.affectedIds[0]
192
291
  const moved = movedId ? useScene.getState().nodes[movedId] : undefined
193
- const pos = (moved as { position?: [number, number, number] } | undefined)?.position
292
+ const effective = moved ? getEffectiveNode(moved) : undefined
293
+ const pos =
294
+ effective?.type === 'item' || effective?.type === 'procedural-item'
295
+ ? nodeLevelFrame(effective.id, {
296
+ ...useScene.getState().nodes,
297
+ [effective.id]: effective,
298
+ }).position
299
+ : (effective as { position?: [number, number, number] } | undefined)?.position
194
300
  if (pos) {
195
301
  const key = movementSfxStepKey({
196
302
  coords: [pos[0], pos[2]],
@@ -205,18 +311,32 @@ export function FloorplanRegistryMoveOverlay() {
205
311
  }
206
312
 
207
313
  const commitFinalStateOrRevert = () => {
208
- const commitValid = session.canCommit()
314
+ if (!useScene.getState().nodes[movingNode.id]) {
315
+ revertOwnWrites()
316
+ endDrafts()
317
+ clearLivePreviews()
318
+ return
319
+ }
320
+ // Some registered sessions stage their final polygon from canCommit.
321
+ const commitValid = ownWrite(() => session.canCommit())
209
322
  const freshPlacement = isFreshPlacementMetadata(
210
323
  (useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined)
211
324
  ?.metadata,
212
325
  )
213
326
 
214
- // Claim ownership of the drag teardown so the 3D move tool's
215
- // unmount-time cleanup skips its restore-from-snapshot — see
216
- // `movingNodeOrigin` in `use-editor.tsx`. Set here (before any
217
- // `setMovingNode(null)`) so that by the time the 3D effect's
218
- // cleanup runs the origin is observable in the store.
219
- setMovingNodeOrigin('2d')
327
+ const atomicPreview =
328
+ freshPlacement &&
329
+ (ownsSubtree ||
330
+ ('children' in movingNode && movingNode.children.length > 0) ||
331
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
332
+ if (atomicPreview && !commitValid) {
333
+ showRejection()
334
+ return false
335
+ }
336
+
337
+ // Keep ordinary teardown order. Validated replacements can refuse, so they
338
+ // claim teardown only after success, before clearing the moving node.
339
+ if (!freshPlacement) setMovingNodeOrigin('2d')
220
340
 
221
341
  // Sessions with a `commit` hook own their atomic write (e.g.
222
342
  // wall move emits creates + deletes + updates via the junction
@@ -224,70 +344,85 @@ export function FloorplanRegistryMoveOverlay() {
224
344
  // and Phase 2's resume — but Phase 2's write is delegated, and
225
345
  // we skip the snapshot-diff finalUpdates path.
226
346
  if (commitValid && freshPlacement) {
227
- session.commit?.()
347
+ // Subtrees finalize from the preview. Staging a session commit would publish
348
+ // an unvalidated graph, including for presets created outside our factory.
349
+ if (!atomicPreview) ownWrite(() => session.commit?.())
228
350
  const stagedNode = useScene.getState().nodes[movingNode.id]
351
+ const preview = usePlacementPreview.getState().node
352
+ const effective = stagedNode ? getEffectiveNode(stagedNode) : null
353
+ const candidate =
354
+ effective?.visible === false && preview?.id === movingNode.id ? preview : effective
229
355
  const committedId = stagedNode
230
356
  ? commitFreshPlacementSubtree(
231
357
  movingNode.id as AnyNodeId,
232
358
  {
359
+ ...(atomicPreview ? candidate : {}),
233
360
  metadata: stripPlacementMetadataFlags(stagedNode.metadata),
234
361
  visible: true,
235
362
  } as Partial<AnyNode>,
363
+ showRejection,
236
364
  )
237
365
  : null
238
- if (historyPaused) {
239
- resumeSceneHistory(useScene)
240
- historyPaused = false
366
+ if (!committedId) return false
367
+ setMovingNodeOrigin('2d')
368
+ if (atomicPreview) {
369
+ for (const id of session.affectedIds) {
370
+ useLiveTransforms.getState().clear(id)
371
+ useLiveNodeOverrides.getState().clear(id)
372
+ }
241
373
  }
242
374
  if (committedId) {
375
+ settleSceneHistoryDrafts(session.affectedIds)
243
376
  sfxEmitter.emit('sfx:item-place')
244
377
  useViewer.getState().setSelection({ selectedIds: [committedId] })
245
378
  }
379
+ endDrafts()
246
380
  return
247
381
  }
248
382
 
249
383
  if (commitValid && session.commit) {
250
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
251
- if (historyPaused) {
252
- resumeSceneHistory(useScene)
253
- historyPaused = false
254
- }
255
- session.commit()
384
+ revertOwnWrites()
385
+ recordDrop(() => {
386
+ session.commit?.()
387
+ endTransientMarkers()
388
+ })
256
389
  sfxEmitter.emit('sfx:item-place')
257
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
390
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
258
391
  return
259
392
  }
260
393
 
261
394
  const sceneState = useScene.getState().nodes
262
395
  const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
263
- for (const snap of snapshots) {
264
- const current = sceneState[snap.id]
396
+ // Reapply everything the revert removes, including newly introduced fields and
397
+ // attachment entries held on hosts outside affectedIds. Foreign writes stay recorded.
398
+ for (const update of sceneHistoryDraftRevertUpdates(session.affectedIds)) {
399
+ const current = sceneState[update.id]
265
400
  if (!current) continue
266
401
  const data: Record<string, unknown> = {}
267
402
  let changed = false
268
- for (const [key, before] of Object.entries(snap.data)) {
403
+ for (const [key, before] of Object.entries(update.data)) {
269
404
  const after = (current as unknown as Record<string, unknown>)[key]
270
405
  if (!deepEqual(before, after)) {
271
406
  data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
272
407
  changed = true
273
408
  }
274
409
  }
275
- if (changed) finalUpdates.push({ id: snap.id, data })
410
+ if (changed) finalUpdates.push({ id: update.id, data })
276
411
  }
277
412
 
278
- for (const snap of snapshots) {
279
- const current = sceneState[snap.id]
413
+ for (const id of initialAffectedIds) {
414
+ const current = sceneState[id]
280
415
  if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
281
416
  continue
282
417
  }
283
- const existing = finalUpdates.find((update) => update.id === snap.id)
418
+ const existing = finalUpdates.find((update) => update.id === id)
284
419
  const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
285
420
  if (existing) {
286
421
  existing.data.metadata = metadata
287
422
  existing.data.visible = true
288
423
  } else {
289
424
  finalUpdates.push({
290
- id: snap.id,
425
+ id,
291
426
  data: { metadata, visible: true },
292
427
  })
293
428
  }
@@ -298,25 +433,21 @@ export function FloorplanRegistryMoveOverlay() {
298
433
  // 1. Revert to baseline while history is still paused.
299
434
  // 2. Resume history.
300
435
  // 3. Re-apply the final state — recorded as one tracked change.
301
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
302
- if (historyPaused) {
303
- resumeSceneHistory(useScene)
304
- historyPaused = false
305
- }
306
- useScene.getState().updateNodes(finalUpdates)
436
+ revertOwnWrites()
437
+ recordDrop(() => {
438
+ useScene.getState().updateNodes(finalUpdates)
439
+ endTransientMarkers()
440
+ })
307
441
  sfxEmitter.emit('sfx:item-place')
308
442
  // Re-select the moved node(s) — mirrors the legacy 3D move
309
443
  // tool. The action menu cleared selection on Move click so
310
444
  // selection-gated affordances (slab/ceiling boundary editor,
311
445
  // etc.) would unmount during the drag; restoring it here
312
446
  // brings them back at the new position.
313
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
447
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
314
448
  } else {
315
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
316
- if (historyPaused) {
317
- resumeSceneHistory(useScene)
318
- historyPaused = false
319
- }
449
+ revertOwnWrites()
450
+ endDrafts()
320
451
  }
321
452
  }
322
453
 
@@ -325,7 +456,7 @@ export function FloorplanRegistryMoveOverlay() {
325
456
  // Bounding-rect check (see `isPointerOverFloorplanScene`) — same
326
457
  // reason as `onMove`: commits should land for any pointer-up
327
458
  // inside the SVG viewport, including empty grid background.
328
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
459
+ if (!isPointerOverFloorplanScene(event)) return
329
460
  if (!hasMovedSinceStart) return
330
461
 
331
462
  // Commit using the LAST pointermove's state — no re-apply at
@@ -344,7 +475,7 @@ export function FloorplanRegistryMoveOverlay() {
344
475
  // pointermove right before pointerup, so the trade-off lands
345
476
  // on the side of WYSIWYG.
346
477
 
347
- commitFinalStateOrRevert()
478
+ if (commitFinalStateOrRevert() === false) return
348
479
  setMovingNode(null)
349
480
 
350
481
  // Swallow the click event that follows this pointer-up — the
@@ -391,7 +522,7 @@ export function FloorplanRegistryMoveOverlay() {
391
522
  }
392
523
  event.preventDefault()
393
524
  event.stopImmediatePropagation()
394
- session.flipSide()
525
+ ownWrite(() => session.flipSide?.())
395
526
  sfxEmitter.emit('sfx:item-rotate')
396
527
  return
397
528
  }
@@ -408,22 +539,17 @@ export function FloorplanRegistryMoveOverlay() {
408
539
  setMovingNodeOrigin('2d')
409
540
  if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
410
541
  emitter.emit('tool:cancel')
411
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
412
- if (historyPaused) {
413
- resumeSceneHistory(useScene)
414
- historyPaused = false
415
- }
542
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
543
+ ownWrite(() => useScene.getState().deleteNode(movingNode.id))
544
+ endDrafts()
416
545
  clearLivePreviews()
417
546
  useAlignmentGuides.getState().clear()
418
547
  setMovingNode(null)
419
548
  return
420
549
  }
421
- // Revert untracked, then resume — no history entry.
422
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
423
- if (historyPaused) {
424
- resumeSceneHistory(useScene)
425
- historyPaused = false
426
- }
550
+ // Revert as the overlay's own write: no history entry.
551
+ revertOwnWrites()
552
+ endDrafts()
427
553
  // Clear any live previews the session wrote. Slab / ceiling
428
554
  // 2D move stages a translation delta in `useLiveTransforms`;
429
555
  // wall move publishes `{ start, end, ... }` to
@@ -432,7 +558,7 @@ export function FloorplanRegistryMoveOverlay() {
432
558
  // position.
433
559
  clearLivePreviews()
434
560
  // Restore selection cleared by the action menu's Move click.
435
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
561
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
436
562
  setMovingNode(null)
437
563
  }
438
564
 
@@ -444,13 +570,14 @@ export function FloorplanRegistryMoveOverlay() {
444
570
  // then pre-empts it so only one handler flips.
445
571
  window.addEventListener('keydown', onKey, true)
446
572
  return () => {
573
+ clearRejection()
447
574
  window.removeEventListener('pointermove', onPointerTrack)
448
575
  window.removeEventListener('pointermove', onMove)
449
576
  window.removeEventListener('pointerup', onPointerUp)
450
577
  window.removeEventListener('keydown', onKey, true)
451
578
  // Unmount cleanup. `historyPaused === true` here means none of
452
579
  // our terminal paths (commit, Esc) ran in this overlay — they
453
- // each call `resumeSceneHistory` and flip the flag.
580
+ // each end the drafts and flip the flag.
454
581
  //
455
582
  // If `movingNodeOrigin === '3d'`, a 3D move tool finalised
456
583
  // while our overlay was still mounted (split view); the live
@@ -464,21 +591,22 @@ export function FloorplanRegistryMoveOverlay() {
464
591
  // our unmount
465
592
  // are now distinguished by the origin flag — no scene-state
466
593
  // diff heuristic required.
594
+ const freshPlacement = ownsSubtree
467
595
  if (historyPaused) {
468
- if (hasMovedSinceStart) {
469
- const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
470
- if (!finalisedBy3D) {
471
- useScene.getState().updateNodes(snapshotsToUpdates(snapshots))
596
+ const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
597
+ if (!finalisedBy3D && !freshPlacement) {
598
+ if (hasMovedSinceStart) {
599
+ revertOwnWrites()
472
600
  }
473
601
  }
474
- resumeSceneHistory(useScene)
602
+ endDrafts()
475
603
  }
476
604
  // Belt-and-suspenders: clear any live previews on abnormal
477
605
  // unmount paths too. Slab / ceiling sessions write to
478
606
  // `useLiveTransforms`; wall sessions write to
479
607
  // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
480
608
  // tool's cleanup isn't there to clear them for us.
481
- clearLivePreviews()
609
+ if (!freshPlacement) clearLivePreviews()
482
610
  // Sessions that publish Figma-style alignment guides during `apply`
483
611
  // (item / shelf / column) leave them in the store; this cleanup runs
484
612
  // after every terminal path (commit + Esc both unmount via
@@ -589,7 +717,7 @@ export function FloorplanRegistryMoveOverlay() {
589
717
  // Same target guard as Path 1 — pointer must be over the floor
590
718
  // plan scene; otherwise we'd react to 3D-canvas moves with garbage
591
719
  // plan coords.
592
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
720
+ if (!isPointerOverFloorplanScene(event)) return
593
721
  const m = toMeters(event.clientX, event.clientY)
594
722
  if (!m) return
595
723
  forcePlace = event.altKey
@@ -740,7 +868,7 @@ export function FloorplanRegistryMoveOverlay() {
740
868
 
741
869
  const onPointerUp = (event: PointerEvent) => {
742
870
  if (event.button !== 0) return
743
- if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return
871
+ if (!isPointerOverFloorplanScene(event)) return
744
872
 
745
873
  const snapped = lastSnapped
746
874
  if (!snapped) return
@@ -754,8 +882,14 @@ export function FloorplanRegistryMoveOverlay() {
754
882
  ]
755
883
  : snapped.rotation
756
884
  const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
757
- setMovingNodeOrigin('2d')
885
+ const atomicPreview =
886
+ isFreshPlacement &&
887
+ (ownsSubtree ||
888
+ ('children' in movingNode && movingNode.children.length > 0) ||
889
+ surfaceAttachmentId(useScene.getState().nodes[movingNode.id] ?? movingNode) !== null)
890
+ if (!atomicPreview) setMovingNodeOrigin('2d')
758
891
  if (!lastPositionValid && !forcePlace) {
892
+ if (atomicPreview) setMovingNodeOrigin('2d')
759
893
  for (const relatedEntry of relatedEntries) {
760
894
  relatedEntry.removeAttribute('transform')
761
895
  }
@@ -766,6 +900,7 @@ export function FloorplanRegistryMoveOverlay() {
766
900
  }
767
901
  let selectedId = movingNode.id as AnyNodeId
768
902
  if (originalPath) {
903
+ if (atomicPreview) setMovingNodeOrigin('2d')
769
904
  // Polyline kinds: shift every point by the committed delta and
770
905
  // write `path`. Strip the fresh-placement flags on first drop.
771
906
  const dx = sx - originalPosition[0]
@@ -795,18 +930,21 @@ export function FloorplanRegistryMoveOverlay() {
795
930
  return
796
931
  }
797
932
  if (isFreshPlacement) {
798
- selectedId =
799
- commitFreshPlacementSubtree(
800
- movingNode.id as AnyNodeId,
801
- {
802
- position: [sx, oldY, sz],
803
- ...rotationPatch,
804
- metadata: stripPlacementMetadataFlags(
805
- (movingNode as { metadata?: unknown }).metadata,
806
- ),
807
- visible: true,
808
- } as Partial<AnyNode>,
809
- ) ?? selectedId
933
+ const committedId = commitFreshPlacementSubtree(
934
+ movingNode.id as AnyNodeId,
935
+ {
936
+ position: [sx, oldY, sz],
937
+ ...rotationPatch,
938
+ metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
939
+ visible: true,
940
+ } as Partial<AnyNode>,
941
+ )
942
+ if (!committedId && atomicPreview) {
943
+ boxEl.setAttribute('stroke', '#ef4444')
944
+ return
945
+ }
946
+ if (atomicPreview) setMovingNodeOrigin('2d')
947
+ selectedId = committedId ?? selectedId
810
948
  } else {
811
949
  useScene.getState().updateNode(
812
950
  movingNode.id as AnyNodeId,
@@ -836,11 +974,9 @@ export function FloorplanRegistryMoveOverlay() {
836
974
  setMovingNodeOrigin('2d')
837
975
  if (isFreshPlacement) {
838
976
  emitter.emit('tool:cancel')
839
- const temporal = useScene.temporal.getState()
840
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
841
- if (wasTracking) temporal.pause()
842
- useScene.getState().deleteNode(movingNode.id as AnyNodeId)
843
- if (wasTracking) temporal.resume()
977
+ if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
978
+ runSceneHistoryDraftWrite(() => useScene.getState().deleteNode(movingNode.id))
979
+ }
844
980
  }
845
981
  for (const relatedEntry of relatedEntries) {
846
982
  relatedEntry.removeAttribute('transform')
@@ -872,26 +1008,6 @@ export function FloorplanRegistryMoveOverlay() {
872
1008
  return null
873
1009
  }
874
1010
 
875
- // ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
876
- //
877
- // Kept inline here to avoid a circular dependency through a shared
878
- // utility module. If a third call site shows up, extract.
879
-
880
- type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
881
-
882
- function snapshotNode(node: AnyNode): NodeSnapshot {
883
- const data: Record<string, unknown> = {}
884
- for (const [key, value] of Object.entries(node)) {
885
- if (key === 'id' || key === 'type' || key === 'object') continue
886
- data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
887
- }
888
- return { id: node.id, data }
889
- }
890
-
891
- function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
892
- return snapshots.map((s) => ({ id: s.id, data: s.data }))
893
- }
894
-
895
1011
  function deepEqual(a: unknown, b: unknown): boolean {
896
1012
  if (a === b) return true
897
1013
  if (Array.isArray(a) && Array.isArray(b)) {