@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -6,17 +6,45 @@ import {
6
6
  type AnyNodeId,
7
7
  type BuildingNode,
8
8
  emitter,
9
+ getLevelDisplayName,
9
10
  type LevelNode,
10
11
  useScene,
11
12
  type ZoneNode,
12
13
  } from '@pascal-app/core'
13
- import { useViewer } from '@pascal-app/viewer'
14
- import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
15
- import { motion } from 'motion/react'
14
+ import {
15
+ CLAY_PALETTE,
16
+ type EdgeMode,
17
+ getSceneTheme,
18
+ SCENE_THEMES,
19
+ useViewer,
20
+ } from '@pascal-app/viewer'
21
+ import {
22
+ ArrowLeft,
23
+ Box,
24
+ Camera,
25
+ Check,
26
+ ChevronRight,
27
+ Diamond,
28
+ Footprints,
29
+ Layers,
30
+ Palette,
31
+ PenLine,
32
+ Sparkles,
33
+ Square,
34
+ } from 'lucide-react'
16
35
  import Link from 'next/link'
36
+ import { flushSync } from 'react-dom'
17
37
  import { useShallow } from 'zustand/react/shallow'
18
38
  import { cn } from '../lib/utils'
39
+ import useEditor from '../store/use-editor'
19
40
  import { ActionButton } from './ui/action-menu/action-button'
41
+ import {
42
+ DropdownMenu,
43
+ DropdownMenuContent,
44
+ DropdownMenuItem,
45
+ DropdownMenuSeparator,
46
+ DropdownMenuTrigger,
47
+ } from './ui/primitives/dropdown-menu'
20
48
  import { TooltipProvider } from './ui/primitives/tooltip'
21
49
 
22
50
  type ProjectOwner = {
@@ -26,6 +54,24 @@ type ProjectOwner = {
26
54
  image: string | null
27
55
  }
28
56
 
57
+ function requestWalkthroughPointerLock() {
58
+ const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
59
+ if (!canvas) return
60
+
61
+ if (!canvas.hasAttribute('tabindex')) {
62
+ canvas.tabIndex = -1
63
+ }
64
+ canvas.focus({ preventScroll: true })
65
+
66
+ if (document.pointerLockElement === canvas) return
67
+
68
+ try {
69
+ canvas.requestPointerLock?.()
70
+ } catch {
71
+ return
72
+ }
73
+ }
74
+
29
75
  const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
30
76
  stacked: 'Stacked',
31
77
  exploded: 'Exploded',
@@ -42,22 +88,188 @@ const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', s
42
88
  const wallModeConfig = {
43
89
  up: {
44
90
  icon: (props: any) => (
45
- <img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
91
+ <img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
46
92
  ),
47
93
  label: 'Full Height',
48
94
  },
49
95
  cutaway: {
50
96
  icon: (props: any) => (
51
- <img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
97
+ <img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
52
98
  ),
53
99
  label: 'Cutaway',
54
100
  },
55
101
  down: {
56
102
  icon: (props: any) => (
57
- <img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />
103
+ <img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
58
104
  ),
59
105
  label: 'Low',
60
106
  },
107
+ translucent: {
108
+ icon: (props: any) => (
109
+ <img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
110
+ ),
111
+ label: 'Translucent',
112
+ },
113
+ }
114
+
115
+ const SHADING_OPTIONS = [
116
+ { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
117
+ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
118
+ ] as const
119
+
120
+ const TEXTURE_OPTIONS = [
121
+ { id: true, name: 'Colored', detail: 'Show materials, textures & colors', icon: Palette },
122
+ { id: false, name: 'Monochrome', detail: 'Flat clay surfaces by role', icon: Square },
123
+ ] as const
124
+
125
+ function RenderModeMenu() {
126
+ const shading = useViewer((s) => s.shading)
127
+ const textures = useViewer((s) => s.textures)
128
+ const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
129
+ const ActiveIcon = active.icon
130
+ return (
131
+ <DropdownMenu>
132
+ <DropdownMenuTrigger asChild>
133
+ <ActionButton
134
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
135
+ label={`Render: ${active.name}`}
136
+ size="icon"
137
+ tooltipSide="top"
138
+ variant="ghost"
139
+ >
140
+ <ActiveIcon className="h-6 w-6" />
141
+ </ActionButton>
142
+ </DropdownMenuTrigger>
143
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
144
+ {SHADING_OPTIONS.map((option) => {
145
+ const OptionIcon = option.icon
146
+ return (
147
+ <DropdownMenuItem
148
+ key={option.id}
149
+ onSelect={() => useViewer.getState().setShading(option.id)}
150
+ >
151
+ <OptionIcon />
152
+ <div className="flex flex-col">
153
+ <span className="text-foreground">{option.name}</span>
154
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
155
+ </div>
156
+ {shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
157
+ </DropdownMenuItem>
158
+ )
159
+ })}
160
+ <DropdownMenuSeparator />
161
+ {TEXTURE_OPTIONS.map((option) => {
162
+ const OptionIcon = option.icon
163
+ return (
164
+ <DropdownMenuItem
165
+ key={option.name}
166
+ onSelect={() => useViewer.getState().setTextures(option.id)}
167
+ >
168
+ <OptionIcon />
169
+ <div className="flex flex-col">
170
+ <span className="text-foreground">{option.name}</span>
171
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
172
+ </div>
173
+ {textures === option.id ? <Check className="ml-auto text-foreground" /> : null}
174
+ </DropdownMenuItem>
175
+ )
176
+ })}
177
+ </DropdownMenuContent>
178
+ </DropdownMenu>
179
+ )
180
+ }
181
+
182
+ function SceneThemeMenu() {
183
+ const sceneTheme = useViewer((s) => s.sceneTheme)
184
+ const active = getSceneTheme(sceneTheme)
185
+ return (
186
+ <DropdownMenu>
187
+ <DropdownMenuTrigger asChild>
188
+ <ActionButton
189
+ className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
190
+ label={`Theme: ${active.name}`}
191
+ size="icon"
192
+ tooltipSide="top"
193
+ variant="ghost"
194
+ >
195
+ <Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
196
+ </ActionButton>
197
+ </DropdownMenuTrigger>
198
+ <DropdownMenuContent align="center" className="min-w-48" side="top">
199
+ {SCENE_THEMES.map((sceneThemeOption) => {
200
+ const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
201
+ (role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
202
+ )
203
+ return (
204
+ <DropdownMenuItem
205
+ key={sceneThemeOption.id}
206
+ onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
207
+ >
208
+ <span
209
+ className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
210
+ style={{ backgroundColor: sceneThemeOption.background }}
211
+ >
212
+ {swatches.map((color, index) => (
213
+ <span
214
+ key={`${sceneThemeOption.id}-${index}`}
215
+ style={{ backgroundColor: color }}
216
+ />
217
+ ))}
218
+ </span>
219
+ <span className="text-foreground">{sceneThemeOption.name}</span>
220
+ {sceneTheme === sceneThemeOption.id ? (
221
+ <Check className="ml-auto text-foreground" />
222
+ ) : null}
223
+ </DropdownMenuItem>
224
+ )
225
+ })}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ )
229
+ }
230
+
231
+ const EDGE_OPTIONS = [
232
+ { id: 'off', name: 'Off', detail: 'No edge lines' },
233
+ { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
234
+ { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
235
+ ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
236
+
237
+ function EdgesMenu() {
238
+ const edges = useViewer((s) => s.edges)
239
+ const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
240
+ return (
241
+ <DropdownMenu>
242
+ <DropdownMenuTrigger asChild>
243
+ <ActionButton
244
+ className={
245
+ edges === 'off'
246
+ ? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
247
+ : 'bg-white/10 text-foreground'
248
+ }
249
+ label={`Edges: ${active.name}`}
250
+ size="icon"
251
+ tooltipSide="top"
252
+ variant="ghost"
253
+ >
254
+ <PenLine className="h-6 w-6" />
255
+ </ActionButton>
256
+ </DropdownMenuTrigger>
257
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
258
+ {EDGE_OPTIONS.map((option) => (
259
+ <DropdownMenuItem
260
+ key={option.id}
261
+ onSelect={() => useViewer.getState().setEdges(option.id)}
262
+ >
263
+ <div className="flex flex-col">
264
+ <span className="text-foreground">{option.name}</span>
265
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
266
+ </div>
267
+ {edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
268
+ </DropdownMenuItem>
269
+ ))}
270
+ </DropdownMenuContent>
271
+ </DropdownMenu>
272
+ )
61
273
  }
62
274
 
63
275
  const getNodeName = (node: AnyNode): string => {
@@ -93,7 +305,6 @@ export const ViewerOverlay = ({
93
305
  const cameraMode = useViewer((s) => s.cameraMode)
94
306
  const levelMode = useViewer((s) => s.levelMode)
95
307
  const wallMode = useViewer((s) => s.wallMode)
96
- const theme = useViewer((s) => s.theme)
97
308
 
98
309
  // Subscribe only to the specific nodes we read so that creating an unrelated
99
310
  // node elsewhere in the scene doesn't re-render this overlay.
@@ -206,7 +417,7 @@ export const ViewerOverlay = ({
206
417
  className={`truncate transition-colors ${zone ? 'text-muted-foreground hover:text-foreground' : 'font-medium text-foreground'}`}
207
418
  onClick={() => handleBreadcrumbClick('level')}
208
419
  >
209
- {level.name || `Level ${level.level}`}
420
+ {getLevelDisplayName(level)}
210
421
  </button>
211
422
  </>
212
423
  )}
@@ -280,53 +491,6 @@ export const ViewerOverlay = ({
280
491
  <div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
281
492
  <TooltipProvider delayDuration={0}>
282
493
  <div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
283
- {/* Theme Toggle */}
284
- <button
285
- aria-label="Toggle theme"
286
- className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
287
- onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
288
- type="button"
289
- >
290
- <div className="relative flex">
291
- {/* Sliding Background */}
292
- <motion.div
293
- animate={{
294
- x: theme === 'light' ? '100%' : '0%',
295
- }}
296
- className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
297
- initial={false}
298
- style={{ width: '50%' }}
299
- transition={{
300
- type: 'spring',
301
- stiffness: 500,
302
- damping: 35,
303
- }}
304
- />
305
-
306
- {/* Dark Mode Icon */}
307
- <div
308
- className={cn(
309
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
310
- theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
311
- )}
312
- >
313
- <Moon className="h-4 w-4" />
314
- </div>
315
-
316
- {/* Light Mode Icon */}
317
- <div
318
- className={cn(
319
- 'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
320
- theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
321
- )}
322
- >
323
- <Sun className="h-4 w-4" />
324
- </div>
325
- </div>
326
- </button>
327
-
328
- <div className="mx-1 h-5 w-px bg-border/40" />
329
-
330
494
  {/* Scans and Guides Visibility */}
331
495
  {canShowScans && (
332
496
  <ActionButton
@@ -344,7 +508,7 @@ export const ViewerOverlay = ({
344
508
  <img
345
509
  alt="Scans"
346
510
  className="h-[28px] w-[28px] object-contain"
347
- src="/icons/mesh.png"
511
+ src="/icons/mesh.webp"
348
512
  />
349
513
  </ActionButton>
350
514
  )}
@@ -365,7 +529,7 @@ export const ViewerOverlay = ({
365
529
  <img
366
530
  alt="Guides"
367
531
  className="h-[28px] w-[28px] object-contain"
368
- src="/icons/floorplan.png"
532
+ src="/icons/floorplan.webp"
369
533
  />
370
534
  </ActionButton>
371
535
  )}
@@ -392,6 +556,12 @@ export const ViewerOverlay = ({
392
556
  <Camera className="h-6 w-6" />
393
557
  </ActionButton>
394
558
 
559
+ <RenderModeMenu />
560
+
561
+ <SceneThemeMenu />
562
+
563
+ <EdgesMenu />
564
+
395
565
  {/* Level Mode */}
396
566
  <ActionButton
397
567
  className={cn(
@@ -437,7 +607,12 @@ export const ViewerOverlay = ({
437
607
  }
438
608
  label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
439
609
  onClick={() => {
440
- const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
610
+ const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
611
+ 'cutaway',
612
+ 'up',
613
+ 'down',
614
+ 'translucent',
615
+ ]
441
616
  const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
442
617
  useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
443
618
  }}
@@ -465,7 +640,7 @@ export const ViewerOverlay = ({
465
640
  <img
466
641
  alt="Orbit Left"
467
642
  className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
468
- src="/icons/rotate.png"
643
+ src="/icons/rotate.webp"
469
644
  />
470
645
  </ActionButton>
471
646
 
@@ -480,7 +655,7 @@ export const ViewerOverlay = ({
480
655
  <img
481
656
  alt="Orbit Right"
482
657
  className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
483
- src="/icons/rotate.png"
658
+ src="/icons/rotate.webp"
484
659
  />
485
660
  </ActionButton>
486
661
 
@@ -495,9 +670,26 @@ export const ViewerOverlay = ({
495
670
  <img
496
671
  alt="Top View"
497
672
  className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
498
- src="/icons/topview.png"
673
+ src="/icons/topview.webp"
499
674
  />
500
675
  </ActionButton>
676
+
677
+ <div className="mx-1 h-5 w-px bg-border/40" />
678
+
679
+ {/* First-person walkthrough */}
680
+ <ActionButton
681
+ className="hover:bg-white/5 hover:text-emerald-400"
682
+ label="Walkthrough"
683
+ onClick={() => {
684
+ flushSync(() => useEditor.getState().setFirstPersonMode(true))
685
+ requestWalkthroughPointerLock()
686
+ }}
687
+ size="icon"
688
+ tooltipSide="top"
689
+ variant="ghost"
690
+ >
691
+ <Footprints className="h-6 w-6" />
692
+ </ActionButton>
501
693
  </div>
502
694
  </TooltipProvider>
503
695
  </div>
@@ -4,15 +4,20 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import { useFrame } from '@react-three/fiber'
6
6
  import type { Mesh } from 'three'
7
+ import { resolveOverlayPolicy } from '../lib/interaction/overlay-policy'
7
8
  import useEditor from '../store/use-editor'
9
+ import useInteractionScope from '../store/use-interaction-scope'
8
10
 
9
11
  export const ViewerZoneSystem = () => {
10
12
  useFrame(() => {
11
13
  const { levelId, zoneId } = useViewer.getState().selection
12
14
  const structureLayer = useEditor.getState().structureLayer
13
15
  const nodes = useScene.getState().nodes
16
+ // During any active interaction zone labels step back entirely (Sims-light).
17
+ const zoneLabelsHidden =
18
+ resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
14
19
 
15
- sceneRegistry.byType.zone.forEach((id) => {
20
+ sceneRegistry.byType.zone!.forEach((id) => {
16
21
  const obj = sceneRegistry.nodes.get(id)
17
22
  if (!obj) return
18
23
 
@@ -35,7 +40,7 @@ export const ViewerZoneSystem = () => {
35
40
  })
36
41
 
37
42
  // Labels: always visible on the current level (regardless of mode or zone selection)
38
- const showLabel = !!levelId && isOnSelectedLevel
43
+ const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
39
44
  const targetOpacity = showLabel ? '1' : '0'
40
45
  const labelEl = document.getElementById(`${id}-label`)
41
46
  if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { isSuspiciousNodeDrop } from './use-auto-save'
3
+
4
+ describe('isSuspiciousNodeDrop', () => {
5
+ test('blocks populated scenes from being flushed as empty skeletons', () => {
6
+ expect(isSuspiciousNodeDrop(12, 0)).toBe(true)
7
+ expect(isSuspiciousNodeDrop(12, 4)).toBe(true)
8
+ })
9
+
10
+ test('allows ordinary edits and intentionally empty starting scenes', () => {
11
+ expect(isSuspiciousNodeDrop(12, 11)).toBe(false)
12
+ expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
13
+ })
14
+ })
@@ -5,11 +5,16 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
5
5
  import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
6
6
 
7
7
  const AUTOSAVE_DEBOUNCE_MS = 1000
8
+ const STRUCTURAL_NODE_COUNT = 4
9
+
10
+ export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
11
+ return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
12
+ }
8
13
 
9
14
  export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
10
15
 
11
16
  interface UseAutoSaveOptions {
12
- onSave?: (scene: SceneGraph) => Promise<void>
17
+ onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
13
18
  onDirty?: () => void
14
19
  onSaveStatusChange?: (status: SaveStatus) => void
15
20
  isVersionPreviewMode?: boolean
@@ -61,6 +66,13 @@ export function useAutoSave({
61
66
  useEffect(() => {
62
67
  let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
63
68
  let lastNodeCount = Object.keys(useScene.getState().nodes).length
69
+ // Collections + scene materials are document-level state that persists with
70
+ // the graph but lives outside `nodes`. Track them by reference (zustand
71
+ // hands out a new object on every mutation) so a material edit or a
72
+ // collection change still triggers a save.
73
+ let lastCollectionsRef = useScene.getState().collections
74
+ let lastMaterialsRef = useScene.getState().materials
75
+ let lastInstalledPluginsRef = useScene.getState().installedPlugins
64
76
 
65
77
  async function executeSave() {
66
78
  if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
@@ -69,14 +81,19 @@ export function useAutoSave({
69
81
  return
70
82
  }
71
83
 
72
- const { nodes, rootNodeIds } = useScene.getState()
73
- const sceneGraph = { nodes, rootNodeIds } as SceneGraph
84
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
85
+ const sceneGraph = {
86
+ nodes,
87
+ rootNodeIds,
88
+ collections,
89
+ materials,
90
+ installedPlugins,
91
+ } as SceneGraph
74
92
 
75
93
  // Guard: refuse to autosave if the scene went from populated to nearly empty.
76
94
  // This catches accidental full deletions before they're persisted.
77
95
  const currentNodeCount = Object.keys(nodes).length
78
- const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
79
- if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
96
+ if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
80
97
  console.warn(
81
98
  `[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
82
99
  )
@@ -118,19 +135,33 @@ export function useAutoSave({
118
135
  const unsubscribe = useScene.subscribe((state) => {
119
136
  if (isLoadingSceneRef.current) {
120
137
  lastNodesSnapshot = JSON.stringify(state.nodes)
138
+ lastCollectionsRef = state.collections
139
+ lastMaterialsRef = state.materials
140
+ lastInstalledPluginsRef = state.installedPlugins
121
141
  return
122
142
  }
123
143
 
124
144
  if (isVersionPreviewModeRef.current) {
125
145
  setSaveStatus('paused')
126
146
  lastNodesSnapshot = JSON.stringify(state.nodes)
147
+ lastCollectionsRef = state.collections
148
+ lastMaterialsRef = state.materials
149
+ lastInstalledPluginsRef = state.installedPlugins
127
150
  return
128
151
  }
129
152
 
130
153
  const currentNodesSnapshot = JSON.stringify(state.nodes)
131
- if (currentNodesSnapshot === lastNodesSnapshot) return
154
+ const changed =
155
+ currentNodesSnapshot !== lastNodesSnapshot ||
156
+ state.collections !== lastCollectionsRef ||
157
+ state.materials !== lastMaterialsRef ||
158
+ state.installedPlugins !== lastInstalledPluginsRef
159
+ if (!changed) return
132
160
 
133
161
  lastNodesSnapshot = currentNodesSnapshot
162
+ lastCollectionsRef = state.collections
163
+ lastMaterialsRef = state.materials
164
+ lastInstalledPluginsRef = state.installedPlugins
134
165
  hasDirtyChangesRef.current = true
135
166
  onDirtyRef.current?.()
136
167
  setSaveStatus('pending')
@@ -148,23 +179,46 @@ export function useAutoSave({
148
179
  }, AUTOSAVE_DEBOUNCE_MS)
149
180
  })
150
181
 
182
+ // Flush any unsaved change while the page is going away. The network
183
+ // save MUST set `keepalive` — a normal fetch is cancelled by the browser
184
+ // the moment the page unloads, so a quick refresh right after an edit
185
+ // would otherwise drop the change entirely. `pagehide` fires in cases
186
+ // (mobile Safari, bfcache) where `beforeunload` does not.
151
187
  function flushOnExit() {
152
188
  if (!hasDirtyChangesRef.current) return
153
- const { nodes, rootNodeIds } = useScene.getState()
154
- const sceneGraph = { nodes, rootNodeIds } as SceneGraph
189
+ const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
190
+ const currentNodeCount = Object.keys(nodes).length
191
+ if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
192
+ console.warn(
193
+ `[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
194
+ )
195
+ setSaveStatus('error')
196
+ return
197
+ }
198
+
199
+ hasDirtyChangesRef.current = false
200
+ lastNodeCount = currentNodeCount
201
+ const sceneGraph = {
202
+ nodes,
203
+ rootNodeIds,
204
+ collections,
205
+ materials,
206
+ installedPlugins,
207
+ } as SceneGraph
155
208
  if (onSaveRef.current) {
156
- onSaveRef.current(sceneGraph).catch(() => {})
209
+ onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
157
210
  } else {
158
211
  saveSceneToLocalStorage(sceneGraph)
159
212
  }
160
- hasDirtyChangesRef.current = false
161
213
  }
162
214
 
163
215
  window.addEventListener('beforeunload', flushOnExit)
216
+ window.addEventListener('pagehide', flushOnExit)
164
217
 
165
218
  return () => {
166
219
  executeSaveRef.current = null
167
220
  window.removeEventListener('beforeunload', flushOnExit)
221
+ window.removeEventListener('pagehide', flushOnExit)
168
222
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
169
223
  flushOnExit()
170
224
  unsubscribe()