@pascal-app/editor 1.0.3 → 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 (440) hide show
  1. package/package.json +11 -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 +202 -57
  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 +36 -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 +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -6,6 +6,8 @@ import {
6
6
  type AnyNodeId,
7
7
  bboxAnchors,
8
8
  bboxCornerAnchors,
9
+ beginSceneHistoryDraft,
10
+ beginSceneHistoryPauseSession,
9
11
  cascadeDirty,
10
12
  collectDescendants,
11
13
  createSceneApi,
@@ -15,8 +17,10 @@ import {
15
17
  getEffectiveNode,
16
18
  type MovableConfig,
17
19
  nodeRegistry,
18
- pauseSceneHistory,
19
- resumeSceneHistory,
20
+ runAsSingleSceneHistoryStep,
21
+ runSceneHistoryDraftWrite,
22
+ sceneHistoryDraftRevertUpdates,
23
+ settleSceneHistoryDrafts,
20
24
  useLiveNodeOverrides,
21
25
  useLiveTransforms,
22
26
  useScene,
@@ -146,15 +150,55 @@ export function FloorplanRegistryMoveOverlay() {
146
150
  }) => FloorplanMoveTargetSession
147
151
  )({ node: movingNode, nodes: sceneNodes, sceneApi })
148
152
 
149
- // Capture snapshots of every affected node BEFORE the first apply
150
- // so the single-undo dance has a clean baseline to revert to.
151
- const snapshots = session.affectedIds
152
- .map((id) => sceneNodes[id])
153
- .filter((n): n is AnyNode => !!n)
154
- .map((n) => snapshotNode(n))
153
+ const initialAffectedIds = session.affectedIds.filter((id) => sceneNodes[id])
155
154
 
156
- 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
+ )
157
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
+ }
158
202
 
159
203
  const clearLivePreviews = () => {
160
204
  const liveTransforms = useLiveTransforms.getState()
@@ -229,15 +273,17 @@ export function FloorplanRegistryMoveOverlay() {
229
273
  hasMovedSinceStart = true
230
274
  lastPlanPoint = planPoint
231
275
  clearRejection()
232
- session.apply({
233
- planPoint,
234
- modifiers: {
235
- shiftKey: event.shiftKey,
236
- altKey: event.altKey,
237
- ctrlKey: event.ctrlKey,
238
- metaKey: event.metaKey,
239
- },
240
- })
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
+ )
241
287
  // Move "tick" — same feedback the 3D move gives, which fires whenever the
242
288
  // resolved position changes (any snapping mode, not just grid), so it
243
289
  // ticks as the item lands on each new snapped/free position.
@@ -265,7 +311,14 @@ export function FloorplanRegistryMoveOverlay() {
265
311
  }
266
312
 
267
313
  const commitFinalStateOrRevert = () => {
268
- 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())
269
322
  const freshPlacement = isFreshPlacementMetadata(
270
323
  (useScene.getState().nodes[movingNode.id] as { metadata?: unknown } | undefined)
271
324
  ?.metadata,
@@ -283,7 +336,7 @@ export function FloorplanRegistryMoveOverlay() {
283
336
 
284
337
  // Keep ordinary teardown order. Validated replacements can refuse, so they
285
338
  // claim teardown only after success, before clearing the moving node.
286
- if (!atomicPreview) setMovingNodeOrigin('2d')
339
+ if (!freshPlacement) setMovingNodeOrigin('2d')
287
340
 
288
341
  // Sessions with a `commit` hook own their atomic write (e.g.
289
342
  // wall move emits creates + deletes + updates via the junction
@@ -293,7 +346,7 @@ export function FloorplanRegistryMoveOverlay() {
293
346
  if (commitValid && freshPlacement) {
294
347
  // Subtrees finalize from the preview. Staging a session commit would publish
295
348
  // an unvalidated graph, including for presets created outside our factory.
296
- if (!atomicPreview) session.commit?.()
349
+ if (!atomicPreview) ownWrite(() => session.commit?.())
297
350
  const stagedNode = useScene.getState().nodes[movingNode.id]
298
351
  const preview = usePlacementPreview.getState().node
299
352
  const effective = stagedNode ? getEffectiveNode(stagedNode) : null
@@ -310,67 +363,66 @@ export function FloorplanRegistryMoveOverlay() {
310
363
  showRejection,
311
364
  )
312
365
  : null
313
- if (!committedId && atomicPreview) return false
366
+ if (!committedId) return false
367
+ setMovingNodeOrigin('2d')
314
368
  if (atomicPreview) {
315
- setMovingNodeOrigin('2d')
316
369
  for (const id of session.affectedIds) {
317
370
  useLiveTransforms.getState().clear(id)
318
371
  useLiveNodeOverrides.getState().clear(id)
319
372
  }
320
373
  }
321
- if (historyPaused) {
322
- resumeSceneHistory(useScene)
323
- historyPaused = false
324
- }
325
374
  if (committedId) {
375
+ settleSceneHistoryDrafts(session.affectedIds)
326
376
  sfxEmitter.emit('sfx:item-place')
327
377
  useViewer.getState().setSelection({ selectedIds: [committedId] })
328
378
  }
379
+ endDrafts()
329
380
  return
330
381
  }
331
382
 
332
383
  if (commitValid && session.commit) {
333
- restoreSnapshots(snapshots)
334
- if (historyPaused) {
335
- resumeSceneHistory(useScene)
336
- historyPaused = false
337
- }
338
- session.commit()
384
+ revertOwnWrites()
385
+ recordDrop(() => {
386
+ session.commit?.()
387
+ endTransientMarkers()
388
+ })
339
389
  sfxEmitter.emit('sfx:item-place')
340
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
390
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
341
391
  return
342
392
  }
343
393
 
344
394
  const sceneState = useScene.getState().nodes
345
395
  const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
346
- for (const snap of snapshots) {
347
- 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]
348
400
  if (!current) continue
349
401
  const data: Record<string, unknown> = {}
350
402
  let changed = false
351
- for (const [key, before] of Object.entries(snap.data)) {
403
+ for (const [key, before] of Object.entries(update.data)) {
352
404
  const after = (current as unknown as Record<string, unknown>)[key]
353
405
  if (!deepEqual(before, after)) {
354
406
  data[key] = Array.isArray(after) ? [...(after as unknown[])] : after
355
407
  changed = true
356
408
  }
357
409
  }
358
- if (changed) finalUpdates.push({ id: snap.id, data })
410
+ if (changed) finalUpdates.push({ id: update.id, data })
359
411
  }
360
412
 
361
- for (const snap of snapshots) {
362
- const current = sceneState[snap.id]
413
+ for (const id of initialAffectedIds) {
414
+ const current = sceneState[id]
363
415
  if (!current || !isFreshPlacementMetadata((current as { metadata?: unknown }).metadata)) {
364
416
  continue
365
417
  }
366
- const existing = finalUpdates.find((update) => update.id === snap.id)
418
+ const existing = finalUpdates.find((update) => update.id === id)
367
419
  const metadata = stripPlacementMetadataFlags((current as { metadata?: unknown }).metadata)
368
420
  if (existing) {
369
421
  existing.data.metadata = metadata
370
422
  existing.data.visible = true
371
423
  } else {
372
424
  finalUpdates.push({
373
- id: snap.id,
425
+ id,
374
426
  data: { metadata, visible: true },
375
427
  })
376
428
  }
@@ -381,25 +433,21 @@ export function FloorplanRegistryMoveOverlay() {
381
433
  // 1. Revert to baseline while history is still paused.
382
434
  // 2. Resume history.
383
435
  // 3. Re-apply the final state — recorded as one tracked change.
384
- restoreSnapshots(snapshots)
385
- if (historyPaused) {
386
- resumeSceneHistory(useScene)
387
- historyPaused = false
388
- }
389
- useScene.getState().updateNodes(finalUpdates)
436
+ revertOwnWrites()
437
+ recordDrop(() => {
438
+ useScene.getState().updateNodes(finalUpdates)
439
+ endTransientMarkers()
440
+ })
390
441
  sfxEmitter.emit('sfx:item-place')
391
442
  // Re-select the moved node(s) — mirrors the legacy 3D move
392
443
  // tool. The action menu cleared selection on Move click so
393
444
  // selection-gated affordances (slab/ceiling boundary editor,
394
445
  // etc.) would unmount during the drag; restoring it here
395
446
  // brings them back at the new position.
396
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
447
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
397
448
  } else {
398
- restoreSnapshots(snapshots)
399
- if (historyPaused) {
400
- resumeSceneHistory(useScene)
401
- historyPaused = false
402
- }
449
+ revertOwnWrites()
450
+ endDrafts()
403
451
  }
404
452
  }
405
453
 
@@ -474,7 +522,7 @@ export function FloorplanRegistryMoveOverlay() {
474
522
  }
475
523
  event.preventDefault()
476
524
  event.stopImmediatePropagation()
477
- session.flipSide()
525
+ ownWrite(() => session.flipSide?.())
478
526
  sfxEmitter.emit('sfx:item-rotate')
479
527
  return
480
528
  }
@@ -492,22 +540,16 @@ export function FloorplanRegistryMoveOverlay() {
492
540
  if (isFreshPlacementMetadata((movingNode as { metadata?: unknown }).metadata)) {
493
541
  emitter.emit('tool:cancel')
494
542
  if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id))
495
- useScene.getState().deleteNode(movingNode.id)
496
- if (historyPaused) {
497
- resumeSceneHistory(useScene)
498
- historyPaused = false
499
- }
543
+ ownWrite(() => useScene.getState().deleteNode(movingNode.id))
544
+ endDrafts()
500
545
  clearLivePreviews()
501
546
  useAlignmentGuides.getState().clear()
502
547
  setMovingNode(null)
503
548
  return
504
549
  }
505
- // Revert untracked, then resume — no history entry.
506
- restoreSnapshots(snapshots)
507
- if (historyPaused) {
508
- resumeSceneHistory(useScene)
509
- historyPaused = false
510
- }
550
+ // Revert as the overlay's own write: no history entry.
551
+ revertOwnWrites()
552
+ endDrafts()
511
553
  // Clear any live previews the session wrote. Slab / ceiling
512
554
  // 2D move stages a translation delta in `useLiveTransforms`;
513
555
  // wall move publishes `{ start, end, ... }` to
@@ -516,7 +558,7 @@ export function FloorplanRegistryMoveOverlay() {
516
558
  // position.
517
559
  clearLivePreviews()
518
560
  // Restore selection cleared by the action menu's Move click.
519
- useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
561
+ useViewer.getState().setSelection({ selectedIds: initialAffectedIds })
520
562
  setMovingNode(null)
521
563
  }
522
564
 
@@ -535,7 +577,7 @@ export function FloorplanRegistryMoveOverlay() {
535
577
  window.removeEventListener('keydown', onKey, true)
536
578
  // Unmount cleanup. `historyPaused === true` here means none of
537
579
  // our terminal paths (commit, Esc) ran in this overlay — they
538
- // each call `resumeSceneHistory` and flip the flag.
580
+ // each end the drafts and flip the flag.
539
581
  //
540
582
  // If `movingNodeOrigin === '3d'`, a 3D move tool finalised
541
583
  // while our overlay was still mounted (split view); the live
@@ -554,10 +596,10 @@ export function FloorplanRegistryMoveOverlay() {
554
596
  const finalisedBy3D = useEditor.getState().movingNodeOrigin === '3d'
555
597
  if (!finalisedBy3D && !freshPlacement) {
556
598
  if (hasMovedSinceStart) {
557
- restoreSnapshots(snapshots)
599
+ revertOwnWrites()
558
600
  }
559
601
  }
560
- resumeSceneHistory(useScene)
602
+ endDrafts()
561
603
  }
562
604
  // Belt-and-suspenders: clear any live previews on abnormal
563
605
  // unmount paths too. Slab / ceiling sessions write to
@@ -933,11 +975,7 @@ export function FloorplanRegistryMoveOverlay() {
933
975
  if (isFreshPlacement) {
934
976
  emitter.emit('tool:cancel')
935
977
  if (!ownsSubtree && !isInteractionSubtreeDraft(movingNode.id)) {
936
- const temporal = useScene.temporal.getState()
937
- const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
938
- if (wasTracking) temporal.pause()
939
- useScene.getState().deleteNode(movingNode.id)
940
- if (wasTracking) temporal.resume()
978
+ runSceneHistoryDraftWrite(() => useScene.getState().deleteNode(movingNode.id))
941
979
  }
942
980
  }
943
981
  for (const relatedEntry of relatedEntries) {
@@ -970,33 +1008,6 @@ export function FloorplanRegistryMoveOverlay() {
970
1008
  return null
971
1009
  }
972
1010
 
973
- // ── Snapshot helpers (shared shape with floorplan-registry-layer) ───
974
- //
975
- // Kept inline here to avoid a circular dependency through a shared
976
- // utility module. If a third call site shows up, extract.
977
-
978
- type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
979
-
980
- function snapshotNode(node: AnyNode): NodeSnapshot {
981
- const data: Record<string, unknown> = {}
982
- for (const [key, value] of Object.entries(node)) {
983
- if (key === 'id' || key === 'type' || key === 'object') continue
984
- data[key] = Array.isArray(value) ? [...(value as unknown[])] : value
985
- }
986
- return { id: node.id, data }
987
- }
988
-
989
- function restoreSnapshots(snapshots: NodeSnapshot[]) {
990
- const updates = snapshots
991
- .filter(
992
- (s) =>
993
- !useScene.getState().nodes[s.id] ||
994
- !deepEqual(s.data, snapshotNode(useScene.getState().nodes[s.id]!).data),
995
- )
996
- .map((s) => ({ id: s.id, data: s.data }))
997
- if (updates.length) useScene.getState().updateNodes(updates)
998
- }
999
-
1000
1011
  function deepEqual(a: unknown, b: unknown): boolean {
1001
1012
  if (a === b) return true
1002
1013
  if (Array.isArray(a) && Array.isArray(b)) {
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import { useScene } from '@pascal-app/core'
4
+ import { commitDimensionValue, isDimensionEditAllowed } from './floorplan-dimension-edit-overlay'
5
+
6
+ type Change = { id: AnyNodeId; data: Partial<AnyNode> }
7
+
8
+ function wall(id: string, start: [number, number], end: [number, number]): AnyNode {
9
+ return {
10
+ object: 'node',
11
+ id,
12
+ type: 'wall',
13
+ parentId: 'level_1',
14
+ visible: true,
15
+ metadata: {},
16
+ start,
17
+ end,
18
+ thickness: 0.15,
19
+ children: [],
20
+ } as unknown as AnyNode
21
+ }
22
+
23
+ function nodes(): Record<string, AnyNode> {
24
+ return {
25
+ level_1: {
26
+ object: 'node',
27
+ id: 'level_1',
28
+ type: 'level',
29
+ parentId: null,
30
+ visible: true,
31
+ metadata: {},
32
+ level: 0,
33
+ children: ['wall_a', 'wall_b', 'dim_1'],
34
+ } as unknown as AnyNode,
35
+ wall_a: wall('wall_a', [0, 0], [3, 0]),
36
+ wall_b: wall('wall_b', [3, 0], [3, 4]),
37
+ dim_1: {
38
+ object: 'node',
39
+ id: 'dim_1',
40
+ type: 'construction-dimension',
41
+ parentId: 'level_1',
42
+ visible: true,
43
+ metadata: {},
44
+ children: [],
45
+ } as unknown as AnyNode,
46
+ }
47
+ }
48
+
49
+ let initial: ReturnType<typeof useScene.getState>
50
+ let applied: Change[]
51
+ let updated: Change[]
52
+ let dirty: AnyNodeId[]
53
+
54
+ beforeEach(() => {
55
+ initial = useScene.getState()
56
+ applied = []
57
+ updated = []
58
+ dirty = []
59
+ useScene.setState({
60
+ nodes: nodes() as ReturnType<typeof useScene.getState>['nodes'],
61
+ applyNodeChanges: ((changes: { update?: Change[] }) => {
62
+ applied.push(...(changes.update ?? []))
63
+ }) as ReturnType<typeof useScene.getState>['applyNodeChanges'],
64
+ updateNode: ((id: AnyNodeId, data: Partial<AnyNode>) => {
65
+ updated.push({ id, data })
66
+ }) as ReturnType<typeof useScene.getState>['updateNode'],
67
+ markDirty: ((id: AnyNodeId) => {
68
+ dirty.push(id)
69
+ }) as ReturnType<typeof useScene.getState>['markDirty'],
70
+ })
71
+ })
72
+
73
+ afterEach(() => {
74
+ useScene.setState(initial, true)
75
+ })
76
+
77
+ describe('commitDimensionValue', () => {
78
+ test('a wall dimension drives the wall end and the wall sharing that corner', () => {
79
+ const outcome = commitDimensionValue({
80
+ target: {
81
+ ownerNodeId: 'wall_a' as AnyNodeId,
82
+ witnessStart: [0, 0],
83
+ witnessEnd: [3, 0],
84
+ value: 3,
85
+ },
86
+ nextLength: 3.5,
87
+ })
88
+
89
+ expect(outcome).toEqual({ ok: true })
90
+ const byId = new Map(applied.map((change) => [change.id, change.data]))
91
+ expect(byId.get('wall_a' as AnyNodeId)).toEqual({ start: [0, 0], end: [3.5, 0] })
92
+ expect(byId.get('wall_b' as AnyNodeId)).toEqual({ start: [3.5, 0], end: [3, 4] })
93
+ expect(dirty.sort()).toEqual(['wall_a', 'wall_b'] as AnyNodeId[])
94
+ expect(updated).toEqual([])
95
+ })
96
+
97
+ test('an undrivable wall dimension is refused and nothing is written', () => {
98
+ const outcome = commitDimensionValue({
99
+ target: {
100
+ ownerNodeId: 'wall_a' as AnyNodeId,
101
+ witnessStart: [1.5, -1],
102
+ witnessEnd: [1.5, 1],
103
+ value: 2,
104
+ },
105
+ nextLength: 2.5,
106
+ })
107
+
108
+ expect(outcome.ok).toBe(false)
109
+ expect(applied).toEqual([])
110
+ expect(updated).toEqual([])
111
+ })
112
+
113
+ test('an undrivable construction dimension keeps the typed text as an override', () => {
114
+ const outcome = commitDimensionValue({
115
+ target: {
116
+ ownerNodeId: 'dim_1' as AnyNodeId,
117
+ witnessStart: [10, 10],
118
+ witnessEnd: [12, 10],
119
+ value: 2,
120
+ },
121
+ nextLength: 3,
122
+ unit: 'metric',
123
+ })
124
+
125
+ expect(outcome).toEqual({ ok: true })
126
+ expect(applied).toEqual([])
127
+ expect(updated).toHaveLength(1)
128
+ expect(updated[0]?.id).toBe('dim_1' as AnyNodeId)
129
+ expect(typeof (updated[0]?.data as { textOverride?: unknown }).textOverride).toBe('string')
130
+ })
131
+ })
132
+
133
+ describe('isDimensionEditAllowed', () => {
134
+ const idle = {
135
+ workspaceMode: 'edit',
136
+ mode: 'select',
137
+ isPreviewMode: false,
138
+ isCaptureMode: false,
139
+ isFirstPersonMode: false,
140
+ } as const
141
+
142
+ test('only an editable scene in select mode of the edit workspace', () => {
143
+ expect(isDimensionEditAllowed(idle, { readOnly: false })).toBe(true)
144
+ expect(isDimensionEditAllowed(idle, { readOnly: true })).toBe(false)
145
+ expect(isDimensionEditAllowed({ ...idle, workspaceMode: 'studio' }, { readOnly: false })).toBe(
146
+ false,
147
+ )
148
+ expect(isDimensionEditAllowed({ ...idle, workspaceMode: 'sheets' }, { readOnly: false })).toBe(
149
+ false,
150
+ )
151
+ expect(isDimensionEditAllowed({ ...idle, mode: 'delete' }, { readOnly: false })).toBe(false)
152
+ expect(isDimensionEditAllowed({ ...idle, isPreviewMode: true }, { readOnly: false })).toBe(
153
+ false,
154
+ )
155
+ })
156
+ })