@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -13,6 +13,9 @@ export const ViewerZoneSystem = () => {
13
13
  const { levelId, zoneId } = useViewer.getState().selection
14
14
  const structureLayer = useEditor.getState().structureLayer
15
15
  const nodes = useScene.getState().nodes
16
+ // Snapshot capture is a clean, camera-only surface — zone geometry and
17
+ // tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
18
+ const isCaptureMode = useEditor.getState().isCaptureMode
16
19
  // During any active interaction zone labels step back entirely (Sims-light).
17
20
  const zoneLabelsHidden =
18
21
  resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
@@ -31,7 +34,8 @@ export const ViewerZoneSystem = () => {
31
34
  // The editor ZoneSystem handles the selected zone's opacity animation.
32
35
  const isSelected = id === zoneId
33
36
  const shouldShowGeometry =
34
- (structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
37
+ !isCaptureMode &&
38
+ ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
35
39
  if (!obj.visible) obj.visible = true
36
40
  obj.traverse((child) => {
37
41
  if ((child as Mesh).isMesh) {
@@ -40,7 +44,7 @@ export const ViewerZoneSystem = () => {
40
44
  })
41
45
 
42
46
  // Labels: always visible on the current level (regardless of mode or zone selection)
43
- const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
47
+ const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
44
48
  const targetOpacity = showLabel ? '1' : '0'
45
49
  const labelEl = document.getElementById(`${id}-label`)
46
50
  if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -17,6 +17,24 @@ export type WalkthroughHudProps = {
17
17
  children?: ReactNode
18
18
  }
19
19
 
20
+ /** The centered walkthrough pointer: a dot that grows into a green ring over
21
+ * an interactable (door / window / elevator). Also mounted by the snapshot
22
+ * capture overlay so walk / drone framing keeps the same E-to-open pointer. */
23
+ export function WalkthroughCrosshair({ interact }: { interact: WalkthroughInteract }) {
24
+ return (
25
+ <div className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
26
+ <div
27
+ className={cn(
28
+ 'rounded-full transition-all duration-150',
29
+ interact
30
+ ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
31
+ : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
32
+ )}
33
+ />
34
+ </div>
35
+ )
36
+ }
37
+
20
38
  export function WalkthroughHud({
21
39
  floorLabel,
22
40
  zoneLabel,
@@ -51,16 +69,7 @@ export function WalkthroughHud({
51
69
  {children}
52
70
  </div>
53
71
 
54
- <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
55
- <div
56
- className={cn(
57
- 'rounded-full transition-all duration-150',
58
- interact
59
- ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
60
- : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
61
- )}
62
- />
63
- </div>
72
+ <WalkthroughCrosshair interact={interact} />
64
73
 
65
74
  <div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2">
66
75
  {suspended ? (
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { isSuspiciousNodeDrop } from './use-auto-save'
2
+ import {
3
+ createStoredNodeCountTracker,
4
+ decideExitFlush,
5
+ isSuspiciousNodeDrop,
6
+ } from './use-auto-save'
3
7
 
4
8
  describe('isSuspiciousNodeDrop', () => {
5
9
  test('blocks populated scenes from being flushed as empty skeletons', () => {
@@ -12,3 +16,120 @@ describe('isSuspiciousNodeDrop', () => {
12
16
  expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
13
17
  })
14
18
  })
19
+
20
+ describe('createStoredNodeCountTracker', () => {
21
+ test('adopts a loaded graph as the baseline the guard measures against', () => {
22
+ // The hook mounts before the scene loads, so the tracker starts at the bare
23
+ // scaffold and only learns the real size when the load lands. Without this,
24
+ // every session's first save compares a populated graph against ~0 and the
25
+ // guard is dead for exactly the write that can destroy the most work.
26
+ const tracker = createStoredNodeCountTracker(0)
27
+ tracker.trackLoadedGraph(42)
28
+
29
+ expect(tracker.allowWrite(0)).toBe(false)
30
+ expect(tracker.count).toBe(42)
31
+ })
32
+
33
+ test('keeps blocking after a blocked write instead of adopting the empty graph', () => {
34
+ const tracker = createStoredNodeCountTracker(0)
35
+ tracker.trackLoadedGraph(42)
36
+
37
+ expect(tracker.allowWrite(0)).toBe(false)
38
+ // A debounced retry must not be the thing that finally lets the wipe land.
39
+ expect(tracker.allowWrite(0)).toBe(false)
40
+ })
41
+
42
+ test('lets ordinary edits through and advances the baseline', () => {
43
+ const tracker = createStoredNodeCountTracker(0)
44
+ tracker.trackLoadedGraph(12)
45
+
46
+ expect(tracker.allowWrite(13)).toBe(true)
47
+ expect(tracker.count).toBe(13)
48
+ expect(tracker.allowWrite(5)).toBe(true)
49
+ expect(tracker.count).toBe(5)
50
+ })
51
+
52
+ test('does not treat a deliberately empty new scene as suspicious', () => {
53
+ const tracker = createStoredNodeCountTracker(0)
54
+ tracker.trackLoadedGraph(4)
55
+
56
+ expect(tracker.allowWrite(0)).toBe(true)
57
+ })
58
+
59
+ test('adopts a smaller loaded graph, so restoring an older version still saves', () => {
60
+ // Loading a 3-node version over a 40-node one is not a deletion.
61
+ const tracker = createStoredNodeCountTracker(0)
62
+ tracker.trackLoadedGraph(40)
63
+ tracker.trackLoadedGraph(3)
64
+
65
+ expect(tracker.allowWrite(3)).toBe(true)
66
+ })
67
+ })
68
+
69
+ describe('decideExitFlush', () => {
70
+ test('reproduces the 2026-08-16 scene-wipe sequence and skips the flush', () => {
71
+ // The exact traced wipe (dev repro, scenes a4993ec9f1ab/1befee38f973 and
72
+ // the live sessions of 2026-08-18):
73
+ // 1. useAutoSave subscribes; store = initial empty state.
74
+ // 2. useHostPanels' mount effect writes default installedPlugins — a
75
+ // scene-store change BEFORE the Editor's load effect runs, so the
76
+ // session is marked dirty with zero user edits.
77
+ // 3. The load effect sets loading=true and calls unloadScene(); the
78
+ // tracker re-baselines to the transient 0-node state.
79
+ // 4. StrictMode's simulated unmount (prod: tab close / navigation)
80
+ // runs the effect cleanup -> flushOnExit with an EMPTY store.
81
+ // The flush must be skipped: the store content is transient, not data.
82
+ expect(
83
+ decideExitFlush({
84
+ isLoadingScene: true,
85
+ hasDirtyChanges: true,
86
+ storedNodeCount: 0,
87
+ currentNodeCount: 0,
88
+ }),
89
+ ).toBe('skip-loading')
90
+ })
91
+
92
+ test('never flushes while a load is in flight, whatever the counts say', () => {
93
+ expect(
94
+ decideExitFlush({
95
+ isLoadingScene: true,
96
+ hasDirtyChanges: true,
97
+ storedNodeCount: 74,
98
+ currentNodeCount: 74,
99
+ }),
100
+ ).toBe('skip-loading')
101
+ })
102
+
103
+ test('does nothing when there are no dirty changes', () => {
104
+ expect(
105
+ decideExitFlush({
106
+ isLoadingScene: false,
107
+ hasDirtyChanges: false,
108
+ storedNodeCount: 74,
109
+ currentNodeCount: 0,
110
+ }),
111
+ ).toBe('skip-clean')
112
+ })
113
+
114
+ test('blocks a populated-to-scaffold drop after hydration', () => {
115
+ expect(
116
+ decideExitFlush({
117
+ isLoadingScene: false,
118
+ hasDirtyChanges: true,
119
+ storedNodeCount: 74,
120
+ currentNodeCount: 0,
121
+ }),
122
+ ).toBe('blocked-suspicious')
123
+ })
124
+
125
+ test('flushes ordinary dirty sessions on exit', () => {
126
+ expect(
127
+ decideExitFlush({
128
+ isLoadingScene: false,
129
+ hasDirtyChanges: true,
130
+ storedNodeCount: 74,
131
+ currentNodeCount: 75,
132
+ }),
133
+ ).toBe('flush')
134
+ })
135
+ })
@@ -11,6 +11,69 @@ export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount
11
11
  return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
12
12
  }
13
13
 
14
+ /**
15
+ * Tracks the node count of the graph we believe is stored, which is what the
16
+ * accidental-wipe guard measures every write against.
17
+ *
18
+ * The distinction that matters: a graph that came from storage is authoritative
19
+ * and has to become the new baseline, while an edited or previewed graph must
20
+ * not. Seeding the baseline once at mount is not enough — the hook mounts
21
+ * before the scene has loaded, so it would sit at ~0 for the whole session and
22
+ * `isSuspiciousNodeDrop` could never fire.
23
+ */
24
+ export function createStoredNodeCountTracker(initialNodeCount: number) {
25
+ let count = initialNodeCount
26
+
27
+ return {
28
+ get count() {
29
+ return count
30
+ },
31
+ /** A graph read from storage — it defines what "populated" means from here. */
32
+ trackLoadedGraph(nodeCount: number) {
33
+ count = nodeCount
34
+ },
35
+ /**
36
+ * `false` when the write would drop a populated scene to a bare scaffold,
37
+ * which is an accidental full deletion far more often than an intent. The
38
+ * caller reports the block; on `true` the write becomes the new baseline.
39
+ */
40
+ allowWrite(nodeCount: number) {
41
+ if (isSuspiciousNodeDrop(count, nodeCount)) return false
42
+ count = nodeCount
43
+ return true
44
+ },
45
+ }
46
+ }
47
+
48
+ export type ExitFlushDecision = 'skip-clean' | 'skip-loading' | 'blocked-suspicious' | 'flush'
49
+
50
+ /**
51
+ * Decides what the unload/unmount flush may do with the store's current
52
+ * content. Pure so the wipe scenarios stay unit-testable.
53
+ *
54
+ * `skip-loading` is the load-bearing branch: while a scene load is in flight
55
+ * the store passes through an intermediate `unloadScene()` state — zero nodes,
56
+ * zero roots — that is NOT user data. A flush fired in that window (StrictMode
57
+ * simulated unmount in dev, a quick tab close or navigation in prod) used to
58
+ * serialize that empty store and PUT it over the server copy, wiping the scene
59
+ * at v2. The dirty flag alone cannot protect here: document-level writes that
60
+ * land before hydration (e.g. the host-panel default `installedPlugins` sync)
61
+ * mark the session dirty without any user edit.
62
+ */
63
+ export function decideExitFlush(opts: {
64
+ isLoadingScene: boolean
65
+ hasDirtyChanges: boolean
66
+ storedNodeCount: number
67
+ currentNodeCount: number
68
+ }): ExitFlushDecision {
69
+ if (!opts.hasDirtyChanges) return 'skip-clean'
70
+ if (opts.isLoadingScene) return 'skip-loading'
71
+ if (isSuspiciousNodeDrop(opts.storedNodeCount, opts.currentNodeCount)) {
72
+ return 'blocked-suspicious'
73
+ }
74
+ return 'flush'
75
+ }
76
+
14
77
  export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
15
78
 
16
79
  interface UseAutoSaveOptions {
@@ -31,10 +94,19 @@ export function useAutoSave({
31
94
  onDirty,
32
95
  onSaveStatusChange,
33
96
  isVersionPreviewMode = false,
34
- }: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
97
+ }: UseAutoSaveOptions): {
98
+ isLoadingSceneRef: MutableRefObject<boolean>
99
+ saveNow: () => void
100
+ } {
35
101
  const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
36
102
  const isSavingRef = useRef(false)
37
- const isLoadingSceneRef = useRef(false)
103
+ // Starts TRUE: the scene is "loading" from mount until the Editor's load
104
+ // effect completes its first hydration. The Editor's load effect runs
105
+ // several hooks AFTER this one (hook order), so store writes in that gap —
106
+ // e.g. `useHostPanels` syncing default `installedPlugins` on mount — must
107
+ // not mark the session dirty or arm a save: the store still holds the empty
108
+ // pre-hydration state, and flushing it wipes the scene server-side.
109
+ const isLoadingSceneRef = useRef(true)
38
110
  const pendingSaveRef = useRef(false)
39
111
  const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
40
112
  const hasDirtyChangesRef = useRef(false)
@@ -65,7 +137,9 @@ export function useAutoSave({
65
137
  // Stable subscription to scene changes
66
138
  useEffect(() => {
67
139
  let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
68
- let lastNodeCount = Object.keys(useScene.getState().nodes).length
140
+ const storedNodeCount = createStoredNodeCountTracker(
141
+ Object.keys(useScene.getState().nodes).length,
142
+ )
69
143
  // Collections + scene materials are document-level state that persists with
70
144
  // the graph but lives outside `nodes`. Track them by reference (zustand
71
145
  // hands out a new object on every mutation) so a material edit or a
@@ -90,17 +164,15 @@ export function useAutoSave({
90
164
  installedPlugins,
91
165
  } as SceneGraph
92
166
 
93
- // Guard: refuse to autosave if the scene went from populated to nearly empty.
94
- // This catches accidental full deletions before they're persisted.
95
167
  const currentNodeCount = Object.keys(nodes).length
96
- if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
168
+ const previousNodeCount = storedNodeCount.count
169
+ if (!storedNodeCount.allowWrite(currentNodeCount)) {
97
170
  console.warn(
98
- `[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
171
+ `[autosave] Blocked: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
99
172
  )
100
173
  setSaveStatus('error')
101
174
  return
102
175
  }
103
- lastNodeCount = currentNodeCount
104
176
 
105
177
  isSavingRef.current = true
106
178
  pendingSaveRef.current = false
@@ -135,6 +207,7 @@ export function useAutoSave({
135
207
  const unsubscribe = useScene.subscribe((state) => {
136
208
  if (isLoadingSceneRef.current) {
137
209
  lastNodesSnapshot = JSON.stringify(state.nodes)
210
+ storedNodeCount.trackLoadedGraph(Object.keys(state.nodes).length)
138
211
  lastCollectionsRef = state.collections
139
212
  lastMaterialsRef = state.materials
140
213
  lastInstalledPluginsRef = state.installedPlugins
@@ -185,19 +258,33 @@ export function useAutoSave({
185
258
  // would otherwise drop the change entirely. `pagehide` fires in cases
186
259
  // (mobile Safari, bfcache) where `beforeunload` does not.
187
260
  function flushOnExit() {
188
- if (!hasDirtyChangesRef.current) return
189
261
  const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
190
262
  const currentNodeCount = Object.keys(nodes).length
191
- if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
263
+ const previousNodeCount = storedNodeCount.count
264
+ const decision = decideExitFlush({
265
+ isLoadingScene: isLoadingSceneRef.current,
266
+ hasDirtyChanges: hasDirtyChangesRef.current,
267
+ storedNodeCount: previousNodeCount,
268
+ currentNodeCount,
269
+ })
270
+ if (decision === 'skip-clean') return
271
+ if (decision === 'skip-loading') {
192
272
  console.warn(
193
- `[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
273
+ '[autosave] Skipped unload flush: a scene load is in flight, the store content is transient. Nothing user-authored is lost.',
274
+ )
275
+ return
276
+ }
277
+ if (decision === 'blocked-suspicious') {
278
+ console.warn(
279
+ `[autosave] Blocked unload flush: scene dropped from ${previousNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
194
280
  )
195
281
  setSaveStatus('error')
196
282
  return
197
283
  }
284
+ // 'flush' — adopt the write as the new stored baseline.
285
+ storedNodeCount.allowWrite(currentNodeCount)
198
286
 
199
287
  hasDirtyChangesRef.current = false
200
- lastNodeCount = currentNodeCount
201
288
  const sceneGraph = {
202
289
  nodes,
203
290
  rootNodeIds,
@@ -255,5 +342,24 @@ export function useAutoSave({
255
342
  setSaveStatus('saved')
256
343
  }, [isVersionPreviewMode, setSaveStatus])
257
344
 
258
- return { isLoadingSceneRef }
345
+ // Imperative flush for the save shortcut: drop the debounce and write now,
346
+ // through the same `executeSave` so the wipe guard and status callbacks stay
347
+ // in the loop. A write already in flight only arms the follow-up.
348
+ const saveNow = useCallback(() => {
349
+ if (isLoadingSceneRef.current) return
350
+
351
+ if (saveTimeoutRef.current) {
352
+ clearTimeout(saveTimeoutRef.current)
353
+ saveTimeoutRef.current = undefined
354
+ }
355
+
356
+ if (isSavingRef.current) {
357
+ pendingSaveRef.current = true
358
+ return
359
+ }
360
+
361
+ executeSaveRef.current?.()
362
+ }, [])
363
+
364
+ return { isLoadingSceneRef, saveNow }
259
365
  }
@@ -0,0 +1,122 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ type CeilingEvent,
4
+ CeilingNode,
5
+ emitter,
6
+ LevelNode,
7
+ sceneRegistry,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
11
+ import { _roots, act, createRoot } from '@react-three/fiber'
12
+ import { createElement } from 'react'
13
+ import {
14
+ Mesh,
15
+ MeshBasicMaterial,
16
+ PerspectiveCamera,
17
+ PlaneGeometry,
18
+ type WebGLRenderer,
19
+ } from 'three'
20
+ import useEditor from '../store/use-editor'
21
+ import { useCeilingEvents } from './use-ceiling-events'
22
+
23
+ test('ceiling-item placement keeps move and commit hits while an unhovered ceiling is batched', async () => {
24
+ const previousViewer = useViewer.getState()
25
+ const previousEditor = useEditor.getState()
26
+ const previousScene = useScene.getState()
27
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
28
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
29
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
30
+ const canvas = Object.assign(new EventTarget(), {
31
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
32
+ }) as unknown as HTMLCanvasElement
33
+ const root = createRoot(canvas)
34
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
35
+ camera.position.set(0, 1, 5)
36
+ camera.lookAt(0, 3, 0)
37
+ camera.updateMatrixWorld()
38
+ const level = LevelNode.parse({ id: 'level_ceiling_batch' })
39
+ const ceiling = CeilingNode.parse({
40
+ id: 'ceiling_placement_batch',
41
+ parentId: level.id,
42
+ polygon: [
43
+ [-5, -5],
44
+ [5, -5],
45
+ [5, 5],
46
+ [-5, 5],
47
+ ],
48
+ })
49
+ const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial())
50
+ surface.position.y = 3
51
+ surface.updateMatrixWorld(true)
52
+ const moves: CeilingEvent[] = []
53
+ const clicks: CeilingEvent[] = []
54
+ const onMove = (event: CeilingEvent) => moves.push(event)
55
+ const onClick = (event: CeilingEvent) => clicks.push(event)
56
+ emitter.on('ceiling:move', onMove)
57
+ emitter.on('ceiling:click', onClick)
58
+ function Placement() {
59
+ useCeilingEvents()
60
+ return null
61
+ }
62
+ const send = () => {
63
+ for (const type of ['pointermove', 'click']) {
64
+ canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }))
65
+ }
66
+ }
67
+ try {
68
+ sceneRegistry.nodes.set(ceiling.id, surface)
69
+ sceneRegistry.byType.ceiling!.add(ceiling.id)
70
+ useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } })
71
+ useViewer.setState({
72
+ hoveredId: null,
73
+ cameraDragging: false,
74
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
75
+ })
76
+ useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
77
+ await root.configure({
78
+ gl: {
79
+ domElement: canvas,
80
+ render() {},
81
+ setSize() {},
82
+ setPixelRatio() {},
83
+ } as unknown as WebGLRenderer,
84
+ camera,
85
+ frameloop: 'never',
86
+ dpr: 1,
87
+ size: { width: 100, height: 100, top: 0, left: 0 },
88
+ })
89
+ await act(async () => {
90
+ root.render(createElement(Placement))
91
+ })
92
+ send()
93
+ hideFromScene(surface, 'batched')
94
+ send()
95
+ showInScene(surface, 'batched')
96
+ send()
97
+ expect(moves).toHaveLength(3)
98
+ expect(clicks).toHaveLength(3)
99
+ expect(moves[0]!.position[1]).toBeCloseTo(3)
100
+ for (const hit of [...moves, ...clicks]) {
101
+ expect(hit.node.id).toBe(ceiling.id)
102
+ expect(hit.position).toEqual(moves[0]!.position)
103
+ expect(hit.localPosition).toEqual(moves[0]!.localPosition)
104
+ }
105
+ expect(useViewer.getState().hoveredId).toBeNull()
106
+ } finally {
107
+ await act(async () => {
108
+ root.render(null)
109
+ })
110
+ _roots.delete(canvas)
111
+ emitter.off('ceiling:move', onMove)
112
+ emitter.off('ceiling:click', onClick)
113
+ sceneRegistry.nodes.delete(ceiling.id)
114
+ sceneRegistry.byType.ceiling!.delete(ceiling.id)
115
+ surface.geometry.dispose()
116
+ surface.material.dispose()
117
+ useScene.setState(previousScene)
118
+ useEditor.setState(previousEditor)
119
+ useViewer.setState(previousViewer)
120
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
121
+ }
122
+ })
@@ -14,6 +14,7 @@ import {
14
14
  type SpatialQuery,
15
15
  useScene,
16
16
  } from '@pascal-app/core'
17
+ import { beginPerfAction, cancelPerfAction, commitPerfAction } from '@pascal-app/viewer'
17
18
  import { useEffect, useRef } from 'react'
18
19
 
19
20
  const sceneApi = createSceneApi(useScene)
@@ -74,14 +75,26 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
74
75
  useEffect(() => {
75
76
  if (!args.active) return
76
77
 
78
+ const initial = argsRef.current.initial
79
+ const nodeType = initial.node?.type
80
+ const isEndpoint = initial.handleId === 'start' || initial.handleId === 'end'
81
+ const actionName = isEndpoint && nodeType ? `drag:${nodeType}-endpoint` : 'drag:move'
82
+
77
83
  const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
78
84
  spatialQuery: argsRef.current.spatialQuery,
79
85
  childQuery: argsRef.current.childQuery,
80
- onCommit: () => argsRef.current.onCommit?.(),
81
- onCancel: () => argsRef.current.onCancel?.(),
86
+ onCommit: () => {
87
+ commitPerfAction()
88
+ argsRef.current.onCommit?.()
89
+ },
90
+ onCancel: () => {
91
+ cancelPerfAction()
92
+ argsRef.current.onCancel?.()
93
+ },
82
94
  })
83
95
 
84
- session.start(argsRef.current.initial)
96
+ beginPerfAction(actionName, initial.node?.id ?? nodeType ?? '')
97
+ session.start(initial)
85
98
 
86
99
  const activatedAt = Date.now()
87
100
  const graceMs = argsRef.current.activationGraceMs ?? 150
@@ -119,6 +132,7 @@ export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
119
132
  }
120
133
  // If the parent flipped `active` to false (or unmounted) while we were
121
134
  // still mid-drag, treat it as a cancel — no dangling history pause.
135
+ if (session.isActive()) cancelPerfAction()
122
136
  session.dispose()
123
137
  }
124
138
  }, [args.active])