@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
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ applySceneOperationPatch,
6
+ BuildingNode,
7
+ clearSceneHistory,
8
+ LevelNode,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { createSessionWrites } from './session-writes'
13
+
14
+ // A live session (a floor-plan drag, a height scrub) takes back only what it
15
+ // wrote itself: an edit a collaborator lands on the same node mid-drag survives
16
+ // the cancel and the commit.
17
+
18
+ globalThis.requestAnimationFrame ??= (callback) => {
19
+ callback(0)
20
+ return 0
21
+ }
22
+ globalThis.cancelAnimationFrame ??= () => {}
23
+
24
+ const level = LevelNode.parse({ id: 'level_writes', parentId: 'building_writes' })
25
+ const building = BuildingNode.parse({ id: 'building_writes', children: [level.id] })
26
+ const wall = WallNode.parse({ id: 'wall_writes', parentId: level.id, start: [0, 0], end: [4, 0] })
27
+ const nodes = () => useScene.getState().nodes as Record<string, AnyNode>
28
+ const data = () => JSON.parse(JSON.stringify(nodes()))
29
+ const id = wall.id as AnyNodeId
30
+
31
+ beforeEach(() => {
32
+ useScene.setState({
33
+ nodes: {
34
+ [building.id]: building,
35
+ [level.id]: { ...level, children: [wall.id] },
36
+ [wall.id]: wall,
37
+ },
38
+ rootNodeIds: [building.id],
39
+ dirtyNodes: new Set(),
40
+ collections: {},
41
+ materials: {},
42
+ readOnly: false,
43
+ })
44
+ clearSceneHistory()
45
+ })
46
+
47
+ /** A collaborator's change, applied outside the session like a remote operation. */
48
+ const remote = (patch: Partial<WallNode>) =>
49
+ useScene.setState({ nodes: { ...nodes(), [id]: { ...nodes()[id]!, ...patch } as AnyNode } })
50
+
51
+ describe('session writes', () => {
52
+ test('cancel takes back the session’s fields and keeps a collaborator’s edit', () => {
53
+ const writes = createSessionWrites()
54
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [6, 0] } }]))
55
+ remote({ name: 'Kitchen wall' })
56
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [7, 0] } }]))
57
+ writes.revert()
58
+ expect(nodes()[id]).toMatchObject({ end: [4, 0], name: 'Kitchen wall' })
59
+ })
60
+
61
+ test('commit re-applies only the session’s fields', () => {
62
+ const writes = createSessionWrites()
63
+ writes.record(() => useScene.getState().updateNodes([{ id, data: { end: [6, 0] } }]))
64
+ remote({ name: 'Kitchen wall' })
65
+ const changes = writes.changes()
66
+ expect(changes.update).toEqual([{ id, data: { end: [6, 0] } }])
67
+ writes.revert()
68
+ useScene.getState().applyNodeChanges(changes)
69
+ expect(nodes()[id]).toMatchObject({ end: [6, 0], name: 'Kitchen wall' })
70
+ })
71
+
72
+ test('a session that previews through overrides writes nothing back', () => {
73
+ const writes = createSessionWrites()
74
+ writes.record(() => {})
75
+ remote({ name: 'Kitchen wall' })
76
+ const before = data()
77
+ writes.revert()
78
+ expect(data()).toEqual(before)
79
+ expect(writes.changes()).toEqual({ create: [], update: [], delete: [] })
80
+ })
81
+
82
+ test('nodes the session created go, nodes it deleted come back, outside history', () => {
83
+ const writes = createSessionWrites()
84
+ const extra = WallNode.parse({
85
+ id: 'wall_extra',
86
+ parentId: level.id,
87
+ start: [4, 0],
88
+ end: [4, 4],
89
+ })
90
+ const before = data()
91
+ writes.record(() => {
92
+ useScene.getState().createNode(extra, level.id as AnyNodeId)
93
+ useScene.getState().deleteNode(id)
94
+ })
95
+ const past = useScene.temporal.getState().pastStates.length
96
+ writes.revert()
97
+ expect(data()).toEqual(before)
98
+ expect(useScene.temporal.getState().pastStates).toHaveLength(past)
99
+ })
100
+
101
+ test('a watched node: another local writer’s draft goes back, a collaborator’s change stays', () => {
102
+ const writes = createSessionWrites()
103
+ writes.watch([id])
104
+ // The 3D tool mounted beside the plan drafts the same node…
105
+ useScene.getState().updateNodes([{ id, data: { metadata: { isTransient: true } } }])
106
+ // …while a collaborator's rename arrives from the host.
107
+ applySceneOperationPatch({
108
+ materialChanges: [],
109
+ nodeUpdates: [{ id, data: { name: 'Kitchen wall' }, removeFields: [] }],
110
+ nodeCreates: [],
111
+ nodeDeletes: [],
112
+ })
113
+ writes.revert()
114
+ expect(nodes()[id]).toMatchObject({ metadata: {}, name: 'Kitchen wall' })
115
+ // The revert ended the watch: later writes are no longer the session's.
116
+ useScene.getState().updateNodes([{ id, data: { end: [5, 0] } }])
117
+ writes.revert()
118
+ expect(nodes()[id]).toMatchObject({ end: [5, 0] })
119
+ })
120
+ })
@@ -0,0 +1,205 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ isApplyingRemoteSceneChange,
5
+ pauseSceneHistory,
6
+ resumeSceneHistory,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+
10
+ type NodeMap = Record<string, AnyNode>
11
+
12
+ /**
13
+ * What a live session wrote to the scene: for each node it touched, the value
14
+ * before its first write and the value it last wrote (`undefined` for
15
+ * "absent"). Only writes made inside `record` count, so a collaborator's change
16
+ * applied between two pointer moves is never mistaken for the session's.
17
+ */
18
+ export type SessionWrites = {
19
+ /** Runs one step of the session and records what it wrote. */
20
+ record: <T>(run: () => T) => T
21
+ /**
22
+ * Also records every local write to `ids` until the session ends, whoever
23
+ * makes it (the 3D tool mounted beside a plan session in split view drafts
24
+ * the same node) — never a collaborator's change applied from the host.
25
+ */
26
+ watch: (ids: readonly string[]) => void
27
+ /** Stops watching (the revert stops too). */
28
+ stop: () => void
29
+ /**
30
+ * Takes back what the session wrote, outside history. A node changed again
31
+ * since by someone else keeps that change: only the session's own fields go
32
+ * back. A session that never wrote the scene touches nothing.
33
+ */
34
+ revert: () => void
35
+ /** The session's writes as one change batch from the originals (the commit a legacy session makes). */
36
+ changes: () => {
37
+ create: { node: AnyNode; parentId?: AnyNodeId }[]
38
+ update: { id: AnyNodeId; data: Partial<AnyNode> }[]
39
+ delete: AnyNodeId[]
40
+ }
41
+ readonly size: number
42
+ }
43
+
44
+ /** The value a field had before the session first wrote it, and the value it last wrote. */
45
+ type FieldWrite = { had: boolean; from: unknown; to: unknown }
46
+
47
+ type Entry = {
48
+ original: AnyNode | undefined
49
+ wrote: AnyNode | undefined
50
+ /** Fields the session changed on a node that existed before and after its write. */
51
+ fields: Map<string, FieldWrite>
52
+ }
53
+
54
+ export function createSessionWrites(): SessionWrites {
55
+ const writes = new Map<string, Entry>()
56
+ let unwatch: (() => void) | null = null
57
+
58
+ const note = (before: NodeMap, after: NodeMap, only?: ReadonlySet<string>) => {
59
+ if (before === after) return
60
+ const ids = only ?? new Set([...Object.keys(before), ...Object.keys(after)])
61
+ for (const id of ids) {
62
+ const prev = before[id]
63
+ const next = after[id]
64
+ if (prev === next) continue
65
+ let entry = writes.get(id)
66
+ if (!entry) {
67
+ entry = { original: prev, wrote: next, fields: new Map() }
68
+ writes.set(id, entry)
69
+ } else entry.wrote = next
70
+ if (!(prev && next)) continue
71
+ const from = prev as Record<string, unknown>
72
+ const to = next as Record<string, unknown>
73
+ for (const key of new Set([...Object.keys(from), ...Object.keys(to)])) {
74
+ if (Object.is(from[key], to[key])) continue
75
+ const field = entry.fields.get(key)
76
+ if (field) field.to = to[key]
77
+ else entry.fields.set(key, { had: Object.hasOwn(from, key), from: from[key], to: to[key] })
78
+ }
79
+ }
80
+ }
81
+
82
+ const created = (id: string) => {
83
+ const entry = writes.get(id)
84
+ return !!entry && !entry.original && !!entry.wrote
85
+ }
86
+ const deleted = (id: string) => {
87
+ const entry = writes.get(id)
88
+ return !!entry && !!entry.original && !entry.wrote
89
+ }
90
+
91
+ // Field by field: a field the session wrote goes back unless someone else
92
+ // has written it since; every other field keeps its current value. A child
93
+ // list drops the session's creations and gets back what it deleted.
94
+ const revertFields = (current: AnyNode, entry: Entry): AnyNode | null => {
95
+ const merged: Record<string, unknown> = { ...current }
96
+ const cur = current as Record<string, unknown>
97
+ let changed = false
98
+ for (const [key, { had, from, to }] of entry.fields) {
99
+ if (key === 'children' && Array.isArray(cur[key]) && Array.isArray(from)) {
100
+ const children = (cur[key] as string[]).filter((id) => !created(id))
101
+ for (const id of from as string[])
102
+ if (deleted(id) && !children.includes(id)) children.push(id)
103
+ if (children.join('|') !== (cur[key] as string[]).join('|')) {
104
+ merged[key] = children
105
+ changed = true
106
+ }
107
+ continue
108
+ }
109
+ if (!Object.is(cur[key], to)) continue
110
+ if (had) merged[key] = from
111
+ else delete merged[key]
112
+ changed = true
113
+ }
114
+ if (!changed) return null
115
+ // Nothing else moved: the node the session found, as it was (same object).
116
+ const original = entry.original as Record<string, unknown> | undefined
117
+ if (
118
+ original &&
119
+ Object.keys(merged).length === Object.keys(original).length &&
120
+ Object.keys(merged).every((key) => Object.is(merged[key], original[key]))
121
+ )
122
+ return entry.original!
123
+ return merged as AnyNode
124
+ }
125
+
126
+ const stop = () => {
127
+ unwatch?.()
128
+ unwatch = null
129
+ }
130
+
131
+ return {
132
+ watch(ids) {
133
+ stop()
134
+ const scope = new Set(ids)
135
+ unwatch = useScene.subscribe((state, previous) => {
136
+ if (isApplyingRemoteSceneChange()) return
137
+ note(previous.nodes as NodeMap, state.nodes as NodeMap, scope)
138
+ })
139
+ },
140
+ stop,
141
+ record(run) {
142
+ const before = useScene.getState().nodes as NodeMap
143
+ try {
144
+ return run()
145
+ } finally {
146
+ note(before, useScene.getState().nodes as NodeMap)
147
+ }
148
+ },
149
+ revert() {
150
+ stop()
151
+ if (writes.size === 0) return
152
+ const nodes = useScene.getState().nodes as NodeMap
153
+ const next: NodeMap = { ...nodes }
154
+ const touched: AnyNode[] = []
155
+ for (const [id, entry] of writes) {
156
+ const current = nodes[id]
157
+ if (!entry.original) {
158
+ if (!current) continue
159
+ delete next[id]
160
+ } else if (!current) {
161
+ next[id] = entry.original
162
+ } else {
163
+ const reverted = revertFields(current, entry)
164
+ if (!reverted) continue
165
+ next[id] = reverted
166
+ }
167
+ touched.push(entry.original ?? current!)
168
+ }
169
+ writes.clear()
170
+ if (touched.length === 0) return
171
+ pauseSceneHistory(useScene)
172
+ try {
173
+ useScene.setState({ nodes: next as ReturnType<typeof useScene.getState>['nodes'] })
174
+ } finally {
175
+ resumeSceneHistory(useScene)
176
+ }
177
+ const { markDirty } = useScene.getState()
178
+ for (const node of touched) {
179
+ if (next[node.id]) markDirty(node.id as AnyNodeId)
180
+ if (node.parentId && next[node.parentId]) markDirty(node.parentId as AnyNodeId)
181
+ }
182
+ },
183
+ changes() {
184
+ const batch: ReturnType<SessionWrites['changes']> = { create: [], update: [], delete: [] }
185
+ for (const [id, { original, wrote, fields }] of writes) {
186
+ if (!original && wrote) {
187
+ batch.create.push({ node: wrote, parentId: (wrote.parentId ?? undefined) as AnyNodeId })
188
+ } else if (original && !wrote) {
189
+ batch.delete.push(id as AnyNodeId)
190
+ } else if (original && wrote) {
191
+ const data: Record<string, unknown> = {}
192
+ // Child lists follow the creates and deletes.
193
+ for (const [key, { from, to }] of fields)
194
+ if (key !== 'children' && JSON.stringify(from) !== JSON.stringify(to)) data[key] = to
195
+ if (Object.keys(data).length)
196
+ batch.update.push({ id: id as AnyNodeId, data: data as Partial<AnyNode> })
197
+ }
198
+ }
199
+ return batch
200
+ },
201
+ get size() {
202
+ return writes.size
203
+ },
204
+ }
205
+ }
@@ -0,0 +1,62 @@
1
+ import { useEffect } from 'react'
2
+ import { useSidebarStore } from '../components/ui/primitives/sidebar'
3
+ import type { SidebarTab } from '../components/ui/sidebar/tab-bar'
4
+ import useEditor from '../store/use-editor'
5
+
6
+ /** The narrowest a reopened sidebar panel may be (the layout's resize floor). */
7
+ export const SIDEBAR_MIN_WIDTH = 300
8
+
9
+ // The desktop layout's rail tabs, published while it is mounted. Hosts pick
10
+ // their own tabs, so a shortcut names the panels it would like, in order, and
11
+ // gets the first one the host has.
12
+ let sidebarTabIds: readonly string[] = []
13
+
14
+ export function setSidebarTabIds(ids: readonly string[]): void {
15
+ sidebarTabIds = ids
16
+ }
17
+
18
+ /**
19
+ * Pick panel `id` and expand a collapsed sidebar onto it, at a usable width.
20
+ * The pick goes first: expanding first would reopen the panel left open for a
21
+ * moment, and a host may arm that panel's tool as it opens (Paint).
22
+ */
23
+ export function showSidebarPanel(id: string): void {
24
+ useEditor.getState().setActiveSidebarPanel(id)
25
+ const sidebar = useSidebarStore.getState()
26
+ if (!sidebar.isCollapsed) return
27
+ sidebar.setIsCollapsed(false)
28
+ if (sidebar.width < SIDEBAR_MIN_WIDTH) sidebar.setWidth(SIDEBAR_MIN_WIDTH)
29
+ }
30
+
31
+ /**
32
+ * Show the first of `preferred` the host provides (`showSidebarPanel`).
33
+ * Returns the panel shown, or null when the host has none of them (e.g. on
34
+ * mobile).
35
+ */
36
+ export function openSidebarPanel(preferred: readonly string[]): string | null {
37
+ const id = preferred.find((candidate) => sidebarTabIds.includes(candidate))
38
+ if (!id) return null
39
+ showSidebarPanel(id)
40
+ return id
41
+ }
42
+
43
+ /**
44
+ * The rail tab on screen: the panel picked in this workspace when the rail has
45
+ * it, else the first tab with a panel. `activeSidebarPanel` follows what shows,
46
+ * so every reader agrees with the screen, while the pick (`sidebarPanels`)
47
+ * stays: a plugin's panel joins the rail only once the scene's plugins load,
48
+ * so a panel restored on reload is missing for a moment and comes back then.
49
+ */
50
+ export function useShownSidebarPanel(tabs: readonly SidebarTab[]): string {
51
+ const activePanel = useEditor((s) => s.activeSidebarPanel)
52
+ const pickedPanel = useEditor((s) => s.sidebarPanels[s.workspaceMode] ?? s.activeSidebarPanel)
53
+ const shownPanel = tabs.some((tab) => tab.id === pickedPanel)
54
+ ? pickedPanel
55
+ : ((tabs.find((tab) => !tab.noPanel) ?? tabs[0])?.id ?? pickedPanel)
56
+
57
+ useEffect(() => {
58
+ if (shownPanel !== activePanel) useEditor.setState({ activeSidebarPanel: shownPanel })
59
+ }, [shownPanel, activePanel])
60
+
61
+ return shownPanel
62
+ }
@@ -9,7 +9,7 @@ import { siteBoundaryHandlesEnabled } from './site-boundary'
9
9
  */
10
10
 
11
11
  const MODES: Mode[] = ['select', 'edit', 'delete', 'build', 'material-paint', 'terrain-sculpt']
12
- const PHASES: Phase[] = ['site', 'structure', 'furnish']
12
+ const PHASES: Phase[] = ['site', 'building']
13
13
 
14
14
  describe('siteBoundaryHandlesEnabled', () => {
15
15
  test('sculpt hides the handles in every phase', () => {
@@ -29,15 +29,13 @@ describe('siteBoundaryHandlesEnabled', () => {
29
29
 
30
30
  test('select mode offers them outside the site phase', () => {
31
31
  // The flags double as the affordance that *enters* site editing, so select mode
32
- // has to show them from structure/furnish or the lot becomes unreachable.
33
- expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'structure' })).toBe(true)
34
- expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'furnish' })).toBe(true)
32
+ // has to show them from the building or the lot becomes unreachable.
33
+ expect(siteBoundaryHandlesEnabled({ mode: 'select', phase: 'building' })).toBe(true)
35
34
  })
36
35
 
37
36
  test('a drafting or painting mode outside the site phase hides them', () => {
38
37
  for (const mode of ['edit', 'delete', 'build', 'material-paint'] as Mode[]) {
39
- expect(siteBoundaryHandlesEnabled({ mode, phase: 'structure' })).toBe(false)
40
- expect(siteBoundaryHandlesEnabled({ mode, phase: 'furnish' })).toBe(false)
38
+ expect(siteBoundaryHandlesEnabled({ mode, phase: 'building' })).toBe(false)
41
39
  }
42
40
  })
43
41
 
@@ -11,7 +11,7 @@ export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
11
11
  * clause was added to only one of them.
12
12
  *
13
13
  * The disagreement was reachable in exactly one configuration, which is why it was
14
- * easy to miss: arming sculpt from the 2D view promotes `viewMode` to `split` *and*
14
+ * easy to miss: arming sculpt from the 2D view opens 3D beside the plan *and*
15
15
  * the phase to `site`, so the floorplan's `phase === 'site'` term went true at the
16
16
  * same moment the 3D editor's `!sculpting` term went false — handles gone in one
17
17
  * pane, live in the other. Grabbing one then calls `begin({kind: 'handle-drag'})`,
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
+ import {
3
+ GROUP_MOVE_DRAG_LABEL,
4
+ GROUP_ROTATE_DRAG_LABEL,
5
+ ROTATE_HANDLE_DRAG_LABEL,
6
+ } from './contextual-help'
3
7
  import {
4
8
  cycleSnappingModeIn,
5
9
  DEFAULT_SNAPPING_MODE,
@@ -49,6 +53,12 @@ describe('resolveSnapFlags', () => {
49
53
  })
50
54
 
51
55
  describe('per-context snapping', () => {
56
+ it('rotation defaults to angles and cycles independently to free rotation', () => {
57
+ expect(defaultSnappingModeFor('rotation')).toBe('angles')
58
+ expect(snappingModesFor('rotation')).toEqual(['angles', 'off'])
59
+ expect(cycleSnappingModeIn('rotation', 'angles')).toBe('off')
60
+ expect(cycleSnappingModeIn('rotation', 'off')).toBe('angles')
61
+ })
52
62
  it('items default to magnetic alignment with no angle lock', () => {
53
63
  expect(defaultSnappingModeFor('item')).toBe('lines')
54
64
  expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
@@ -120,6 +130,10 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
120
130
  tool: string | null = null,
121
131
  ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode })
122
132
 
133
+ it('dividing a room draws like a wall draft (wall, with angles)', () => {
134
+ expect(ctx({ kind: 'room-divide' })).toBe('wall')
135
+ })
136
+
123
137
  it('translating a whole structural node has no angle (polygon, not wall)', () => {
124
138
  expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
125
139
  expect(ctx({ kind: 'moving', nodeType: 'slab' })).toBe('polygon')
@@ -132,7 +146,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
132
146
  expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
133
147
  expect(
134
148
  ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
135
- ).toBeNull()
149
+ ).toBe('rotation')
150
+ expect(ctx({ kind: 'handle-drag', handle: GROUP_ROTATE_DRAG_LABEL })).toBe('rotation')
136
151
  })
137
152
 
138
153
  it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
@@ -1,5 +1,9 @@
1
1
  import type { SnapProfile } from '@pascal-app/core'
2
- import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
+ import {
3
+ GROUP_MOVE_DRAG_LABEL,
4
+ GROUP_ROTATE_DRAG_LABEL,
5
+ ROTATE_HANDLE_DRAG_LABEL,
6
+ } from './contextual-help'
3
7
 
4
8
  /**
5
9
  * Snapping mode is a single global, user-cyclable control that maps onto the
@@ -68,14 +72,13 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
68
72
  // alignment lines). The mode is remembered per context and shown live, so it's
69
73
  // never a silent surprise — it just matches what you're doing.
70
74
 
71
- export type SnapContext = 'wall' | 'item' | 'polygon'
75
+ export type SnapContext = 'wall' | 'item' | 'polygon' | 'rotation'
72
76
 
73
77
  // The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
74
78
  type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
75
79
 
76
80
  // `modes[0]` is the cycle's first entry; `default` is what a context starts at.
77
- // The 'wall' set is the ONLY one with an angle lock — it applies solely when
78
- // you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
81
+ // The 'wall' set includes angle lock when setting a segment's direction.
79
82
  // Translating a whole wall, curving it, or drawing/moving a slab can't change an
80
83
  // angle, so those use the no-angle 'polygon' set.
81
84
  const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
@@ -88,9 +91,10 @@ const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
88
91
  // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
89
92
  // default, NO angle lock.
90
93
  polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
94
+ rotation: { modes: ['angles', 'off'], default: 'angles' },
91
95
  }
92
96
 
93
- export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
97
+ export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon', 'rotation']
94
98
 
95
99
  export function snappingModesFor(context: SnapContext): SnappingMode[] {
96
100
  return SNAP_PROFILES[context].modes
@@ -157,8 +161,12 @@ export function snapContextOf(args: {
157
161
  // like `profileOf` so `snapping-mode` need not import the registry; defaults
158
162
  // to `true` (the structural draw default) when not supplied.
159
163
  draftDirectionalOf?: (typeOrTool: string) => boolean
164
+ // The paint tool's region sub-mode draws lines and boxes on a surface: the
165
+ // no-angle 'polygon' set (grid / lines / off), idle or mid-gesture.
166
+ paintRegion?: boolean
160
167
  }): SnapContext | null {
161
- const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args
168
+ const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf, paintRegion } = args
169
+ if (paintRegion && mode === 'material-paint') return 'polygon'
162
170
  // The group-move gizmo translates the whole selection — same no-angle
163
171
  // treatment as a single-node move, so Shift cycles the 'item' modes and the
164
172
  // HUD shows the item snapping chips for the drag.
@@ -166,12 +174,16 @@ export function snapContextOf(args: {
166
174
  return 'item'
167
175
  }
168
176
  switch (scope.kind) {
177
+ // Dividing a room draws a segment like a wall draft: same modes, same keys.
178
+ case 'room-divide':
179
+ return 'wall'
169
180
  case 'mesh-editing':
170
181
  return scope.nodeId
171
182
  ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
172
183
  : null
173
184
  case 'handle-drag':
174
- if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
185
+ if (scope.handle === ROTATE_HANDLE_DRAG_LABEL || scope.handle === GROUP_ROTATE_DRAG_LABEL)
186
+ return 'rotation'
175
187
  return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
176
188
  case 'placing':
177
189
  case 'moving':
@@ -0,0 +1,53 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Matrix4, Ray, Vector3 } from 'three'
3
+ import {
4
+ createSpatialDragPlane,
5
+ intersectSpatialDragPlane,
6
+ spatialDragLocalY,
7
+ } from './spatial-drag-plane'
8
+
9
+ test('controller translation gives continuous motion at the grabbed height', () => {
10
+ const start = new Vector3(2, 2.4, 1)
11
+ const ray = new Ray(new Vector3(2, 3.4, 2), new Vector3(0, -1, -1).normalize())
12
+ const plane = createSpatialDragPlane(start, ray)
13
+ for (let step = 0; step <= 10; step++) {
14
+ const moved = ray.clone()
15
+ moved.origin.z += step / 10
16
+ const hit = intersectSpatialDragPlane(moved, plane, new Vector3())!
17
+ expect(hit.x).toBeCloseTo(2)
18
+ expect(hit.y).toBeCloseTo(2.4)
19
+ expect(hit.z).toBeCloseTo(1 + step / 10)
20
+ }
21
+ })
22
+
23
+ test('horizontal rays use a stable facing plane and grazing rays are ignored', () => {
24
+ const ray = new Ray(new Vector3(0, 1, 2), new Vector3(0, 0, -1))
25
+ const plane = createSpatialDragPlane(new Vector3(0, 1, 0), ray)
26
+ ray.origin.x = 0.1
27
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.x).toBeCloseTo(0.1)
28
+ ray.direction.set(1, 0, -0.001).normalize()
29
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())).toBeNull()
30
+ })
31
+
32
+ test('drag plane follows a rotated, scaled building frame', () => {
33
+ const transform = new Matrix4().makeRotationZ(Math.PI / 4).scale(new Vector3(0.2, 0.2, 0.2))
34
+ const point = new Vector3(0, 2, 0).applyMatrix4(transform)
35
+ const up = new Vector3(0, 1, 0).transformDirection(transform)
36
+ const ray = new Ray(point.clone().add(up), up.clone().negate())
37
+ const plane = createSpatialDragPlane(point, ray, transform)
38
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.distanceTo(point)).toBeLessThan(1e-8)
39
+ })
40
+
41
+ test('height drag uses model metres at human and miniature scales without an initial jump', () => {
42
+ for (const scale of [1, 0.2, 2]) {
43
+ const frame = new Matrix4().makeRotationY(0.7).scale(new Vector3(scale, scale, scale))
44
+ frame.setPosition(2, 4, -3)
45
+ const inverse = frame.clone().invert()
46
+ const start = new Vector3(1, 2.5, 3).applyMatrix4(frame)
47
+ const initialY = spatialDragLocalY(start, inverse)
48
+ for (let step = 0; step <= 10; step++) {
49
+ const moved = start.clone().add(new Vector3(0, (scale * step) / 10, 0))
50
+ expect(2.5 + spatialDragLocalY(moved, inverse) - initialY).toBeCloseTo(2.5 + step / 10)
51
+ }
52
+ }
53
+ })
@@ -0,0 +1,19 @@
1
+ import { type Matrix4, Plane, type Ray, Vector3 } from 'three'
2
+
3
+ export function createSpatialDragPlane(point: Vector3, ray: Ray, localToWorld?: Matrix4) {
4
+ const normal = new Vector3(0, 1, 0)
5
+ if (localToWorld) normal.transformDirection(localToWorld)
6
+ // A nearly horizontal controller ray cannot reliably intersect a floor plane.
7
+ if (Math.abs(normal.dot(ray.direction)) < 0.2) normal.copy(ray.direction)
8
+ return new Plane().setFromNormalAndCoplanarPoint(normal, point)
9
+ }
10
+
11
+ export function intersectSpatialDragPlane(ray: Ray, plane: Plane, target: Vector3) {
12
+ if (Math.abs(plane.normal.dot(ray.direction)) < 0.05) return null
13
+ return ray.intersectPlane(plane, target)
14
+ }
15
+
16
+ // Freeze this frame at grab time; world metres differ from model metres in God view.
17
+ export function spatialDragLocalY(worldPoint: Vector3, worldToLocal: Matrix4) {
18
+ return worldPoint.clone().applyMatrix4(worldToLocal).y
19
+ }