@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,124 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type NodeEvent, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useThree } from '@react-three/fiber'
6
+ import { useEffect } from 'react'
7
+ import { selectionModifiersFromEvent } from '../../lib/selection-routing'
8
+ import {
9
+ applyViewerClick,
10
+ applyViewerHover,
11
+ viewerPickFromNodeEvent,
12
+ } from '../../lib/viewer-selection'
13
+
14
+ /**
15
+ * Selection for a parametric viewer surface (the published viewer's fallback,
16
+ * its embed, the editor's Preview): free-form and room-first, through the same
17
+ * rules as the baked viewer. Mount it with `<Viewer selectionManager="custom">`.
18
+ */
19
+ export function ViewerSelectionManager() {
20
+ const gl = useThree((state) => state.gl)
21
+
22
+ useEffect(() => {
23
+ let clickHandled = false
24
+ let hoveredFrom: string | null = null
25
+ const pendingClicks = new Set<number>()
26
+
27
+ const onMove = (event: NodeEvent) => {
28
+ if (useViewer.getState().walkthroughMode) return
29
+ const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
30
+ if (!pick) return
31
+ event.stopPropagation()
32
+ hoveredFrom = event.node.id
33
+ applyViewerHover(pick, useScene.getState().nodes)
34
+ }
35
+ const onLeave = (event: NodeEvent) => {
36
+ if (event.node.id !== hoveredFrom) return
37
+ hoveredFrom = null
38
+ useViewer.getState().setHoveredId(null)
39
+ }
40
+ const onClick = (event: NodeEvent) => {
41
+ if (useViewer.getState().walkthroughMode) return
42
+ const pick = viewerPickFromNodeEvent(event, useScene.getState().nodes)
43
+ if (!pick) return
44
+ event.stopPropagation()
45
+ clickHandled = true
46
+ applyViewerClick(
47
+ pick,
48
+ useScene.getState().nodes,
49
+ selectionModifiersFromEvent(event.nativeEvent),
50
+ )
51
+ }
52
+ // A click on nothing selectable clears the room and the element.
53
+ const onCanvasClick = (event: MouseEvent) => {
54
+ const viewer = useViewer.getState()
55
+ if (event.button !== 0 || viewer.walkthroughMode) return
56
+ if (viewer.cameraDragging || viewer.inputDragging) return
57
+ const frame = requestAnimationFrame(() => {
58
+ pendingClicks.delete(frame)
59
+ if (useViewer.getState().walkthroughMode) return
60
+ if (clickHandled) {
61
+ clickHandled = false
62
+ return
63
+ }
64
+ applyViewerClick(null, useScene.getState().nodes)
65
+ })
66
+ pendingClicks.add(frame)
67
+ }
68
+
69
+ emitter.on('node:move', onMove)
70
+ emitter.on('node:enter', onMove)
71
+ emitter.on('node:leave', onLeave)
72
+ emitter.on('node:click', onClick)
73
+ const canvas = gl.domElement
74
+ canvas.addEventListener('click', onCanvasClick)
75
+ return () => {
76
+ emitter.off('node:move', onMove)
77
+ emitter.off('node:enter', onMove)
78
+ emitter.off('node:leave', onLeave)
79
+ emitter.off('node:click', onClick)
80
+ canvas.removeEventListener('click', onCanvasClick)
81
+ for (const frame of pendingClicks) cancelAnimationFrame(frame)
82
+ useViewer.getState().setHoveredId(null)
83
+ }
84
+ }, [gl])
85
+
86
+ return <ViewerOutlineSync />
87
+ }
88
+
89
+ /** The selected elements and the hovered one, outlined (rooms highlight instead). */
90
+ function ViewerOutlineSync() {
91
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
92
+ const externalSelectedIds = useViewer((s) => s.externalSelectedIds)
93
+ const hoveredId = useViewer((s) => s.hoveredId)
94
+ const outliner = useViewer((s) => s.outliner)
95
+ const geometryRevision = useViewer((s) => s.geometryRevision)
96
+ const walkthrough = useViewer((s) => s.walkthroughMode)
97
+ const nodes = useScene((s) => s.nodes)
98
+
99
+ useEffect(() => {
100
+ void geometryRevision
101
+ outliner.selectedObjects.length = 0
102
+ outliner.hoveredObjects.length = 0
103
+ if (walkthrough) return
104
+ for (const id of new Set([...selectedIds, ...externalSelectedIds])) {
105
+ const object = sceneRegistry.nodes.get(id)
106
+ if (object) outliner.selectedObjects.push(object)
107
+ }
108
+ const hovered = hoveredId ? nodes[hoveredId as AnyNodeId] : undefined
109
+ if (hovered && hovered.type !== 'zone') {
110
+ const object = sceneRegistry.nodes.get(hovered.id)
111
+ if (object) outliner.hoveredObjects.push(object)
112
+ }
113
+ }, [selectedIds, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision, walkthrough])
114
+
115
+ useEffect(
116
+ () => () => {
117
+ outliner.selectedObjects.length = 0
118
+ outliner.hoveredObjects.length = 0
119
+ },
120
+ [outliner],
121
+ )
122
+
123
+ return null
124
+ }
@@ -60,7 +60,6 @@ function selectViewerLevel(nodes: Record<string, AnyNode>, levelId: string) {
60
60
  selectedIds: [],
61
61
  zoneId: null,
62
62
  })
63
- viewer.setLevelMode('solo')
64
63
  }
65
64
 
66
65
  export function ViewerStage({
@@ -137,7 +136,8 @@ export function ViewerStage({
137
136
  if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
138
137
  markPerfAction('level-switch', nextLevelId)
139
138
  }
140
- selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
139
+ if (nextLevelId !== useViewer.getState().selection.levelId)
140
+ selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
141
141
  if (notify) onLevelChange?.(nextLevelId)
142
142
  },
143
143
  [onLevelChange, scene],
@@ -11,7 +11,6 @@ import useInteractionScope from '../store/use-interaction-scope'
11
11
  export const ViewerZoneSystem = () => {
12
12
  useFrame(() => {
13
13
  const { levelId, zoneId } = useViewer.getState().selection
14
- const structureLayer = useEditor.getState().structureLayer
15
14
  const nodes = useScene.getState().nodes
16
15
  // Snapshot capture is a clean, camera-only surface — zone geometry and
17
16
  // tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
@@ -29,14 +28,14 @@ export const ViewerZoneSystem = () => {
29
28
 
30
29
  const isOnSelectedLevel = zone.parentId === levelId
31
30
 
32
- // Keep group visible (so <Html> labels stay active), hide/show meshes only.
33
- // Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
34
- // The editor ZoneSystem handles the selected zone's opacity animation.
31
+ // Keep group visible (so <Html> labels stay active), hide/show meshes only:
32
+ // only the selected zone shows its volume.
35
33
  const isSelected = id === zoneId
36
- const shouldShowGeometry =
37
- !isCaptureMode &&
38
- ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
39
- if (!obj.visible) obj.visible = true
34
+ // A zone the author hid (sidebar eye) takes the group with it — this
35
+ // per-frame write would otherwise undo the renderer's `visible` prop.
36
+ const nodeVisible = zone.visible !== false
37
+ const shouldShowGeometry = nodeVisible && !isCaptureMode && isSelected
38
+ if (obj.visible !== nodeVisible) obj.visible = nodeVisible
40
39
  obj.traverse((child) => {
41
40
  if ((child as Mesh).isMesh) {
42
41
  child.visible = shouldShowGeometry
@@ -44,7 +43,8 @@ export const ViewerZoneSystem = () => {
44
43
  })
45
44
 
46
45
  // Labels: always visible on the current level (regardless of mode or zone selection)
47
- const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
46
+ const showLabel =
47
+ nodeVisible && !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
48
48
  const targetOpacity = showLabel ? '1' : '0'
49
49
  const labelEl = document.getElementById(`${id}-label`)
50
50
  if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -0,0 +1,311 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import {
3
+ BuildingNode,
4
+ ColumnNode,
5
+ clearSceneHistory,
6
+ LevelNode,
7
+ SiteNode,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { create } from '@react-three/test-renderer'
11
+ import { type SaveStatus, useAutoSave } from './use-auto-save'
12
+
13
+ const originalWindow = globalThis.window
14
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame
15
+ const previousScene = useScene.getState()
16
+ let renderer: Awaited<ReturnType<typeof create>> | undefined
17
+
18
+ afterEach(async () => {
19
+ await renderer?.unmount()
20
+ renderer = undefined
21
+ globalThis.window = originalWindow
22
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame
23
+ useScene.setState(previousScene, true)
24
+ clearSceneHistory()
25
+ })
26
+
27
+ function graph(count: number) {
28
+ if (count === 0) return { nodes: {}, rootNodeIds: [] }
29
+ const columns = Array.from({ length: count - 3 }, (_, index) =>
30
+ ColumnNode.parse({ id: `column_autosave_${index}`, parentId: 'level_autosave' }),
31
+ )
32
+ const level = LevelNode.parse({
33
+ id: 'level_autosave',
34
+ parentId: 'building_autosave',
35
+ children: columns.map((node) => node.id),
36
+ })
37
+ const building = BuildingNode.parse({
38
+ id: 'building_autosave',
39
+ parentId: 'site_autosave',
40
+ children: [level.id],
41
+ })
42
+ const site = SiteNode.parse({ id: 'site_autosave', children: [building.id] })
43
+ const nodes = [site, building, level, ...columns]
44
+ return { nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), rootNodeIds: [site.id] }
45
+ }
46
+
47
+ /** Mounts the hook in its initial state: a scene load not yet completed. */
48
+ async function mountBeforeLoad(guardAgainstSceneWipe?: boolean, save?: () => Promise<void>) {
49
+ globalThis.window = new EventTarget() as unknown as Window & typeof globalThis
50
+ useScene.setState({ ...graph(0), readOnly: false })
51
+ clearSceneHistory()
52
+ const writes: number[] = []
53
+ const statuses: SaveStatus[] = []
54
+ let controls: ReturnType<typeof useAutoSave> | undefined
55
+ function Host(props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) {
56
+ controls = useAutoSave({
57
+ ...props,
58
+ onSave: async (scene) => {
59
+ writes.push(Object.keys(scene.nodes).length)
60
+ await save?.()
61
+ },
62
+ onSaveStatusChange: (status) => statuses.push(status),
63
+ })
64
+ return null
65
+ }
66
+ renderer = await create(<Host guardAgainstSceneWipe={guardAgainstSceneWipe} />)
67
+ return {
68
+ writes,
69
+ statuses,
70
+ controls: controls!,
71
+ update: (props: { guardAgainstSceneWipe?: boolean; isVersionPreviewMode?: boolean }) =>
72
+ renderer!.update(<Host {...props} />),
73
+ }
74
+ }
75
+
76
+ async function mount(
77
+ loadedCount: number,
78
+ guardAgainstSceneWipe?: boolean,
79
+ save?: () => Promise<void>,
80
+ ) {
81
+ const mounted = await mountBeforeLoad(guardAgainstSceneWipe, save)
82
+ useScene.setState(graph(loadedCount))
83
+ clearSceneHistory()
84
+ mounted.controls.completeSceneLoad()
85
+ mounted.statuses.length = 0
86
+ return mounted
87
+ }
88
+
89
+ /** The Editor's load sequence: begin, unload, hydrate the store, complete. */
90
+ function loadScene(controls: ReturnType<typeof useAutoSave>, count: number) {
91
+ controls.beginSceneLoad()
92
+ useScene.getState().unloadScene()
93
+ hydrate(count)
94
+ controls.completeSceneLoad()
95
+ }
96
+
97
+ function hydrate(count: number) {
98
+ const { nodes, rootNodeIds } = graph(count)
99
+ useScene.getState().setScene(nodes as never, rootNodeIds as never)
100
+ clearSceneHistory()
101
+ }
102
+
103
+ function agentAddsColumn() {
104
+ useScene
105
+ .getState()
106
+ .createNode(
107
+ ColumnNode.parse({ id: 'column_agent', parentId: 'level_autosave' }),
108
+ 'level_autosave' as never,
109
+ )
110
+ }
111
+
112
+ /** A hidden tab: requestAnimationFrame callbacks never run. */
113
+ function hideDocument() {
114
+ const frames: FrameRequestCallback[] = []
115
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
116
+ frames.push(callback)
117
+ return frames.length
118
+ }) as typeof requestAnimationFrame
119
+ return frames
120
+ }
121
+
122
+ test('a scene loaded in a hidden tab autosaves agent edits without waiting for a frame', async () => {
123
+ hideDocument()
124
+ const { writes, statuses, controls } = await mountBeforeLoad()
125
+ statuses.length = 0
126
+ // Pre-hydration document writes (the host-panel installedPlugins sync on
127
+ // mount) still must not arm a save of the empty store.
128
+ useScene.getState().setInstalledPlugins(['pre-hydration-default'], { explicit: false })
129
+ loadScene(controls, 12)
130
+ expect(statuses).toEqual([])
131
+
132
+ agentAddsColumn()
133
+ await Bun.sleep(1100)
134
+ expect(writes).toEqual([13])
135
+ expect(statuses.at(-1)).toBe('saved')
136
+ })
137
+
138
+ test('opening a scene and leaving it unchanged saves nothing', async () => {
139
+ const { writes, statuses, controls } = await mountBeforeLoad()
140
+ loadScene(controls, 12)
141
+ statuses.length = 0
142
+ // Mount-time writes after the load: systems re-marking nodes, and a plugin
143
+ // that registers late syncing the default plugin set.
144
+ useScene.getState().markDirty('level_autosave' as never)
145
+ useScene.getState().setInstalledPlugins(['late-default'], { explicit: false })
146
+ await Bun.sleep(1100)
147
+ window.dispatchEvent(new Event('pagehide'))
148
+ expect(writes).toEqual([])
149
+ expect(statuses).toEqual([])
150
+ })
151
+
152
+ test('installing a plugin by hand after the load is saved', async () => {
153
+ const { writes, controls } = await mountBeforeLoad()
154
+ loadScene(controls, 12)
155
+ useScene.getState().setInstalledPlugins(['chosen'], { explicit: true })
156
+ await Bun.sleep(1100)
157
+ expect(writes).toEqual([12])
158
+ })
159
+
160
+ test('the exit flush writes an agent edit made right after a hidden load', async () => {
161
+ hideDocument()
162
+ const { writes, statuses, controls } = await mountBeforeLoad()
163
+ loadScene(controls, 12)
164
+ agentAddsColumn()
165
+ window.dispatchEvent(new Event('pagehide'))
166
+ expect(writes).toEqual([13])
167
+ expect(statuses).not.toContain('error')
168
+ })
169
+
170
+ test('the exit flush still skips while a load is in flight', async () => {
171
+ const { writes, controls } = await mount(12)
172
+ agentAddsColumn()
173
+ controls.beginSceneLoad()
174
+ useScene.getState().unloadScene()
175
+ window.dispatchEvent(new Event('pagehide'))
176
+ expect(writes).toEqual([])
177
+ })
178
+
179
+ test('a save that came due mid-load is written once the load completes', async () => {
180
+ const { writes, statuses, controls } = await mount(12)
181
+ agentAddsColumn()
182
+ controls.beginSceneLoad()
183
+ useScene.getState().unloadScene()
184
+ await Bun.sleep(1100)
185
+ expect(writes).toEqual([])
186
+ expect(statuses.at(-1)).toBe('paused')
187
+
188
+ hydrate(12)
189
+ controls.completeSceneLoad()
190
+ await Bun.sleep(0)
191
+ expect(writes).toEqual([12])
192
+ expect(statuses.at(-1)).toBe('saved')
193
+ })
194
+
195
+ test.each([
196
+ 'undo',
197
+ 'redo',
198
+ 'delete',
199
+ ] as const)('autosave permits a 5 to 4 single-node %s', async (action) => {
200
+ const { writes, statuses } = await mount(5)
201
+ if (action === 'undo') {
202
+ useScene.setState(graph(4))
203
+ clearSceneHistory()
204
+ useScene.setState(graph(5))
205
+ useScene.temporal.getState().undo()
206
+ } else if (action === 'redo') {
207
+ useScene.getState().deleteNode('column_autosave_1')
208
+ useScene.temporal.getState().undo()
209
+ useScene.temporal.getState().redo()
210
+ } else {
211
+ useScene.getState().deleteNode('column_autosave_1')
212
+ }
213
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(4)
214
+ await Bun.sleep(1100)
215
+ expect(statuses).not.toContain('error')
216
+ expect(writes).toEqual([4])
217
+ expect(statuses.at(-1)).toBe('saved')
218
+ })
219
+
220
+ test('autosave permits a single-node deletion on exit', async () => {
221
+ const { writes, statuses } = await mount(5)
222
+ useScene.getState().deleteNode('column_autosave_1')
223
+ window.dispatchEvent(new Event('pagehide'))
224
+ expect(statuses).not.toContain('error')
225
+ expect(writes).toEqual([4])
226
+ })
227
+
228
+ test('autosave blocks a full wipe and resumes after an allowed edit', async () => {
229
+ const { writes, statuses, controls } = await mount(78)
230
+ useScene.setState(graph(4))
231
+ controls.saveNow()
232
+ window.dispatchEvent(new Event('pagehide'))
233
+ expect(statuses).toContain('error')
234
+ expect(writes).toEqual([])
235
+ useScene.setState(graph(5))
236
+ controls.saveNow()
237
+ await Bun.sleep(0)
238
+ expect(writes).toEqual([5])
239
+ expect(statuses.at(-1)).toBe('saved')
240
+ })
241
+
242
+ test('collaboration scaffold deletion waits for persistence and reports saved', async () => {
243
+ const idle = Promise.withResolvers<void>()
244
+ const { writes, statuses } = await mount(78, false, () => idle.promise)
245
+ useScene.setState(graph(4))
246
+ await Bun.sleep(1100)
247
+ expect(statuses).not.toContain('error')
248
+ expect(writes).toEqual([4])
249
+ expect(statuses).toEqual(['pending', 'saving'])
250
+ idle.resolve()
251
+ await Bun.sleep(0)
252
+ expect(statuses).toEqual(['pending', 'saving', 'saved'])
253
+ })
254
+
255
+ test.each([
256
+ 'pagehide',
257
+ 'unmount',
258
+ ])('collaboration scaffold deletion still flushes on %s', async (event) => {
259
+ const { writes, statuses } = await mount(78, false)
260
+ useScene.setState(graph(4))
261
+ if (event === 'pagehide') window.dispatchEvent(new Event(event))
262
+ else {
263
+ await renderer!.unmount()
264
+ renderer = undefined
265
+ }
266
+ expect(statuses).not.toContain('error')
267
+ expect(writes).toEqual([4])
268
+ })
269
+
270
+ test('collaboration guard exemption preserves preview pause and resume', async () => {
271
+ const { writes, statuses, update } = await mount(78, false)
272
+ useScene.setState(graph(4))
273
+ await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: true })
274
+ expect(statuses.at(-1)).toBe('paused')
275
+ await Bun.sleep(1100)
276
+ expect(writes).toEqual([])
277
+ await update({ guardAgainstSceneWipe: false, isVersionPreviewMode: false })
278
+ await Bun.sleep(1100)
279
+ expect(writes).toEqual([4])
280
+ expect(statuses.at(-1)).toBe('saved')
281
+ expect(statuses).not.toContain('error')
282
+ })
283
+
284
+ test('changing guard ownership recovers an error and keeps the new stored baseline', async () => {
285
+ const { writes, statuses, controls, update } = await mount(78)
286
+ useScene.setState(graph(4))
287
+ controls.saveNow()
288
+ expect(statuses.at(-1)).toBe('error')
289
+ await update({ guardAgainstSceneWipe: false })
290
+ controls.saveNow()
291
+ await Bun.sleep(0)
292
+ expect(writes).toEqual([4])
293
+ expect(statuses.at(-1)).toBe('saved')
294
+ await update({ guardAgainstSceneWipe: true })
295
+ useScene.setState(graph(3))
296
+ controls.saveNow()
297
+ await Bun.sleep(0)
298
+ expect(writes).toEqual([4, 3])
299
+ expect(statuses.at(-1)).toBe('saved')
300
+ })
301
+
302
+ test('collaboration callback failures still report error', async () => {
303
+ const { writes, statuses, controls } = await mount(78, false, async () => {
304
+ throw new Error('Collaboration failed')
305
+ })
306
+ useScene.setState(graph(4))
307
+ controls.saveNow()
308
+ await Bun.sleep(0)
309
+ expect(writes).toEqual([4])
310
+ expect(statuses).toEqual(['pending', 'saving', 'error'])
311
+ })
@@ -1,14 +1,18 @@
1
1
  'use client'
2
2
 
3
3
  import { useScene } from '@pascal-app/core'
4
- import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
5
- import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
4
+ import { useCallback, useEffect, useRef } from 'react'
5
+ import { getSavedSceneDocument, type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
6
6
 
7
7
  const AUTOSAVE_DEBOUNCE_MS = 1000
8
8
  const STRUCTURAL_NODE_COUNT = 4
9
9
 
10
10
  export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
11
- return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
11
+ return (
12
+ previousNodeCount > STRUCTURAL_NODE_COUNT &&
13
+ currentNodeCount <= STRUCTURAL_NODE_COUNT &&
14
+ previousNodeCount - currentNodeCount > 1
15
+ )
12
16
  }
13
17
 
14
18
  /**
@@ -37,8 +41,8 @@ export function createStoredNodeCountTracker(initialNodeCount: number) {
37
41
  * which is an accidental full deletion far more often than an intent. The
38
42
  * caller reports the block; on `true` the write becomes the new baseline.
39
43
  */
40
- allowWrite(nodeCount: number) {
41
- if (isSuspiciousNodeDrop(count, nodeCount)) return false
44
+ allowWrite(nodeCount: number, guardAgainstSceneWipe = true) {
45
+ if (guardAgainstSceneWipe && isSuspiciousNodeDrop(count, nodeCount)) return false
42
46
  count = nodeCount
43
47
  return true
44
48
  },
@@ -65,10 +69,14 @@ export function decideExitFlush(opts: {
65
69
  hasDirtyChanges: boolean
66
70
  storedNodeCount: number
67
71
  currentNodeCount: number
72
+ guardAgainstSceneWipe?: boolean
68
73
  }): ExitFlushDecision {
69
74
  if (!opts.hasDirtyChanges) return 'skip-clean'
70
75
  if (opts.isLoadingScene) return 'skip-loading'
71
- if (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) {
76
+ if (
77
+ opts.guardAgainstSceneWipe !== false &&
78
+ isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)
79
+ ) {
72
80
  return 'blocked-suspicious'
73
81
  }
74
82
  return 'flush'
@@ -77,6 +85,7 @@ export function decideExitFlush(opts: {
77
85
  export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
78
86
 
79
87
  interface UseAutoSaveOptions {
88
+ guardAgainstSceneWipe?: boolean
80
89
  onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
81
90
  onDirty?: () => void
82
91
  onSaveStatusChange?: (status: SaveStatus) => void
@@ -90,12 +99,14 @@ interface UseAutoSaveOptions {
90
99
  * ⚠️ Mount in exactly ONE component (the Editor).
91
100
  */
92
101
  export function useAutoSave({
102
+ guardAgainstSceneWipe = true,
93
103
  onSave,
94
104
  onDirty,
95
105
  onSaveStatusChange,
96
106
  isVersionPreviewMode = false,
97
107
  }: UseAutoSaveOptions): {
98
- isLoadingSceneRef: MutableRefObject<boolean>
108
+ beginSceneLoad: () => void
109
+ completeSceneLoad: () => void
99
110
  saveNow: () => void
100
111
  } {
101
112
  const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
@@ -116,6 +127,7 @@ export function useAutoSave({
116
127
  const onDirtyRef = useRef(onDirty)
117
128
  const onSaveStatusChangeRef = useRef(onSaveStatusChange)
118
129
  const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
130
+ const guardAgainstSceneWipeRef = useRef(guardAgainstSceneWipe)
119
131
 
120
132
  useEffect(() => {
121
133
  onSaveRef.current = onSave
@@ -129,6 +141,9 @@ export function useAutoSave({
129
141
  useEffect(() => {
130
142
  isVersionPreviewModeRef.current = isVersionPreviewMode
131
143
  }, [isVersionPreviewMode])
144
+ useEffect(() => {
145
+ guardAgainstSceneWipeRef.current = guardAgainstSceneWipe
146
+ }, [guardAgainstSceneWipe])
132
147
 
133
148
  const setSaveStatus = useCallback((status: SaveStatus) => {
134
149
  onSaveStatusChangeRef.current?.(status)
@@ -155,7 +170,8 @@ export function useAutoSave({
155
170
  return
156
171
  }
157
172
 
158
- const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
173
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } =
174
+ getSavedSceneDocument()
159
175
  const sceneGraph = {
160
176
  nodes,
161
177
  rootNodeIds,
@@ -166,7 +182,7 @@ export function useAutoSave({
166
182
 
167
183
  const currentNodeCount = Object.keys(nodes).length
168
184
  const previousNodeCount = storedNodeCount.count
169
- if (!storedNodeCount.allowWrite(currentNodeCount)) {
185
+ if (!storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)) {
170
186
  console.warn(
171
187
  `[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
172
188
  )
@@ -223,18 +239,23 @@ export function useAutoSave({
223
239
  return
224
240
  }
225
241
 
242
+ // A plugin set the user never chose is the host's defaults, re-derived on
243
+ // every load: a plugin registering after the load (hosted plugins arrive
244
+ // late) syncs it without anyone editing the scene.
245
+ const pluginsEdited =
246
+ state.installedPlugins !== lastInstalledPluginsRef && state.hasExplicitPluginInstallState
247
+ lastInstalledPluginsRef = state.installedPlugins
226
248
  const currentNodesSnapshot = JSON.stringify(state.nodes)
227
249
  const changed =
228
250
  currentNodesSnapshot !== lastNodesSnapshot ||
229
251
  state.collections !== lastCollectionsRef ||
230
252
  state.materials !== lastMaterialsRef ||
231
- state.installedPlugins !== lastInstalledPluginsRef
253
+ pluginsEdited
232
254
  if (!changed) return
233
255
 
234
256
  lastNodesSnapshot = currentNodesSnapshot
235
257
  lastCollectionsRef = state.collections
236
258
  lastMaterialsRef = state.materials
237
- lastInstalledPluginsRef = state.installedPlugins
238
259
  hasDirtyChangesRef.current = true
239
260
  onDirtyRef.current?.()
240
261
  setSaveStatus('pending')
@@ -258,7 +279,8 @@ export function useAutoSave({
258
279
  // would otherwise drop the change entirely. `pagehide` fires in cases
259
280
  // (mobile Safari, bfcache) where `beforeunload` does not.
260
281
  function flushOnExit() {
261
- const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
282
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } =
283
+ getSavedSceneDocument()
262
284
  const currentNodeCount = Object.keys(nodes).length
263
285
  const previousNodeCount = storedNodeCount.count
264
286
  const decision = decideExitFlush({
@@ -266,6 +288,7 @@ export function useAutoSave({
266
288
  hasDirtyChanges: hasDirtyChangesRef.current,
267
289
  storedNodeCount: previousNodeCount,
268
290
  currentNodeCount,
291
+ guardAgainstSceneWipe: guardAgainstSceneWipeRef.current,
269
292
  })
270
293
  if (decision === 'skip-clean') return
271
294
  if (decision === 'skip-loading') {
@@ -282,7 +305,7 @@ export function useAutoSave({
282
305
  return
283
306
  }
284
307
  // 'flush' — adopt the write as the new stored baseline.
285
- storedNodeCount.allowWrite(currentNodeCount)
308
+ storedNodeCount.allowWrite(currentNodeCount, guardAgainstSceneWipeRef.current)
286
309
 
287
310
  hasDirtyChangesRef.current = false
288
311
  const sceneGraph = {
@@ -361,5 +384,23 @@ export function useAutoSave({
361
384
  executeSaveRef.current?.()
362
385
  }, [])
363
386
 
364
- return { isLoadingSceneRef, saveNow }
387
+ const beginSceneLoad = useCallback(() => {
388
+ isLoadingSceneRef.current = true
389
+ }, [])
390
+
391
+ // Call as soon as the store holds the loaded graph — hydration is data, not
392
+ // a frame. Gating this on requestAnimationFrame kept a tab loaded while
393
+ // hidden (no frames ever run) "loading" indefinitely: every later edit was
394
+ // adopted as the loaded baseline, so nothing autosaved and the exit flush
395
+ // had nothing dirty to write.
396
+ const completeSceneLoad = useCallback(() => {
397
+ isLoadingSceneRef.current = false
398
+ // A save that came due mid-load was deferred; the store now holds real
399
+ // data, so write it rather than leave the status parked on 'paused'.
400
+ if (pendingSaveRef.current && !isSavingRef.current && !saveTimeoutRef.current) {
401
+ executeSaveRef.current?.()
402
+ }
403
+ }, [])
404
+
405
+ return { beginSceneLoad, completeSceneLoad, saveNow }
365
406
  }
@@ -101,6 +101,7 @@ test.each([
101
101
  render() {},
102
102
  setSize() {},
103
103
  setPixelRatio() {},
104
+ xr: { isPresenting: false },
104
105
  } as unknown as WebGLRenderer,
105
106
  camera,
106
107
  frameloop: 'never',