@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -1,8 +1,11 @@
1
1
  import { afterEach, expect, test } from 'bun:test'
2
2
  import {
3
3
  BuildingNode,
4
+ beginScenePreview,
4
5
  ColumnNode,
5
6
  clearSceneHistory,
7
+ endScenePreview,
8
+ isScenePreviewing,
6
9
  LevelNode,
7
10
  SiteNode,
8
11
  useScene,
@@ -16,6 +19,7 @@ const previousScene = useScene.getState()
16
19
  let renderer: Awaited<ReturnType<typeof create>> | undefined
17
20
 
18
21
  afterEach(async () => {
22
+ endScenePreview()
19
23
  await renderer?.unmount()
20
24
  renderer = undefined
21
25
  globalThis.window = originalWindow
@@ -192,30 +196,29 @@ test('a save that came due mid-load is written once the load completes', async (
192
196
  expect(statuses.at(-1)).toBe('saved')
193
197
  })
194
198
 
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
- })
199
+ test.each(['undo', 'redo', 'delete'] as const)(
200
+ 'autosave permits a 5 to 4 single-node %s',
201
+ async (action) => {
202
+ const { writes, statuses } = await mount(5)
203
+ if (action === 'undo') {
204
+ useScene.setState(graph(4))
205
+ clearSceneHistory()
206
+ useScene.setState(graph(5))
207
+ useScene.temporal.getState().undo()
208
+ } else if (action === 'redo') {
209
+ useScene.getState().deleteNode('column_autosave_1')
210
+ useScene.temporal.getState().undo()
211
+ useScene.temporal.getState().redo()
212
+ } else {
213
+ useScene.getState().deleteNode('column_autosave_1')
214
+ }
215
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(4)
216
+ await Bun.sleep(1100)
217
+ expect(statuses).not.toContain('error')
218
+ expect(writes).toEqual([4])
219
+ expect(statuses.at(-1)).toBe('saved')
220
+ },
221
+ )
219
222
 
220
223
  test('autosave permits a single-node deletion on exit', async () => {
221
224
  const { writes, statuses } = await mount(5)
@@ -252,20 +255,20 @@ test('collaboration scaffold deletion waits for persistence and reports saved',
252
255
  expect(statuses).toEqual(['pending', 'saving', 'saved'])
253
256
  })
254
257
 
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
- })
258
+ test.each(['pagehide', 'unmount'])(
259
+ 'collaboration scaffold deletion still flushes on %s',
260
+ async (event) => {
261
+ const { writes, statuses } = await mount(78, false)
262
+ useScene.setState(graph(4))
263
+ if (event === 'pagehide') window.dispatchEvent(new Event(event))
264
+ else {
265
+ await renderer!.unmount()
266
+ renderer = undefined
267
+ }
268
+ expect(statuses).not.toContain('error')
269
+ expect(writes).toEqual([4])
270
+ },
271
+ )
269
272
 
270
273
  test('collaboration guard exemption preserves preview pause and resume', async () => {
271
274
  const { writes, statuses, update } = await mount(78, false)
@@ -309,3 +312,58 @@ test('collaboration callback failures still report error', async () => {
309
312
  expect(writes).toEqual([4])
310
313
  expect(statuses).toEqual(['pending', 'saving', 'error'])
311
314
  })
315
+
316
+ // A scene preview (the scene as it was before an agent's turn, shown while a control is held) is not
317
+ // an edit: it must reach neither the server nor the exit flush, and must not leave a save armed.
318
+ const beforeLastEdit = () => useScene.temporal.getState().pastStates.at(-1) as never
319
+
320
+ test('a held preview saves nothing and leaves nothing to save', async () => {
321
+ const { writes } = await mount(12)
322
+ agentAddsColumn()
323
+ await Bun.sleep(1100)
324
+ expect(writes).toEqual([13])
325
+
326
+ const preview = beginScenePreview(beforeLastEdit())
327
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(12)
328
+ await Bun.sleep(1100)
329
+ preview?.end()
330
+ await Bun.sleep(1100)
331
+
332
+ expect(writes).toEqual([13])
333
+ })
334
+
335
+ test('an edit that came due under a held preview is written after it, from the live scene', async () => {
336
+ const { writes } = await mount(12)
337
+ agentAddsColumn()
338
+ const preview = beginScenePreview(beforeLastEdit())
339
+ await Bun.sleep(1100)
340
+ expect(writes).toEqual([])
341
+
342
+ preview?.end()
343
+ await Bun.sleep(10)
344
+
345
+ expect(writes).toEqual([13])
346
+ })
347
+
348
+ test('leaving the page under a held preview writes the live scene, not the preview', async () => {
349
+ const { writes } = await mount(12)
350
+ agentAddsColumn()
351
+ beginScenePreview(beforeLastEdit())
352
+
353
+ window.dispatchEvent(new Event('pagehide'))
354
+
355
+ expect(isScenePreviewing()).toBe(false)
356
+ expect(writes).toEqual([13])
357
+ })
358
+
359
+ test('the save shortcut under a held preview writes the live scene', async () => {
360
+ const { writes, controls } = await mount(12)
361
+ agentAddsColumn()
362
+ beginScenePreview(beforeLastEdit())
363
+
364
+ controls.saveNow()
365
+ await Bun.sleep(0)
366
+
367
+ expect(isScenePreviewing()).toBe(false)
368
+ expect(writes).toEqual([13])
369
+ })
@@ -1,6 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { useScene } from '@pascal-app/core'
3
+ import {
4
+ endScenePreview,
5
+ isScenePreviewing,
6
+ subscribeScenePreview,
7
+ useScene,
8
+ } from '@pascal-app/core'
4
9
  import { useCallback, useEffect, useRef } from 'react'
5
10
  import { getSavedSceneDocument, type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
6
11
 
@@ -164,6 +169,11 @@ export function useAutoSave({
164
169
  let lastInstalledPluginsRef = useScene.getState().installedPlugins
165
170
 
166
171
  async function executeSave() {
172
+ // A held scene preview is on screen, not in the document: wait for it to end.
173
+ if (isScenePreviewing()) {
174
+ pendingSaveRef.current = true
175
+ return
176
+ }
167
177
  if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
168
178
  pendingSaveRef.current = true
169
179
  setSaveStatus('paused')
@@ -230,6 +240,10 @@ export function useAutoSave({
230
240
  return
231
241
  }
232
242
 
243
+ // The store shows another state for a moment: it is not a change, and the live scene it
244
+ // gives back is what the last snapshot already holds.
245
+ if (isScenePreviewing()) return
246
+
233
247
  if (isVersionPreviewModeRef.current) {
234
248
  setSaveStatus('paused')
235
249
  lastNodesSnapshot = JSON.stringify(state.nodes)
@@ -279,6 +293,8 @@ export function useAutoSave({
279
293
  // would otherwise drop the change entirely. `pagehide` fires in cases
280
294
  // (mobile Safari, bfcache) where `beforeunload` does not.
281
295
  function flushOnExit() {
296
+ // What is on screen may be a preview: write the live scene, never that.
297
+ endScenePreview()
282
298
  const { nodes, rootNodeIds, collections, materials, installedPlugins } =
283
299
  getSavedSceneDocument()
284
300
  const currentNodeCount = Object.keys(nodes).length
@@ -325,7 +341,15 @@ export function useAutoSave({
325
341
  window.addEventListener('beforeunload', flushOnExit)
326
342
  window.addEventListener('pagehide', flushOnExit)
327
343
 
344
+ // A save that came due under a held preview was deferred; the live scene is back.
345
+ const stopWatchingPreview = subscribeScenePreview((previewing) => {
346
+ if (previewing || !pendingSaveRef.current || isSavingRef.current) return
347
+ if (isLoadingSceneRef.current || saveTimeoutRef.current) return
348
+ executeSaveRef.current?.()
349
+ })
350
+
328
351
  return () => {
352
+ stopWatchingPreview()
329
353
  executeSaveRef.current = null
330
354
  window.removeEventListener('beforeunload', flushOnExit)
331
355
  window.removeEventListener('pagehide', flushOnExit)
@@ -370,6 +394,7 @@ export function useAutoSave({
370
394
  // in the loop. A write already in flight only arms the follow-up.
371
395
  const saveNow = useCallback(() => {
372
396
  if (isLoadingSceneRef.current) return
397
+ endScenePreview()
373
398
 
374
399
  if (saveTimeoutRef.current) {
375
400
  clearTimeout(saveTimeoutRef.current)
@@ -22,124 +22,129 @@ import useEditor from '../store/use-editor'
22
22
  import useInteractionScope from '../store/use-interaction-scope'
23
23
  import { useCeilingEvents } from './use-ceiling-events'
24
24
 
25
- test.each([
26
- 'item',
27
- 'procedural-item',
28
- ])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => {
29
- const previousViewer = useViewer.getState()
30
- const previousEditor = useEditor.getState()
31
- const previousInteraction = useInteractionScope.getState()
32
- const previousScene = useScene.getState()
33
- const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
34
- const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
35
- actGlobal.IS_REACT_ACT_ENVIRONMENT = true
36
- const canvas = Object.assign(new EventTarget(), {
37
- getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
38
- }) as unknown as HTMLCanvasElement
39
- const root = createRoot(canvas)
40
- const camera = new PerspectiveCamera(60, 1, 0.1, 100)
41
- camera.position.set(0, 1, 5)
42
- camera.lookAt(0, 3, 0)
43
- camera.updateMatrixWorld()
44
- const level = LevelNode.parse({ id: 'level_ceiling_batch' })
45
- const ceiling = CeilingNode.parse({
46
- id: 'ceiling_placement_batch',
47
- parentId: level.id,
48
- polygon: [
49
- [-5, -5],
50
- [5, -5],
51
- [5, 5],
52
- [-5, 5],
53
- ],
54
- })
55
- const surface = new Mesh(new PlaneGeometry(10, 10).rotateX(-Math.PI / 2), new MeshBasicMaterial())
56
- surface.position.y = 3
57
- surface.updateMatrixWorld(true)
58
- const moves: CeilingEvent[] = []
59
- const clicks: CeilingEvent[] = []
60
- const onMove = (event: CeilingEvent) => moves.push(event)
61
- const onClick = (event: CeilingEvent) => clicks.push(event)
62
- emitter.on('ceiling:move', onMove)
63
- emitter.on('ceiling:click', onClick)
64
- function Placement() {
65
- useCeilingEvents()
66
- return null
67
- }
68
- const send = () => {
69
- for (const type of ['pointermove', 'click']) {
70
- canvas.dispatchEvent(Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }))
71
- }
72
- }
73
- try {
74
- sceneRegistry.nodes.set(ceiling.id, surface)
75
- sceneRegistry.byType.ceiling!.add(ceiling.id)
76
- useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } })
77
- useViewer.setState({
78
- hoveredId: null,
79
- cameraDragging: false,
80
- selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
25
+ test.each(['item', 'procedural-item'])(
26
+ '%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched',
27
+ async (kind) => {
28
+ const previousViewer = useViewer.getState()
29
+ const previousEditor = useEditor.getState()
30
+ const previousInteraction = useInteractionScope.getState()
31
+ const previousScene = useScene.getState()
32
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
33
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
34
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
35
+ const canvas = Object.assign(new EventTarget(), {
36
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
37
+ }) as unknown as HTMLCanvasElement
38
+ const root = createRoot(canvas)
39
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
40
+ camera.position.set(0, 1, 5)
41
+ camera.lookAt(0, 3, 0)
42
+ camera.updateMatrixWorld()
43
+ const level = LevelNode.parse({ id: 'level_ceiling_batch' })
44
+ const ceiling = CeilingNode.parse({
45
+ id: 'ceiling_placement_batch',
46
+ parentId: level.id,
47
+ polygon: [
48
+ [-5, -5],
49
+ [5, -5],
50
+ [5, 5],
51
+ [-5, 5],
52
+ ],
81
53
  })
82
- if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
83
- else {
84
- useEditor.setState({ selectedItem: null })
85
- useEditor.getState().setMovingNode(
86
- ProceduralItemNode.parse({
87
- recipe: {
88
- ...shelfRecipe,
89
- mounting: { attachTo: 'ceiling', reference: 'top' },
90
- surfaces: [
91
- { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
92
- ],
93
- },
94
- parentId: ceiling.id,
95
- }) as never,
96
- )
54
+ const surface = new Mesh(
55
+ new PlaneGeometry(10, 10).rotateX(-Math.PI / 2),
56
+ new MeshBasicMaterial(),
57
+ )
58
+ surface.position.y = 3
59
+ surface.updateMatrixWorld(true)
60
+ const moves: CeilingEvent[] = []
61
+ const clicks: CeilingEvent[] = []
62
+ const onMove = (event: CeilingEvent) => moves.push(event)
63
+ const onClick = (event: CeilingEvent) => clicks.push(event)
64
+ emitter.on('ceiling:move', onMove)
65
+ emitter.on('ceiling:click', onClick)
66
+ function Placement() {
67
+ useCeilingEvents()
68
+ return null
97
69
  }
98
- await root.configure({
99
- gl: {
100
- domElement: canvas,
101
- render() {},
102
- setSize() {},
103
- setPixelRatio() {},
104
- xr: { isPresenting: false },
105
- } as unknown as WebGLRenderer,
106
- camera,
107
- frameloop: 'never',
108
- dpr: 1,
109
- size: { width: 100, height: 100, top: 0, left: 0 },
110
- })
111
- await act(async () => {
112
- root.render(createElement(Placement))
113
- })
114
- send()
115
- hideFromScene(surface, 'batched')
116
- send()
117
- showInScene(surface, 'batched')
118
- send()
119
- expect(moves).toHaveLength(3)
120
- expect(clicks).toHaveLength(3)
121
- expect(moves[0]!.position[1]).toBeCloseTo(3)
122
- for (const hit of [...moves, ...clicks]) {
123
- expect(hit.node.id).toBe(ceiling.id)
124
- expect(hit.position).toEqual(moves[0]!.position)
125
- expect(hit.localPosition).toEqual(moves[0]!.localPosition)
70
+ const send = () => {
71
+ for (const type of ['pointermove', 'click']) {
72
+ canvas.dispatchEvent(
73
+ Object.assign(new Event(type), { clientX: 55, clientY: 50, button: 0 }),
74
+ )
75
+ }
126
76
  }
127
- expect(useViewer.getState().hoveredId).toBeNull()
128
- } finally {
129
- await act(async () => {
130
- root.render(null)
131
- })
132
- _roots.delete(canvas)
133
- emitter.off('ceiling:move', onMove)
134
- emitter.off('ceiling:click', onClick)
135
- sceneRegistry.nodes.delete(ceiling.id)
136
- sceneRegistry.byType.ceiling!.delete(ceiling.id)
137
- surface.geometry.dispose()
138
- surface.material.dispose()
139
- useScene.setState(previousScene)
140
- useEditor.setState(previousEditor)
141
- useInteractionScope.setState(previousInteraction)
142
- useViewer.setState(previousViewer)
143
- actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
144
- }
145
- })
77
+ try {
78
+ sceneRegistry.nodes.set(ceiling.id, surface)
79
+ sceneRegistry.byType.ceiling!.add(ceiling.id)
80
+ useScene.setState({ nodes: { [level.id]: level, [ceiling.id]: ceiling } })
81
+ useViewer.setState({
82
+ hoveredId: null,
83
+ cameraDragging: false,
84
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
85
+ })
86
+ if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
87
+ else {
88
+ useEditor.setState({ selectedItem: null })
89
+ useEditor.getState().setMovingNode(
90
+ ProceduralItemNode.parse({
91
+ recipe: {
92
+ ...shelfRecipe,
93
+ mounting: { attachTo: 'ceiling', reference: 'top' },
94
+ surfaces: [
95
+ { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
96
+ ],
97
+ },
98
+ parentId: ceiling.id,
99
+ }) as never,
100
+ )
101
+ }
102
+ await root.configure({
103
+ gl: {
104
+ domElement: canvas,
105
+ render() {},
106
+ setSize() {},
107
+ setPixelRatio() {},
108
+ xr: { isPresenting: false },
109
+ } as unknown as WebGLRenderer,
110
+ camera,
111
+ frameloop: 'never',
112
+ dpr: 1,
113
+ size: { width: 100, height: 100, top: 0, left: 0 },
114
+ })
115
+ await act(async () => {
116
+ root.render(createElement(Placement))
117
+ })
118
+ send()
119
+ hideFromScene(surface, 'batched')
120
+ send()
121
+ showInScene(surface, 'batched')
122
+ send()
123
+ expect(moves).toHaveLength(3)
124
+ expect(clicks).toHaveLength(3)
125
+ expect(moves[0]!.position[1]).toBeCloseTo(3)
126
+ for (const hit of [...moves, ...clicks]) {
127
+ expect(hit.node.id).toBe(ceiling.id)
128
+ expect(hit.position).toEqual(moves[0]!.position)
129
+ expect(hit.localPosition).toEqual(moves[0]!.localPosition)
130
+ }
131
+ expect(useViewer.getState().hoveredId).toBeNull()
132
+ } finally {
133
+ await act(async () => {
134
+ root.render(null)
135
+ })
136
+ _roots.delete(canvas)
137
+ emitter.off('ceiling:move', onMove)
138
+ emitter.off('ceiling:click', onClick)
139
+ sceneRegistry.nodes.delete(ceiling.id)
140
+ sceneRegistry.byType.ceiling!.delete(ceiling.id)
141
+ surface.geometry.dispose()
142
+ surface.material.dispose()
143
+ useScene.setState(previousScene)
144
+ useEditor.setState(previousEditor)
145
+ useInteractionScope.setState(previousInteraction)
146
+ useViewer.setState(previousViewer)
147
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
148
+ }
149
+ },
150
+ )