@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
@@ -0,0 +1,422 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type {
3
+ SnapshotCaptureFailedEvent,
4
+ SnapshotCapturePose,
5
+ ThumbnailGenerateEvent,
6
+ } from '@pascal-app/core'
7
+ import { Euler, PerspectiveCamera, Quaternion, Vector3 } from 'three'
8
+ import {
9
+ applySnapshotCapturePose,
10
+ captureSnapshotScene,
11
+ createSnapshotQueue,
12
+ enqueueSnapshotCapture,
13
+ isOverlaySnapshotSave,
14
+ runSnapshotCapture,
15
+ } from './snapshot-capture'
16
+
17
+ test('manual capture feedback ignores background requests and other projects', () => {
18
+ const saved = { id: 'frame', url: 'https://example.test/frame.webp', width: 1920, height: 1080 }
19
+ expect(isOverlaySnapshotSave(undefined, 'project')).toBe(true)
20
+ expect(isOverlaySnapshotSave(saved, 'project')).toBe(true)
21
+ expect(isOverlaySnapshotSave({ ...saved, projectId: 'project' }, 'project')).toBe(true)
22
+ expect(isOverlaySnapshotSave({ ...saved, projectId: 'other' }, 'project')).toBe(false)
23
+ expect(isOverlaySnapshotSave({ ...saved, requestId: 'background' }, 'project')).toBe(false)
24
+ })
25
+
26
+ describe('capture scene restoration', () => {
27
+ test('restores all presentation changes before GPU readback settles', async () => {
28
+ const events: string[] = []
29
+ let readback!: (value: string) => void
30
+ const result = captureSnapshotScene((restore) => {
31
+ restore(() => events.push('levels restored'))
32
+ restore(() => events.push('materials restored'))
33
+ events.push('rendered')
34
+ return new Promise<string>((resolve) => {
35
+ readback = resolve
36
+ })
37
+ })
38
+ expect(events).toEqual(['rendered', 'materials restored', 'levels restored'])
39
+ readback('frame')
40
+ expect(await result).toBe('frame')
41
+ })
42
+
43
+ test('a setup failure still restores every already-applied change', async () => {
44
+ let restored = false
45
+ await expect(
46
+ captureSnapshotScene((restore) => {
47
+ restore(() => {
48
+ restored = true
49
+ })
50
+ throw new Error('Framing failed')
51
+ }),
52
+ ).rejects.toThrow('Framing failed')
53
+ expect(restored).toBe(true)
54
+ })
55
+
56
+ test('an after-capture listener failure cannot prevent level and visibility restoration', async () => {
57
+ const restored: string[] = []
58
+ await expect(
59
+ captureSnapshotScene((restore) => {
60
+ restore(() => {
61
+ restored.push('levels')
62
+ })
63
+ restore(() => {
64
+ restored.push('visibility')
65
+ })
66
+ restore(() => {
67
+ throw new Error('Listener failed')
68
+ })
69
+ return Promise.reject(new Error('Readback failed'))
70
+ }),
71
+ ).rejects.toThrow('Listener failed')
72
+ expect(restored).toEqual(['visibility', 'levels'])
73
+ await Promise.resolve()
74
+ })
75
+ })
76
+
77
+ describe('explicit snapshot camera', () => {
78
+ const pose: SnapshotCapturePose = {
79
+ position: [4, 2, -3],
80
+ quaternion: new Quaternion().setFromEuler(new Euler(0.2, 1.1, 0.6)).toArray(),
81
+ fov: 47,
82
+ }
83
+
84
+ test('retains the authored world pose including roll without moving the viewport camera', () => {
85
+ const viewportCamera = new PerspectiveCamera(65, 2, 0.1, 1000)
86
+ viewportCamera.position.set(10, 20, 30)
87
+ viewportCamera.rotation.set(0.9, 0.8, 0.7)
88
+ const previousPosition = viewportCamera.position.toArray()
89
+ const previousQuaternion = viewportCamera.quaternion.toArray()
90
+ const captureCamera = viewportCamera.clone()
91
+
92
+ applySnapshotCapturePose(
93
+ captureCamera,
94
+ pose,
95
+ { width: 1600, height: 900 },
96
+ { w: 1920, h: 1080 },
97
+ )
98
+
99
+ expect(captureCamera.position.toArray()).toEqual(pose.position)
100
+ expect(captureCamera.quaternion.toArray()).toEqual(pose.quaternion)
101
+ expect(captureCamera.fov).toBeCloseTo(pose.fov, 10)
102
+ expect(viewportCamera.position.toArray()).toEqual(previousPosition)
103
+ expect(viewportCamera.quaternion.toArray()).toEqual(previousQuaternion)
104
+ })
105
+
106
+ test.each([
107
+ { width: 900, height: 1600, w: 1920, h: 1080 },
108
+ { width: 1600, height: 900, w: 1080, h: 1920 },
109
+ { width: 1440, height: 900, w: 1920, h: 1080 },
110
+ ])('matches the authored composition after center crop: %j', ({ width, height, w, h }) => {
111
+ const capture = new PerspectiveCamera()
112
+ applySnapshotCapturePose(capture, pose, { width, height }, { w, h })
113
+ const ideal = new PerspectiveCamera(pose.fov, w / h)
114
+ ideal.position.fromArray(pose.position)
115
+ ideal.quaternion.fromArray(pose.quaternion)
116
+ ideal.updateMatrixWorld()
117
+ const worldPoint = new Vector3(0.4, 0.7, -7)
118
+ .applyQuaternion(ideal.quaternion)
119
+ .add(ideal.position)
120
+ const expected = worldPoint.clone().project(ideal)
121
+ const actual = worldPoint.clone().project(capture)
122
+ const cropHeight = width / height < w / h ? Math.round(width / (w / h)) : height
123
+ const cropWidth = width / height > w / h ? Math.round(height * (w / h)) : width
124
+
125
+ expect((actual.x * width) / cropWidth).toBeCloseTo(expected.x, 3)
126
+ expect((actual.y * height) / cropHeight).toBeCloseTo(expected.y, 10)
127
+ })
128
+
129
+ test('rejects a malformed lens before mutating the capture camera', () => {
130
+ const camera = new PerspectiveCamera()
131
+ expect(() =>
132
+ applySnapshotCapturePose(
133
+ camera,
134
+ { ...pose, fov: Number.NaN },
135
+ { width: 1600, height: 900 },
136
+ { w: 1920, h: 1080 },
137
+ ),
138
+ ).toThrow('Invalid snapshot camera pose or dimensions')
139
+ expect(camera.position.toArray()).toEqual([0, 0, 0])
140
+ })
141
+ })
142
+
143
+ describe('snapshot request correlation', () => {
144
+ test('queued authored frames survive a viewport camera and callback replacement', async () => {
145
+ const enqueue = createSnapshotQueue()
146
+ const version = { current: 1 }
147
+ const failures: SnapshotCaptureFailedEvent[] = []
148
+ const captured: ThumbnailGenerateEvent[] = []
149
+ let release!: () => void
150
+ const background = enqueue(
151
+ {},
152
+ () =>
153
+ new Promise<void>((resolve) => {
154
+ release = resolve
155
+ }),
156
+ )
157
+ await Promise.resolve()
158
+ let viewportCamera = new PerspectiveCamera()
159
+ const pose: SnapshotCapturePose = {
160
+ position: [4, 2, -3],
161
+ quaternion: [0, 0, 0, 1],
162
+ fov: 47,
163
+ }
164
+ const event: ThumbnailGenerateEvent = {
165
+ projectId: 'project',
166
+ requestId: 'frame',
167
+ captureMode: 'standard',
168
+ cameraPose: pose,
169
+ }
170
+ const generate = async (request: ThumbnailGenerateEvent) => {
171
+ const captureCamera = viewportCamera.clone()
172
+ applySnapshotCapturePose(
173
+ captureCamera,
174
+ request.cameraPose!,
175
+ { width: 1600, height: 900 },
176
+ { w: 1920, h: 1080 },
177
+ )
178
+ expect(captureCamera.position.toArray()).toEqual(pose.position)
179
+ captured.push(request)
180
+ }
181
+ const first = enqueueSnapshotCapture(enqueue, version, event, generate, (failure) =>
182
+ failures.push(failure),
183
+ )
184
+ viewportCamera = new PerspectiveCamera(90)
185
+ viewportCamera.position.set(20, 10, 5)
186
+ const second = enqueueSnapshotCapture(
187
+ enqueue,
188
+ version,
189
+ { ...event, requestId: 'next-frame' },
190
+ async (request) => generate(request),
191
+ (failure) => failures.push(failure),
192
+ )
193
+ release()
194
+ await Promise.all([background, first, second])
195
+ expect(captured.map((request) => request.requestId)).toEqual(['frame', 'next-frame'])
196
+ expect(viewportCamera.position.toArray()).toEqual([20, 10, 5])
197
+ expect(failures).toEqual([])
198
+ })
199
+
200
+ test('disposing the capture pipeline cancels queued frames before the replacement scene renders', async () => {
201
+ const enqueue = createSnapshotQueue()
202
+ const version = { current: 1 }
203
+ const failures: SnapshotCaptureFailedEvent[] = []
204
+ const captured: string[] = []
205
+ let release!: () => void
206
+ const background = enqueue(
207
+ {},
208
+ () =>
209
+ new Promise<void>((resolve) => {
210
+ release = resolve
211
+ }),
212
+ )
213
+ await Promise.resolve()
214
+ const capture = async (event: ThumbnailGenerateEvent) => {
215
+ captured.push(event.requestId!)
216
+ }
217
+ const oldFrame = enqueueSnapshotCapture(
218
+ enqueue,
219
+ version,
220
+ { projectId: 'old', requestId: 'old-frame' },
221
+ capture,
222
+ (failure) => failures.push(failure),
223
+ )
224
+ version.current += 1
225
+ const newFrame = enqueueSnapshotCapture(
226
+ enqueue,
227
+ version,
228
+ { projectId: 'new', requestId: 'new-frame' },
229
+ capture,
230
+ (failure) => failures.push(failure),
231
+ )
232
+ release()
233
+ await Promise.all([background, oldFrame, newFrame])
234
+ expect(captured).toEqual(['new-frame'])
235
+ expect(failures).toEqual([
236
+ { requestId: 'old-frame', error: 'The scene changed before capture. Try again.' },
237
+ ])
238
+ })
239
+
240
+ test.each([
241
+ 'standard',
242
+ 'viewport',
243
+ 'area',
244
+ ] as const)('a manual %s shutter waits for a background upload; redundant autosaves are dropped', async (captureMode) => {
245
+ const enqueue = createSnapshotQueue()
246
+ const busy = { current: false }
247
+ const failures: SnapshotCaptureFailedEvent[] = []
248
+ const events: string[] = []
249
+ let finishUpload!: () => void
250
+ const uploading = new Promise<void>((resolve) => {
251
+ finishUpload = resolve
252
+ })
253
+ const background = enqueue({ requestId: 'background' }, () =>
254
+ runSnapshotCapture(
255
+ 'background',
256
+ busy,
257
+ async () => {
258
+ events.push('background rendered')
259
+ await uploading
260
+ events.push('background saved')
261
+ },
262
+ (failure) => failures.push(failure),
263
+ ),
264
+ )
265
+ await Promise.resolve()
266
+ expect(busy.current).toBe(true)
267
+ const autosave = enqueue({}, async () => {
268
+ events.push('autosave')
269
+ })
270
+ let overlayState = 'capturing'
271
+ const manual = enqueue({ captureMode }, () =>
272
+ runSnapshotCapture(
273
+ undefined,
274
+ busy,
275
+ async () => {
276
+ events.push('manual saved')
277
+ overlayState = 'saved'
278
+ },
279
+ (failure) => failures.push(failure),
280
+ ),
281
+ )
282
+ await autosave
283
+ expect(overlayState).toBe('capturing')
284
+ expect(events).toEqual(['background rendered'])
285
+ finishUpload()
286
+ await Promise.all([background, manual])
287
+ expect(events).toEqual(['background rendered', 'background saved', 'manual saved'])
288
+ expect(overlayState).toBe('saved')
289
+ expect(failures).toEqual([])
290
+ expect(busy.current).toBe(false)
291
+ await enqueue({}, async () => {
292
+ events.push('idle autosave')
293
+ })
294
+ expect(events.at(-1)).toBe('idle autosave')
295
+ })
296
+
297
+ test('queues a frame dispatched by a save callback until the previous capture releases its lock', async () => {
298
+ const enqueue = createSnapshotQueue()
299
+ const busy = { current: false }
300
+ const failures: SnapshotCaptureFailedEvent[] = []
301
+ const events: string[] = []
302
+ let second: Promise<void> | undefined
303
+ await enqueue({ requestId: 'first' }, () =>
304
+ runSnapshotCapture(
305
+ 'first',
306
+ busy,
307
+ async () => {
308
+ events.push('first saved')
309
+ second = enqueue({ requestId: 'second' }, () =>
310
+ runSnapshotCapture(
311
+ 'second',
312
+ busy,
313
+ async () => {
314
+ events.push('second saved')
315
+ },
316
+ (failure) => failures.push(failure),
317
+ ),
318
+ )
319
+ await Promise.resolve()
320
+ events.push('first callback returned')
321
+ },
322
+ (failure) => failures.push(failure),
323
+ ),
324
+ )
325
+ await second
326
+ expect(events).toEqual(['first saved', 'first callback returned', 'second saved'])
327
+ expect(failures).toEqual([])
328
+ })
329
+
330
+ test('a rejected queue task does not strand later captures', async () => {
331
+ const enqueue = createSnapshotQueue()
332
+ await expect(
333
+ enqueue({}, async () => {
334
+ throw new Error('Failed')
335
+ }),
336
+ ).rejects.toThrow('Failed')
337
+ let captured = false
338
+ await enqueue({}, async () => {
339
+ captured = true
340
+ })
341
+ expect(captured).toBe(true)
342
+ })
343
+ test('reports only the rejected request as busy and allows the next capture after completion', async () => {
344
+ const busy = { current: false }
345
+ const failures: SnapshotCaptureFailedEvent[] = []
346
+ const completed: string[] = []
347
+ let finish!: () => void
348
+ const pending = new Promise<void>((resolve) => {
349
+ finish = resolve
350
+ })
351
+ const first = runSnapshotCapture(
352
+ 'first',
353
+ busy,
354
+ async () => {
355
+ await pending
356
+ completed.push('first')
357
+ },
358
+ (failure) => failures.push(failure),
359
+ )
360
+
361
+ await runSnapshotCapture(
362
+ 'second',
363
+ busy,
364
+ async () => {
365
+ completed.push('second')
366
+ },
367
+ (failure) => failures.push(failure),
368
+ )
369
+ expect(failures).toEqual([
370
+ {
371
+ requestId: 'second',
372
+ error: 'Another snapshot is being captured. Try again.',
373
+ },
374
+ ])
375
+ expect(completed).toEqual([])
376
+ expect(busy.current).toBe(true)
377
+
378
+ finish()
379
+ await first
380
+ await runSnapshotCapture(
381
+ 'third',
382
+ busy,
383
+ async () => {
384
+ completed.push('third')
385
+ },
386
+ (failure) => failures.push(failure),
387
+ )
388
+ expect(completed).toEqual(['first', 'third'])
389
+ expect(busy.current).toBe(false)
390
+ })
391
+
392
+ test('correlates render failures and releases the capture lock', async () => {
393
+ const busy = { current: false }
394
+ const failures: SnapshotCaptureFailedEvent[] = []
395
+ await runSnapshotCapture(
396
+ 'failed-frame',
397
+ busy,
398
+ async () => {
399
+ throw new Error('GPU readback failed')
400
+ },
401
+ (failure) => failures.push(failure),
402
+ )
403
+
404
+ expect(failures).toEqual([{ requestId: 'failed-frame', error: 'GPU readback failed' }])
405
+ expect(busy.current).toBe(false)
406
+ })
407
+
408
+ test('uncorrelated legacy requests still capture', async () => {
409
+ let captured = false
410
+ const failures: SnapshotCaptureFailedEvent[] = []
411
+ await runSnapshotCapture(
412
+ undefined,
413
+ { current: false },
414
+ async () => {
415
+ captured = true
416
+ },
417
+ (failure) => failures.push(failure),
418
+ )
419
+ expect(captured).toBe(true)
420
+ expect(failures).toEqual([])
421
+ })
422
+ })
@@ -0,0 +1,145 @@
1
+ import type {
2
+ SnapshotCaptureFailedEvent,
3
+ SnapshotCapturePose,
4
+ SnapshotSavedEvent,
5
+ ThumbnailGenerateEvent,
6
+ } from '@pascal-app/core'
7
+ import { MathUtils, type PerspectiveCamera } from 'three'
8
+
9
+ export function isOverlaySnapshotSave(event: SnapshotSavedEvent | undefined, projectId: string) {
10
+ return !event?.requestId && (!event?.projectId || event.projectId === projectId)
11
+ }
12
+
13
+ export function createSnapshotQueue() {
14
+ let tail = Promise.resolve()
15
+ let pendingCount = 0
16
+ return (
17
+ event: Pick<ThumbnailGenerateEvent, 'requestId' | 'captureMode'>,
18
+ capture: () => Promise<void>,
19
+ ) => {
20
+ if (pendingCount > 0 && !event.requestId && !event.captureMode) return Promise.resolve()
21
+ pendingCount += 1
22
+ const pending = tail.then(capture).finally(() => {
23
+ pendingCount -= 1
24
+ })
25
+ tail = pending.catch(() => {})
26
+ return pending
27
+ }
28
+ }
29
+
30
+ export function enqueueSnapshotCapture(
31
+ enqueue: ReturnType<typeof createSnapshotQueue>,
32
+ version: { current: number },
33
+ event: ThumbnailGenerateEvent,
34
+ capture: (event: ThumbnailGenerateEvent) => Promise<void>,
35
+ reportFailure: (failure: SnapshotCaptureFailedEvent) => void,
36
+ ) {
37
+ const requestedVersion = version.current
38
+ return enqueue(event, async () => {
39
+ if (requestedVersion !== version.current) {
40
+ if (event.requestId) {
41
+ reportFailure({
42
+ requestId: event.requestId,
43
+ error: 'The scene changed before capture. Try again.',
44
+ })
45
+ }
46
+ return
47
+ }
48
+ await capture(event)
49
+ })
50
+ }
51
+
52
+ export async function captureSnapshotScene<T>(
53
+ capture: (restore: (callback: () => void) => void) => T | Promise<T>,
54
+ ): Promise<T> {
55
+ const restorers: Array<() => void> = []
56
+ const errors: unknown[] = []
57
+ let result: T | Promise<T> | undefined
58
+ try {
59
+ result = capture((restore) => restorers.push(restore))
60
+ } catch (error) {
61
+ errors.push(error)
62
+ }
63
+ // The offscreen render is synchronous. Restore before adopting its promise,
64
+ // so GPU readback never leaves the interactive scene in its capture pose.
65
+ for (const restore of restorers.reverse()) {
66
+ try {
67
+ restore()
68
+ } catch (error) {
69
+ errors.push(error)
70
+ }
71
+ }
72
+ if (errors.length > 0) {
73
+ void Promise.resolve(result).catch(() => {})
74
+ throw errors.length === 1
75
+ ? errors[0]
76
+ : new AggregateError(errors, 'Snapshot restoration failed')
77
+ }
78
+ return result as T | Promise<T>
79
+ }
80
+
81
+ export function applySnapshotCapturePose(
82
+ camera: PerspectiveCamera,
83
+ pose: SnapshotCapturePose,
84
+ viewport: { width: number; height: number },
85
+ output: { w: number; h: number },
86
+ ) {
87
+ if (
88
+ ![...pose.position, ...pose.quaternion, pose.fov].every(Number.isFinite) ||
89
+ pose.fov <= 0 ||
90
+ pose.fov >= 180 ||
91
+ ![viewport.width, viewport.height, output.w, output.h].every(
92
+ (dimension) => Number.isFinite(dimension) && dimension >= 1,
93
+ ) ||
94
+ Math.abs(pose.quaternion.reduce((sum, value) => sum + value * value, 0) - 1) > 0.001
95
+ ) {
96
+ throw new Error('Invalid snapshot camera pose or dimensions')
97
+ }
98
+
99
+ const aspect = viewport.width / viewport.height
100
+ const outputAspect = output.w / output.h
101
+ const cropHeight =
102
+ aspect < outputAspect ? Math.round(viewport.width / outputAspect) : viewport.height
103
+ if (cropHeight < 1) throw new Error('Snapshot crop is too small')
104
+
105
+ camera.position.fromArray(pose.position)
106
+ camera.quaternion.fromArray(pose.quaternion)
107
+ camera.aspect = aspect
108
+ // The snapshot pipeline center-crops a viewport-sized render. Expand its
109
+ // vertical FOV so that the cropped image keeps the authored lens framing.
110
+ camera.fov = MathUtils.radToDeg(
111
+ 2 * Math.atan(Math.tan(MathUtils.degToRad(pose.fov) / 2) * (viewport.height / cropHeight)),
112
+ )
113
+ camera.zoom = 1
114
+ camera.updateProjectionMatrix()
115
+ camera.updateMatrixWorld()
116
+ }
117
+
118
+ export async function runSnapshotCapture(
119
+ requestId: string | undefined,
120
+ busy: { current: boolean },
121
+ capture: () => Promise<void>,
122
+ reportFailure: (failure: SnapshotCaptureFailedEvent) => void,
123
+ ) {
124
+ if (busy.current) {
125
+ if (requestId)
126
+ reportFailure({ requestId, error: 'Another snapshot is being captured. Try again.' })
127
+ return
128
+ }
129
+
130
+ busy.current = true
131
+ try {
132
+ await capture()
133
+ } catch (error) {
134
+ if (requestId) {
135
+ reportFailure({
136
+ requestId,
137
+ error: error instanceof Error ? error.message : 'Snapshot capture failed',
138
+ })
139
+ } else {
140
+ console.error('Failed to generate thumbnail:', error)
141
+ }
142
+ } finally {
143
+ busy.current = false
144
+ }
145
+ }