@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
@@ -0,0 +1,175 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { availableViews, editorHostViewRegistry, registerEditorHostView } from '../lib/editor-views'
3
+ import useEditor, { normalizePersistedEditorUiState } from './use-editor'
4
+ import {
5
+ activeViewLayout,
6
+ DEFAULT_VIEW_LAYOUT,
7
+ isViewVisible,
8
+ sceneLayout,
9
+ VIEW_2D,
10
+ VIEW_3D,
11
+ visibleScene,
12
+ } from './view-layout'
13
+
14
+ function layout() {
15
+ return activeViewLayout(useEditor.getState())
16
+ }
17
+
18
+ afterEach(() => {
19
+ useEditor.getState().setCaptureMode(false)
20
+ useEditor.getState().setFirstPersonMode(false)
21
+ useEditor.getState().setWorkspaceMode('edit')
22
+ useEditor.setState({ viewLayouts: {} })
23
+ editorHostViewRegistry.reset()
24
+ })
25
+
26
+ describe('the stage layout', () => {
27
+ test('a view sits in one pane: picking the other pane’s view swaps them', () => {
28
+ useEditor.getState().setViewLayout(sceneLayout('split'))
29
+ useEditor.getState().setPaneView(0, VIEW_3D)
30
+ expect(layout().panes).toEqual([VIEW_3D, VIEW_2D])
31
+ expect(layout().split).toBe(true)
32
+ })
33
+
34
+ test('a view opened beside the scene splits next to the focused pane', () => {
35
+ useEditor.getState().showView('gallery', { beside: true })
36
+ expect(layout()).toMatchObject({ split: true, panes: [VIEW_3D, 'gallery'], focus: 1 })
37
+ expect(visibleScene(useEditor.getState())).toBe('3d')
38
+ })
39
+
40
+ test('opening a view already on screen only focuses it', () => {
41
+ useEditor.getState().setViewLayout(sceneLayout('split'))
42
+ useEditor.getState().showView(VIEW_3D)
43
+ expect(layout()).toMatchObject({ panes: [VIEW_2D, VIEW_3D], focus: 1, split: true })
44
+ })
45
+
46
+ test('splitting from 3D brings the plan in on the left, as the split always did', () => {
47
+ useEditor.getState().toggleSplit()
48
+ expect(layout()).toMatchObject({ split: true, panes: [VIEW_2D, VIEW_3D], focus: 1 })
49
+ })
50
+
51
+ test('closing the split keeps the focused pane', () => {
52
+ useEditor.getState().setViewLayout(sceneLayout('split'))
53
+ useEditor.getState().focusViewPane(1)
54
+ useEditor.getState().toggleSplit()
55
+ expect(layout()).toMatchObject({ split: false, panes: [VIEW_3D, VIEW_2D] })
56
+ })
57
+
58
+ test('closing a view collapses its split, or brings a lone pane back to 3D', () => {
59
+ useEditor.getState().showView('gallery', { beside: true })
60
+ useEditor.getState().closeView('gallery')
61
+ expect(layout()).toMatchObject({ split: false, panes: [VIEW_3D, VIEW_2D] })
62
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
63
+ useEditor.getState().showView('gallery')
64
+ useEditor.getState().closeView('gallery')
65
+ expect(visibleScene(useEditor.getState())).toBe('3d')
66
+ })
67
+
68
+ test('a plan beside another view owns the scene like a plan alone', () => {
69
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
70
+ useEditor.getState().showView('gallery', { beside: true })
71
+ expect(visibleScene(useEditor.getState())).toBe('2d')
72
+ })
73
+
74
+ test('each workspace keeps its own layout', () => {
75
+ useEditor.getState().setViewLayout(sceneLayout('split'))
76
+ useEditor.getState().setWorkspaceMode('studio')
77
+ expect(layout()).toEqual(DEFAULT_VIEW_LAYOUT)
78
+ useEditor.getState().showView('gallery')
79
+ useEditor.getState().setWorkspaceMode('edit')
80
+ expect(visibleScene(useEditor.getState())).toBe('split')
81
+ })
82
+
83
+ test('capture frames in 3D alone and gives the layout back untouched', () => {
84
+ useEditor.getState().setViewLayout(sceneLayout('2d'))
85
+ useEditor.getState().setCaptureMode(true)
86
+ expect(visibleScene(useEditor.getState())).toBe('3d')
87
+ useEditor.getState().setCaptureMode(false)
88
+ expect(visibleScene(useEditor.getState())).toBe('2d')
89
+ })
90
+ })
91
+
92
+ describe('the deprecated viewMode for plugins written before views', () => {
93
+ test('mirrors the scene on screen and sets it', () => {
94
+ useEditor.getState().setViewMode('split')
95
+ expect(visibleScene(useEditor.getState())).toBe('split')
96
+ expect(useEditor.getState().viewMode).toBe('split')
97
+ useEditor.getState().showView('gallery')
98
+ expect(useEditor.getState().viewMode).toBe('3d')
99
+ useEditor.getState().setCaptureMode(true)
100
+ expect(useEditor.getState().viewMode).toBe('3d')
101
+ })
102
+ })
103
+
104
+ describe('layouts saved before views', () => {
105
+ test.each([
106
+ ['3d', '3d'],
107
+ ['2d', '2d'],
108
+ ['split', 'split'],
109
+ ] as const)('a stored %s view opens as %s, the plan on the left', (viewMode, scene) => {
110
+ const state = normalizePersistedEditorUiState({ viewMode, floorplanPaneRatio: 0.4 } as never)
111
+ const restored = state.viewLayouts.edit
112
+ expect(restored).toBeDefined()
113
+ if (!restored) return
114
+ expect(
115
+ visibleScene({
116
+ viewLayouts: state.viewLayouts,
117
+ workspaceMode: 'edit',
118
+ isCaptureMode: false,
119
+ isFirstPersonMode: false,
120
+ }),
121
+ ).toBe(scene)
122
+ if (scene !== '3d') expect(restored).toMatchObject({ panes: [VIEW_2D, VIEW_3D], ratio: 0.4 })
123
+ })
124
+
125
+ test('an open floorplan from before view modes opens split', () => {
126
+ const state = normalizePersistedEditorUiState({ isFloorplanOpen: true } as never)
127
+ expect(state.viewLayouts.edit).toMatchObject({ split: true })
128
+ })
129
+ })
130
+
131
+ describe('registered views', () => {
132
+ test('a plugin view is offered only where its plugin is installed and its workspace matches', () => {
133
+ registerEditorHostView({
134
+ id: 'acme:pets',
135
+ label: 'Pets',
136
+ icon: { kind: 'iconify', name: 'lucide:cat' },
137
+ component: async () => ({ default: () => null }),
138
+ pluginId: 'acme',
139
+ })
140
+ registerEditorHostView({
141
+ id: 'acme:habitat',
142
+ label: 'Habitat',
143
+ icon: { kind: 'iconify', name: 'lucide:house' },
144
+ component: async () => ({ default: () => null }),
145
+ pluginId: 'acme',
146
+ workspaces: ['studio'],
147
+ })
148
+ const ids = (workspaceMode: string, installedPlugins: string[]) =>
149
+ availableViews({
150
+ registered: editorHostViewRegistry.getSnapshot(),
151
+ workspaceMode,
152
+ installedPlugins,
153
+ }).map((view) => view.id)
154
+ expect(ids('edit', [])).toEqual([VIEW_3D, VIEW_2D])
155
+ expect(ids('edit', ['acme'])).toEqual([VIEW_3D, VIEW_2D, 'acme:pets'])
156
+ expect(ids('studio', ['acme'])).toEqual([VIEW_3D, VIEW_2D, 'acme:habitat'])
157
+ })
158
+
159
+ test('the scene views cannot be replaced', () => {
160
+ expect(() =>
161
+ registerEditorHostView({
162
+ id: VIEW_3D,
163
+ label: '3D',
164
+ icon: { kind: 'iconify', name: 'lucide:box' },
165
+ component: async () => ({ default: () => null }),
166
+ }),
167
+ ).toThrow()
168
+ })
169
+
170
+ test('a layout naming a view that is gone keeps it, so the choice returns with the view', () => {
171
+ useEditor.getState().showView('acme:pets')
172
+ expect(isViewVisible(useEditor.getState(), 'acme:pets')).toBe(true)
173
+ expect(isViewVisible(useEditor.getState(), VIEW_3D)).toBe(false)
174
+ })
175
+ })
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The stage is one or two panes, each showing one view: the 3D scene, the 2D
3
+ * plan, or a view a host or plugin registered (`registerEditorHostView`). A view
4
+ * sits in at most one pane — the 3D canvas owns a single WebGL context and the
5
+ * plan a single interaction session — so every write keeps the panes distinct.
6
+ *
7
+ * Each workspace keeps its own layout, so Studio can sit on 3D | Gallery while
8
+ * Edit stays on 2D | 3D.
9
+ */
10
+
11
+ export const VIEW_3D = '3d'
12
+ export const VIEW_2D = '2d'
13
+
14
+ export type ViewPaneIndex = 0 | 1
15
+
16
+ export type ViewLayout = {
17
+ split: boolean
18
+ /** `panes[1]` is what split opens beside `panes[0]`, also while unsplit. */
19
+ panes: readonly [string, string]
20
+ /** Width of pane 0 as a fraction of the stage. */
21
+ ratio: number
22
+ focus: ViewPaneIndex
23
+ }
24
+
25
+ export const MIN_VIEW_PANE_RATIO = 0.15
26
+ export const MAX_VIEW_PANE_RATIO = 0.85
27
+
28
+ export const DEFAULT_VIEW_LAYOUT: ViewLayout = {
29
+ split: false,
30
+ panes: [VIEW_3D, VIEW_2D],
31
+ ratio: 0.5,
32
+ focus: 0,
33
+ }
34
+
35
+ /** Capture and first person frame through the 3D camera, whatever the layout says. */
36
+ const SINGLE_3D_LAYOUT: ViewLayout = DEFAULT_VIEW_LAYOUT
37
+
38
+ export function clampViewPaneRatio(value: unknown): number {
39
+ if (!(typeof value === 'number' && Number.isFinite(value))) return DEFAULT_VIEW_LAYOUT.ratio
40
+ return Math.min(MAX_VIEW_PANE_RATIO, Math.max(MIN_VIEW_PANE_RATIO, value))
41
+ }
42
+
43
+ export function normalizeViewLayout(value: unknown): ViewLayout {
44
+ const candidate = value as Partial<ViewLayout> | null | undefined
45
+ const panes = candidate?.panes
46
+ const first = typeof panes?.[0] === 'string' && panes[0] ? panes[0] : VIEW_3D
47
+ let second = typeof panes?.[1] === 'string' && panes[1] ? panes[1] : VIEW_2D
48
+ if (second === first) second = first === VIEW_3D ? VIEW_2D : VIEW_3D
49
+ const split = candidate?.split === true
50
+ return {
51
+ split,
52
+ panes: [first, second],
53
+ ratio: clampViewPaneRatio(candidate?.ratio),
54
+ focus: split && candidate?.focus === 1 ? 1 : 0,
55
+ }
56
+ }
57
+
58
+ export function visibleViews(layout: ViewLayout): readonly string[] {
59
+ return layout.split ? layout.panes : [layout.panes[0]]
60
+ }
61
+
62
+ export function paneOfView(layout: ViewLayout, viewId: string): ViewPaneIndex | null {
63
+ if (layout.panes[0] === viewId) return 0
64
+ if (layout.split && layout.panes[1] === viewId) return 1
65
+ return null
66
+ }
67
+
68
+ /** Put `viewId` in `pane`; if the other pane holds it, the two trade places. */
69
+ export function withPaneView(layout: ViewLayout, pane: ViewPaneIndex, viewId: string): ViewLayout {
70
+ const target: ViewPaneIndex = layout.split ? pane : 0
71
+ const other: ViewPaneIndex = target === 0 ? 1 : 0
72
+ const panes: [string, string] = [layout.panes[0], layout.panes[1]]
73
+ if (panes[target] === viewId) return { ...layout, focus: target }
74
+ if (panes[other] === viewId) panes[other] = panes[target]
75
+ panes[target] = viewId
76
+ return { ...layout, panes, focus: target }
77
+ }
78
+
79
+ /**
80
+ * Show `viewId`. A view already on screen just takes focus; otherwise it
81
+ * replaces the focused pane, or with `beside` opens next to it.
82
+ */
83
+ export function withView(
84
+ layout: ViewLayout,
85
+ viewId: string,
86
+ options: { beside?: boolean } = {},
87
+ ): ViewLayout {
88
+ const pane = paneOfView(layout, viewId)
89
+ if (pane !== null) return { ...layout, focus: pane }
90
+ if (!options.beside) return withPaneView(layout, layout.focus, viewId)
91
+ if (!layout.split) return withPaneView({ ...layout, split: true }, 1, viewId)
92
+ return withPaneView(layout, layout.focus === 0 ? 1 : 0, viewId)
93
+ }
94
+
95
+ /**
96
+ * Opening the split from 3D brings the plan in on the left, where the split has
97
+ * always put it, with 3D keeping focus. Closing a split keeps the focused pane.
98
+ */
99
+ export function withSplitToggled(layout: ViewLayout): ViewLayout {
100
+ if (!layout.split) {
101
+ if (layout.panes[0] === VIEW_3D && layout.panes[1] === VIEW_2D) {
102
+ return { ...layout, split: true, panes: [VIEW_2D, VIEW_3D], focus: 1 }
103
+ }
104
+ return { ...layout, split: true }
105
+ }
106
+ const kept = layout.focus === 1 ? swapped(layout) : layout
107
+ return { ...kept, split: false, focus: 0 }
108
+ }
109
+
110
+ /**
111
+ * Close a view: a split collapses to the other pane; a single pane goes back to
112
+ * the scene.
113
+ */
114
+ export function withoutView(layout: ViewLayout, viewId: string): ViewLayout {
115
+ const pane = paneOfView(layout, viewId)
116
+ if (pane === null) return layout
117
+ const kept = layout.split ? layout.panes[pane === 0 ? 1 : 0] : VIEW_3D
118
+ return { ...layout, split: false, panes: [kept, kept === VIEW_3D ? VIEW_2D : VIEW_3D], focus: 0 }
119
+ }
120
+
121
+ export function swapped(layout: ViewLayout): ViewLayout {
122
+ return {
123
+ ...layout,
124
+ panes: [layout.panes[1], layout.panes[0]],
125
+ ratio: 1 - layout.ratio,
126
+ focus: layout.focus === 0 ? 1 : 0,
127
+ }
128
+ }
129
+
130
+ type ViewLayoutState = {
131
+ viewLayouts: Readonly<Record<string, ViewLayout>>
132
+ workspaceMode: string
133
+ isCaptureMode: boolean
134
+ isFirstPersonMode: boolean
135
+ }
136
+
137
+ export function storedViewLayout(state: Pick<ViewLayoutState, 'viewLayouts' | 'workspaceMode'>) {
138
+ return state.viewLayouts[state.workspaceMode] ?? DEFAULT_VIEW_LAYOUT
139
+ }
140
+
141
+ /** The layout on screen: the workspace's own, unless capture or first person needs 3D alone. */
142
+ export function activeViewLayout(state: ViewLayoutState): ViewLayout {
143
+ if (state.isCaptureMode || state.isFirstPersonMode) return SINGLE_3D_LAYOUT
144
+ return storedViewLayout(state)
145
+ }
146
+
147
+ export function isViewVisible(state: ViewLayoutState, viewId: string): boolean {
148
+ return paneOfView(activeViewLayout(state), viewId) !== null
149
+ }
150
+
151
+ export type VisibleScene = '3d' | '2d' | 'split'
152
+
153
+ /** A layout showing just the scene: 3D, the plan, or the plan beside 3D (the split as it always was). */
154
+ export function sceneLayout(scene: VisibleScene, ratio = DEFAULT_VIEW_LAYOUT.ratio): ViewLayout {
155
+ if (scene === '3d') return DEFAULT_VIEW_LAYOUT
156
+ return {
157
+ split: scene === 'split',
158
+ panes: [VIEW_2D, VIEW_3D],
159
+ ratio: clampViewPaneRatio(ratio),
160
+ focus: 0,
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Which projections of the scene are on screen — 3D alone, the plan alone, or
166
+ * both side by side — or `null` while the panes show other views only. The
167
+ * code that hands input between the canvas and the plan reads this: a plan
168
+ * beside a gallery owns input exactly as a plan alone does.
169
+ */
170
+ export function visibleScene(state: ViewLayoutState): VisibleScene | null {
171
+ const layout = activeViewLayout(state)
172
+ const has3d = paneOfView(layout, VIEW_3D) !== null
173
+ const has2d = paneOfView(layout, VIEW_2D) !== null
174
+ if (has3d && has2d) return 'split'
175
+ if (has3d) return '3d'
176
+ if (has2d) return '2d'
177
+ return null
178
+ }
179
+
180
+ /**
181
+ * Layouts saved before views existed stored one `viewMode` (`'3d' | '2d' |
182
+ * 'split'`, the plan always on the left) and the plan's width.
183
+ */
184
+ export function viewLayoutFromLegacy(legacy: {
185
+ viewMode?: unknown
186
+ isFloorplanOpen?: unknown
187
+ floorplanPaneRatio?: unknown
188
+ }): ViewLayout {
189
+ const viewMode =
190
+ legacy.viewMode === '2d' || legacy.viewMode === '3d' || legacy.viewMode === 'split'
191
+ ? legacy.viewMode
192
+ : legacy.isFloorplanOpen === true
193
+ ? 'split'
194
+ : '3d'
195
+ return sceneLayout(viewMode, clampViewPaneRatio(legacy.floorplanPaneRatio))
196
+ }
@@ -0,0 +1,16 @@
1
+ export function installImmediateAnimationFrames(): () => void {
2
+ const names = ['requestAnimationFrame', 'cancelAnimationFrame'] as const
3
+ const previous = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name))
4
+ globalThis.requestAnimationFrame = (callback) => {
5
+ callback(0)
6
+ return 0
7
+ }
8
+ globalThis.cancelAnimationFrame = () => {}
9
+ return () => {
10
+ for (const [index, name] of names.entries()) {
11
+ const descriptor = previous[index]
12
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor)
13
+ else Reflect.deleteProperty(globalThis, name)
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,224 @@
1
+ import { expect } from 'bun:test'
2
+ import {
3
+ type AnyNodeId,
4
+ acquireSceneHistoryPause,
5
+ BuildingNode,
6
+ createZone,
7
+ DoorNode,
8
+ generateId,
9
+ getSceneHistoryPauseDepth,
10
+ initSpaceDetectionSync,
11
+ LevelNode,
12
+ useLiveNodeOverrides,
13
+ useScene,
14
+ type WallNode,
15
+ } from '@pascal-app/core'
16
+ import { commitRoomElevation } from '../lib/room-handle-drag'
17
+ import { applyRoomPlan } from '../lib/room-structure-commands'
18
+
19
+ /**
20
+ * The harness scene, reconciled live: a 6 × 4 m room (`zoneId`, its east wall
21
+ * at x = 6 the one gestures move) and, west of it through a door, a room raised
22
+ * 0.45 m (`raisedZoneId`) whose floor steps down through the doorway. Returns
23
+ * the reconciler's stop.
24
+ */
25
+ export function seedRoundTripScene(buildingId: string, levelId: string) {
26
+ const building = BuildingNode.parse({ id: buildingId, children: [levelId] })
27
+ const level = LevelNode.parse({ id: levelId, parentId: building.id })
28
+ useScene.setState({
29
+ nodes: { [building.id]: building, [level.id]: level },
30
+ rootNodeIds: [building.id],
31
+ dirtyNodes: new Set(),
32
+ materials: {},
33
+ collections: {},
34
+ readOnly: false,
35
+ })
36
+ const stop = initSpaceDetectionSync(useScene, {
37
+ getState: () => ({ spaces: {}, setSpaces: () => {} }),
38
+ })
39
+ const room = (polygon: [number, number][]) => {
40
+ const plan = createZone(useScene.getState().nodes, {
41
+ levelId,
42
+ polygon,
43
+ enclose: true,
44
+ mintId: generateId,
45
+ })
46
+ applyRoomPlan(plan)
47
+ return plan.zoneId
48
+ }
49
+ const zoneId = room([
50
+ [0, 0],
51
+ [6, 0],
52
+ [6, 4],
53
+ [0, 4],
54
+ ])
55
+ const raisedZoneId = room([
56
+ [-4, 0],
57
+ [0, 0],
58
+ [0, 4],
59
+ [-4, 4],
60
+ ])
61
+ const shared = Object.values(useScene.getState().nodes).find(
62
+ (n): n is WallNode => n.type === 'wall' && n.start[0] === 0 && n.end[0] === 0,
63
+ )!
64
+ const door = DoorNode.parse({ parentId: shared.id, wallId: shared.id, position: [2, 0, 0] })
65
+ useScene.getState().applyNodeChanges({ create: [{ node: door, parentId: shared.id }] })
66
+ commitRoomElevation(raisedZoneId, 0.45)
67
+ return { stop, zoneId, raisedZoneId }
68
+ }
69
+
70
+ /**
71
+ * A live gesture as the harness drives it: `start` begins it and moves it far
72
+ * enough that its preview reshapes the building (derived plates, ceilings,
73
+ * zones included); then either `cancel` or `commit` ends it.
74
+ */
75
+ export type LiveGesture = {
76
+ start: () => unknown
77
+ cancel: () => unknown
78
+ commit: () => unknown
79
+ /** Whether the preview ran (an override, a ghost…), checked after `start`. */
80
+ previewed?: () => boolean
81
+ }
82
+
83
+ /** Every node, as plain data (JSON also folds -0 into 0, as the transport does). */
84
+ export const sceneData = (): Record<string, unknown> =>
85
+ JSON.parse(JSON.stringify(useScene.getState().nodes))
86
+
87
+ function expectReleased() {
88
+ expect([...useLiveNodeOverrides.getState().overrides.keys()]).toEqual([])
89
+ expect(getSceneHistoryPauseDepth()).toBe(0)
90
+ expect(useScene.temporal.getState().isTracking).toBe(true)
91
+ }
92
+
93
+ /**
94
+ * start → move → cancel leaves every node deep-equal to before (derived ones
95
+ * included), no history entry, history tracking again, no override left, and
96
+ * every node the preview drew from an override marked to rebuild — else its
97
+ * mesh stays at the preview.
98
+ */
99
+ export async function expectCancelRestores(gesture: LiveGesture) {
100
+ const before = sceneData()
101
+ const past = useScene.temporal.getState().pastStates.length
102
+ await gesture.start()
103
+ if (gesture.previewed) expect(gesture.previewed()).toBe(true)
104
+ const drawnFromPreview = [...useLiveNodeOverrides.getState().overrides.keys()] as AnyNodeId[]
105
+ useScene.getState().dirtyNodes.clear()
106
+ await gesture.cancel()
107
+ expect(sceneData()).toEqual(before)
108
+ expect(useScene.temporal.getState().pastStates.length).toBe(past)
109
+ expectReleased()
110
+ const { nodes, dirtyNodes, markDirty } = useScene.getState()
111
+ const stale = drawnFromPreview.filter((id) => {
112
+ if (!nodes[id] || dirtyNodes.has(id)) return false
113
+ // A kind that opts out of dirty tracking redraws from the override store itself.
114
+ markDirty(id)
115
+ return dirtyNodes.has(id)
116
+ })
117
+ expect(stale).toEqual([])
118
+ }
119
+
120
+ /**
121
+ * start → move → commit writes one undo step that reshapes derived nodes of
122
+ * `derivedTypes` too, releases everything, and one undo brings every node back.
123
+ */
124
+ export async function expectCommitIsOneUndoStep(
125
+ gesture: LiveGesture,
126
+ derivedTypes: readonly string[] = ['slab', 'zone'],
127
+ ) {
128
+ const before = sceneData()
129
+ const past = useScene.temporal.getState().pastStates.length
130
+ await gesture.start()
131
+ await gesture.commit()
132
+ const after = sceneData()
133
+ expect(useScene.temporal.getState().pastStates.length).toBe(past + 1)
134
+ expectReleased()
135
+ const changedTypes = new Set(
136
+ Object.keys({ ...before, ...after })
137
+ .filter((id) => JSON.stringify(before[id]) !== JSON.stringify(after[id]))
138
+ .map((id) => ((after[id] ?? before[id]) as { type: string }).type),
139
+ )
140
+ for (const type of derivedTypes) expect([...changedTypes]).toContain(type)
141
+ useScene.temporal.getState().undo()
142
+ expect(sceneData()).toEqual(before)
143
+ }
144
+
145
+ /**
146
+ * During the drag only the surfaces (plates, ceilings, zones) the release
147
+ * reshapes are drawn from a preview, and each is drawn exactly as the release
148
+ * leaves it — the floors it never touches keep their stored shape.
149
+ */
150
+ export async function expectPreviewMatchesCommit(
151
+ gesture: LiveGesture,
152
+ surfaceTypes: readonly string[] = ['slab', 'ceiling', 'zone'],
153
+ ) {
154
+ const before = sceneData() as Record<string, Record<string, unknown>>
155
+ await gesture.start()
156
+ const previews = new Map(
157
+ [...useLiveNodeOverrides.getState().overrides].filter(([id]) =>
158
+ surfaceTypes.includes(before[id]?.type as string),
159
+ ),
160
+ )
161
+ await gesture.commit()
162
+ const after = sceneData() as Record<string, Record<string, unknown>>
163
+ const json = (value: unknown) => JSON.stringify(value ?? null)
164
+ const untouched = [...previews.keys()].filter(
165
+ (id) => after[id] && json(before[id]) === json(after[id]),
166
+ )
167
+ expect(untouched).toEqual([])
168
+ const drawn: Record<string, Record<string, string>> = {}
169
+ const committed: Record<string, Record<string, string>> = {}
170
+ for (const [id, values] of previews) {
171
+ if (!after[id]) continue
172
+ drawn[id] = {}
173
+ committed[id] = {}
174
+ for (const key of Object.keys(values)) {
175
+ drawn[id][key] = json(values[key])
176
+ committed[id][key] = json(after[id][key])
177
+ }
178
+ }
179
+ expect(drawn).toEqual(committed)
180
+ const reshapedUnseen = Object.keys(after).filter(
181
+ (id) =>
182
+ before[id] &&
183
+ surfaceTypes.includes(after[id]!.type as string) &&
184
+ (json(before[id].polygon) !== json(after[id]!.polygon) ||
185
+ json(before[id].holes) !== json(after[id]!.holes)) &&
186
+ !previews.get(id)?.polygon,
187
+ )
188
+ expect(reshapedUnseen).toEqual([])
189
+ }
190
+
191
+ /**
192
+ * A collaborator's edit landing mid-gesture (here: a field the gesture never
193
+ * writes) survives both the cancel and the commit. `target` names the node,
194
+ * read once the gesture has started.
195
+ */
196
+ export async function expectRemoteEditSurvives(
197
+ gesture: () => LiveGesture,
198
+ target: () => AnyNodeId,
199
+ patch: Record<string, unknown> = { name: 'Edited by a collaborator' },
200
+ ) {
201
+ const remote = (id: AnyNodeId) => {
202
+ const nodes = useScene.getState().nodes
203
+ const release = acquireSceneHistoryPause(useScene)
204
+ try {
205
+ useScene.setState({ nodes: { ...nodes, [id]: { ...nodes[id]!, ...patch } } })
206
+ } finally {
207
+ release()
208
+ }
209
+ }
210
+ for (const end of ['cancel', 'commit'] as const) {
211
+ const run = gesture()
212
+ const before = sceneData() as Record<string, Record<string, unknown>>
213
+ await run.start()
214
+ const id = target()
215
+ remote(id)
216
+ await run[end]()
217
+ const after = sceneData() as Record<string, Record<string, unknown>>
218
+ expect({ end, ...pick(after[id], patch) }).toEqual({ end, ...patch })
219
+ if (end === 'cancel') expect(after).toEqual({ ...before, [id]: { ...before[id], ...patch } })
220
+ }
221
+ }
222
+
223
+ const pick = (node: Record<string, unknown> | undefined, patch: Record<string, unknown>) =>
224
+ Object.fromEntries(Object.keys(patch).map((key) => [key, node?.[key]]))
@@ -0,0 +1,82 @@
1
+ import { useScene } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
3
+ import { _roots, act, createRoot, extend } from '@react-three/fiber'
4
+ import type { ReactNode } from 'react'
5
+ import * as THREE from 'three'
6
+ import useEditor from '../store/use-editor'
7
+ import useInteractionScope from '../store/use-interaction-scope'
8
+ import useSessionGroups from '../store/use-session-groups'
9
+
10
+ extend({
11
+ Group: THREE.Group,
12
+ Mesh: THREE.Mesh,
13
+ LineSegments: THREE.LineSegments,
14
+ CircleGeometry: THREE.CircleGeometry,
15
+ CylinderGeometry: THREE.CylinderGeometry,
16
+ PlaneGeometry: THREE.PlaneGeometry,
17
+ MeshBasicMaterial: THREE.MeshBasicMaterial,
18
+ LineBasicMaterial: THREE.LineBasicMaterial,
19
+ })
20
+
21
+ export async function withSelectionHarness(
22
+ run: (harness: {
23
+ render: (node: ReactNode) => Promise<void>
24
+ canvas: HTMLCanvasElement
25
+ }) => Promise<void>,
26
+ ) {
27
+ const scene = useScene.getState()
28
+ const viewer = useViewer.getState()
29
+ const editor = useEditor.getState()
30
+ const scope = useInteractionScope.getState()
31
+ const groups = useSessionGroups.getState()
32
+ const previousWindow = globalThis.window
33
+ const previousRaf = globalThis.requestAnimationFrame
34
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
35
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
36
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
37
+ globalThis.window = new EventTarget() as Window & typeof globalThis
38
+ globalThis.requestAnimationFrame = () => 0
39
+ const canvas = Object.assign(new EventTarget(), {
40
+ style: { cursor: '' },
41
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 1000 }),
42
+ }) as unknown as HTMLCanvasElement
43
+ const root = createRoot(canvas)
44
+ try {
45
+ const camera = Object.assign(new THREE.OrthographicCamera(-1, 9, 9, -1, 0.1, 100), {
46
+ manual: true,
47
+ })
48
+ camera.position.set(0, 10, 0)
49
+ camera.up.set(0, 0, -1)
50
+ camera.lookAt(0, 0, 0)
51
+ camera.updateMatrixWorld(true)
52
+ await root.configure({
53
+ gl: {
54
+ domElement: canvas,
55
+ render() {},
56
+ setSize() {},
57
+ setPixelRatio() {},
58
+ } as unknown as THREE.WebGLRenderer,
59
+ camera,
60
+ frameloop: 'never',
61
+ dpr: 1,
62
+ size: { width: 1000, height: 1000, top: 0, left: 0 },
63
+ })
64
+ await run({
65
+ render: async (node) => {
66
+ await act(async () => root.render(node))
67
+ },
68
+ canvas,
69
+ })
70
+ } finally {
71
+ await act(async () => root.render(null))
72
+ _roots.delete(canvas)
73
+ useScene.setState(scene)
74
+ useViewer.setState(viewer)
75
+ useEditor.setState(editor)
76
+ useInteractionScope.setState(scope)
77
+ useSessionGroups.setState(groups)
78
+ globalThis.window = previousWindow
79
+ globalThis.requestAnimationFrame = previousRaf
80
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
81
+ }
82
+ }