@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,11 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { emitter } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ emitter,
6
+ sceneRegistry,
7
+ type ThumbnailGenerateEvent,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import {
5
11
  computeHeroFraming,
6
12
  createSnapshotPipeline,
7
13
  GRID_LAYER,
14
+ getVisibleWallMaterials,
8
15
  heroCameraPose,
16
+ refreshIsolation,
9
17
  SNAPSHOT_MAX_EDGE,
10
18
  SNAPSHOT_MIME,
11
19
  SNAPSHOT_QUALITY,
@@ -14,6 +22,8 @@ import {
14
22
  THUMBNAIL_HEIGHT,
15
23
  THUMBNAIL_WIDTH,
16
24
  temporarilyHideNodeTypes,
25
+ temporarilyShowShadowOnly,
26
+ useSceneAtmosphere,
17
27
  useViewer,
18
28
  } from '@pascal-app/viewer'
19
29
  import type { CameraControls } from '@react-three/drei'
@@ -22,9 +32,19 @@ import { useCallback, useEffect, useRef } from 'react'
22
32
  import * as THREE from 'three'
23
33
  import type { WebGPURenderer } from 'three/webgpu'
24
34
  import { EDITOR_LAYER } from '../../lib/constants'
35
+ import {
36
+ applySnapshotCapturePose,
37
+ captureSnapshotScene,
38
+ createSnapshotQueue,
39
+ enqueueSnapshotCapture,
40
+ runSnapshotCapture,
41
+ } from './snapshot-capture'
25
42
 
26
43
  export interface SnapshotCameraData {
44
+ requestId?: string
27
45
  position: [number, number, number]
46
+ quaternion?: [number, number, number, number]
47
+ fov?: number
28
48
  target: [number, number, number] | null
29
49
  type?: 'perspective' | 'orthographic'
30
50
  zoom?: number
@@ -36,6 +56,9 @@ interface ThumbnailGeneratorProps {
36
56
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
37
57
  }
38
58
 
59
+ /** Metres ahead of a controls-less camera to place the stored snapshot target. */
60
+ const FIRST_PERSON_TARGET_DISTANCE = 8
61
+
39
62
  function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
40
63
  const maxEdge = Math.max(width, height)
41
64
  if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
@@ -47,20 +70,24 @@ function clampSnapshotSize(width: number, height: number): { w: number; h: numbe
47
70
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
48
71
  const gl = useThree((state) => state.gl)
49
72
  const scene = useThree((state) => state.scene)
50
- const mainCamera = useThree((state) => state.camera)
73
+ const atmosphere = useSceneAtmosphere()
74
+ const getThree = useThree((state) => state.get)
51
75
  const controls = useThree((state) => state.controls) as CameraControls | null
52
76
  const isGenerating = useRef(false)
77
+ const captureQueue = useRef(createSnapshotQueue())
53
78
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
54
79
 
55
80
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
56
81
  const pipelineRef = useRef<SnapshotPipeline | null>(null)
82
+ const captureVersion = useRef(0)
57
83
 
58
84
  useEffect(() => {
59
85
  onThumbnailCaptureRef.current = onThumbnailCapture
60
86
  }, [onThumbnailCapture])
61
87
 
62
- // Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
88
+ // Reuse the camera and snapshot graph until the active atmosphere source changes.
63
89
  useEffect(() => {
90
+ captureVersion.current += 1
64
91
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
65
92
  cam.layers.disable(EDITOR_LAYER)
66
93
  cam.layers.disable(GRID_LAYER)
@@ -73,6 +100,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
73
100
  renderer: gl as unknown as WebGPURenderer,
74
101
  scene,
75
102
  camera: cam,
103
+ atmosphere,
76
104
  })
77
105
  if (!mounted) {
78
106
  pipeline?.dispose()
@@ -85,242 +113,277 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
85
113
 
86
114
  return () => {
87
115
  mounted = false
116
+ captureVersion.current += 1
117
+ thumbnailCameraRef.current = null
88
118
  pipelineRef.current?.dispose()
89
119
  pipelineRef.current = null
90
120
  }
91
- }, [gl, scene])
121
+ }, [gl, scene, atmosphere])
92
122
 
93
123
  const generate = useCallback(
94
- async (
95
- snapLevels: boolean,
96
- captureMode?: 'standard' | 'viewport' | 'area',
97
- cropRegion?: { x: number; y: number; width: number; height: number },
98
- standardSize?: { w: number; h: number },
99
- transparent = false,
100
- ) => {
124
+ async (event: ThumbnailGenerateEvent) => {
125
+ const { captureMode, cropRegion, standardSize, cameraPose, requestId } = event
126
+ const snapLevels = event.snapLevels === true
127
+ const transparent = event.transparent === true
101
128
  const standardW = standardSize?.w ?? THUMBNAIL_WIDTH
102
129
  const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT
103
- if (isGenerating.current) return
104
- if (!onThumbnailCaptureRef.current) return
105
-
106
- isGenerating.current = true
107
-
108
- try {
109
- const thumbnailCamera = thumbnailCameraRef.current
110
- if (!thumbnailCamera) return
111
-
112
- // Copy the main camera's transform and projection so the thumbnail
113
- // matches exactly what the user sees in the viewport.
114
- thumbnailCamera.position.copy(mainCamera.position)
115
- thumbnailCamera.quaternion.copy(mainCamera.quaternion)
116
- if (mainCamera instanceof THREE.PerspectiveCamera) {
117
- thumbnailCamera.fov = mainCamera.fov
118
- thumbnailCamera.near = mainCamera.near
119
- thumbnailCamera.far = mainCamera.far
120
- }
121
- const { width, height } = gl.domElement
122
- thumbnailCamera.aspect = width / height
123
- thumbnailCamera.updateProjectionMatrix()
124
- // The capture camera never joins the scene graph, so its matrixWorld
125
- // is only refreshed by the render itself — too late for the backdrop
126
- // uniforms below.
127
- thumbnailCamera.updateMatrixWorld()
128
-
129
- const pipeline = pipelineRef.current
130
- pipeline?.applyEnvironment({
131
- theme: useViewer.getState().sceneTheme,
132
- transparent,
133
- grade: useViewer.getState().shading === 'rendered',
134
- // Preset/item captures stay clean; scene captures mirror the canvas.
135
- edges: transparent ? 'off' : useViewer.getState().edges,
136
- camera: thumbnailCamera,
137
- })
138
-
139
- // Capture camera data for snapshot storage
140
- const pos = mainCamera.position
141
- let tgt: [number, number, number] | null = null
142
- if (controls && 'getTarget' in controls) {
143
- const v = new THREE.Vector3()
144
- ;(controls as any).getTarget(v)
145
- tgt = [v.x, v.y, v.z]
146
- }
147
- const isOrtho = mainCamera instanceof THREE.OrthographicCamera
148
- const cameraData: SnapshotCameraData = {
149
- position: [pos.x, pos.y, pos.z],
150
- target: tgt,
151
- type: isOrtho ? 'orthographic' : 'perspective',
152
- ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
153
- }
154
-
155
- // For auto-save: snap levels to stacked positions and reset levelMode
156
- let restoreLevelMode: (() => void) | null = null
157
- let restoreLevels: () => void = () => {}
158
- if (snapLevels) {
159
- const prevMode = useViewer.getState().levelMode
160
- if (prevMode !== 'stacked') {
161
- useViewer.getState().setLevelMode('stacked')
162
- restoreLevelMode = () => useViewer.getState().setLevelMode(prevMode)
130
+ await runSnapshotCapture(
131
+ requestId,
132
+ isGenerating,
133
+ async () => {
134
+ const version = captureVersion.current
135
+ const onCapture = onThumbnailCaptureRef.current
136
+ if (!onCapture) throw new Error('Snapshot storage is unavailable')
137
+ if (cameraPose && event.projectId !== useViewer.getState().projectId)
138
+ throw new Error('The active project changed before capture')
139
+ const thumbnailCamera = thumbnailCameraRef.current
140
+ if (!thumbnailCamera) throw new Error('Snapshot camera is not ready')
141
+ const { camera: mainCamera, controls } = getThree()
142
+ if (cameraPose && (snapLevels || (captureMode && captureMode !== 'standard'))) {
143
+ throw new Error('An explicit snapshot camera requires standard capture mode')
163
144
  }
164
- restoreLevels = snapLevelsToTruePositions()
165
- }
166
-
167
- // Hide scan, guide, and spawn nodes directly so they are excluded from
168
- // the thumbnail regardless of whether ScanSystem/GuideSystem listeners
169
- // are registered. Spawn renders on SCENE_LAYER for occlusion, so the
170
- // thumbnail camera's layer mask can't filter it either. Returns a
171
- // function that restores the original visibility.
172
- const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
173
-
174
- // Auto-save shots don't copy the user's mid-edit camera — they re-pose
175
- // onto the same computed hero angle the published thumbnail uses, so a
176
- // project's card never shows a half-zoomed working view. Measured after
177
- // the level snap so stacked positions frame correctly. User-driven
178
- // captures (captureMode set) keep the exact viewport pose.
179
- if (snapLevels) {
180
- const framing = computeHeroFraming()
181
- if (framing) {
182
- const pose = heroCameraPose({
183
- boxes: framing.boxes,
184
- aim: framing.aim,
185
- azimuthRad: framing.azimuthRad,
186
- aspect: width / height,
187
- })
188
- thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
189
- thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
190
- thumbnailCamera.updateMatrixWorld()
191
- pipeline?.applyEnvironment({
192
- theme: useViewer.getState().sceneTheme,
193
- transparent,
194
- grade: useViewer.getState().shading === 'rendered',
195
- edges: transparent ? 'off' : useViewer.getState().edges,
196
- camera: thumbnailCamera,
197
- })
198
- cameraData.position = pose.position
199
- cameraData.target = pose.target
145
+ if (cameraPose && !pipelineRef.current) {
146
+ throw new Error('Snapshot renderer is not ready. Try again.')
200
147
  }
201
- }
202
-
203
- let blob: Blob
204
-
205
- if (pipeline) {
206
- let capturePromise: ReturnType<SnapshotPipeline['capture']>
207
148
 
208
- // Notify other systems (wall cutouts, selection manager) to restore
209
- // their overrides before capture and re-apply them after.
210
- try {
211
- emitter.emit('thumbnail:before-capture', undefined)
212
- capturePromise = pipeline.capture({
213
- captureMode,
214
- cropRegion,
215
- standardSize,
216
- })
217
- } finally {
218
- // Restore level positions, levelMode, and node visibility immediately
219
- // after the render — before the async GPU readback. Runs in `finally`
220
- // so a render failure can't leave helpers permanently hidden.
221
- emitter.emit('thumbnail:after-capture', undefined)
222
- restoreLevels()
223
- restoreLevelMode?.()
224
- restoreNodeVisibility()
149
+ // Copy the main camera's transform and projection so the thumbnail
150
+ // matches exactly what the user sees in the viewport.
151
+ thumbnailCamera.position.copy(mainCamera.position)
152
+ thumbnailCamera.quaternion.copy(mainCamera.quaternion)
153
+ if (mainCamera instanceof THREE.PerspectiveCamera) {
154
+ thumbnailCamera.fov = mainCamera.fov
155
+ thumbnailCamera.near = mainCamera.near
156
+ thumbnailCamera.far = mainCamera.far
157
+ }
158
+ const { width, height } = gl.domElement
159
+ thumbnailCamera.aspect = width / height
160
+ if (cameraPose) {
161
+ applySnapshotCapturePose(
162
+ thumbnailCamera,
163
+ cameraPose,
164
+ { width, height },
165
+ {
166
+ w: standardW,
167
+ h: standardH,
168
+ },
169
+ )
170
+ }
171
+ thumbnailCamera.updateProjectionMatrix()
172
+ // The capture camera never joins the scene graph, so its matrixWorld
173
+ // is only refreshed by the render itself — too late for the backdrop
174
+ // uniforms below.
175
+ thumbnailCamera.updateMatrixWorld()
176
+
177
+ const pipeline = pipelineRef.current
178
+ pipeline?.applyEnvironment({
179
+ theme: useViewer.getState().sceneTheme,
180
+ transparent,
181
+ grade: useViewer.getState().shading === 'rendered',
182
+ // Preset/item captures stay clean; scene captures mirror the canvas.
183
+ edges: transparent ? 'off' : useViewer.getState().edges,
184
+ camera: thumbnailCamera,
185
+ })
186
+
187
+ // Capture camera data for snapshot storage
188
+ const pos = cameraPose ? thumbnailCamera.position : mainCamera.position
189
+ let tgt: [number, number, number] | null = null
190
+ if (!cameraPose && controls && 'getTarget' in controls) {
191
+ const v = new THREE.Vector3()
192
+ ;(controls as any).getTarget(v)
193
+ tgt = [v.x, v.y, v.z]
194
+ } else {
195
+ // Walk / drone captures run without orbit controls, so there is no orbit
196
+ // target to read. Synthesize one down the view axis — otherwise the
197
+ // saved snapshot carries no framing to return to.
198
+ const look = new THREE.Vector3(0, 0, -1)
199
+ .applyQuaternion(cameraPose ? thumbnailCamera.quaternion : mainCamera.quaternion)
200
+ .multiplyScalar(FIRST_PERSON_TARGET_DISTANCE)
201
+ .add(pos)
202
+ tgt = [look.x, look.y, look.z]
203
+ }
204
+ const isOrtho = !cameraPose && mainCamera instanceof THREE.OrthographicCamera
205
+ const cameraData: SnapshotCameraData = {
206
+ ...(requestId && { requestId }),
207
+ position: [pos.x, pos.y, pos.z],
208
+ ...(cameraPose && {
209
+ quaternion: [...cameraPose.quaternion] as [number, number, number, number],
210
+ fov: cameraPose.fov,
211
+ }),
212
+ target: tgt,
213
+ type: isOrtho ? 'orthographic' : 'perspective',
214
+ ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
225
215
  }
226
216
 
227
- const result = await capturePromise
228
- blob = result.blob
217
+ const capturePromise = captureSnapshotScene((restore) => {
218
+ if (snapLevels) {
219
+ const prevMode = useViewer.getState().levelMode
220
+ if (prevMode !== 'stacked') {
221
+ restore(() => useViewer.getState().setLevelMode(prevMode))
222
+ useViewer.getState().setLevelMode('stacked')
223
+ }
224
+ restore(snapLevelsToTruePositions())
225
+ }
226
+ restore(temporarilyHideNodeTypes(['scan', 'guide', 'spawn']))
227
+
228
+ // Auto-save uses the published hero framing. An authored shot keeps
229
+ // its own camera while sharing the same true level positions.
230
+ if (snapLevels) {
231
+ const framing = computeHeroFraming()
232
+ if (framing) {
233
+ const pose = heroCameraPose({
234
+ boxes: framing.boxes,
235
+ aim: framing.aim,
236
+ azimuthRad: framing.azimuthRad,
237
+ aspect: width / height,
238
+ })
239
+ thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
240
+ thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
241
+ thumbnailCamera.updateMatrixWorld()
242
+ pipeline?.applyEnvironment({
243
+ theme: useViewer.getState().sceneTheme,
244
+ transparent,
245
+ grade: useViewer.getState().shading === 'rendered',
246
+ edges: transparent ? 'off' : useViewer.getState().edges,
247
+ camera: thumbnailCamera,
248
+ })
249
+ cameraData.position = pose.position
250
+ cameraData.target = pose.target
251
+ }
252
+ }
229
253
 
230
- if (captureMode !== undefined) cameraData.captureMode = captureMode
231
- cameraData.resolution = { w: result.outW, h: result.outH }
232
- } else {
233
- // Fallback: plain render directly to the canvas
234
- try {
254
+ restore(() => emitter.emit('thumbnail:after-capture', undefined))
235
255
  emitter.emit('thumbnail:before-capture', undefined)
236
- gl.render(scene, thumbnailCamera)
237
- } finally {
238
- emitter.emit('thumbnail:after-capture', undefined)
239
- restoreLevels()
240
- restoreLevelMode?.()
241
- restoreNodeVisibility()
242
- }
243
-
244
- let outW: number
245
- let outH: number
256
+ if (cameraPose) {
257
+ restore(snapLevelsToTruePositions())
258
+ restore(temporarilyShowShadowOnly(scene))
259
+ const wallMaterials = new Map<THREE.Mesh, THREE.Material | THREE.Material[]>()
260
+ restore(() => {
261
+ for (const [mesh, material] of wallMaterials) mesh.material = material
262
+ })
263
+ const state = useScene.getState()
264
+ const viewer = useViewer.getState()
265
+ for (const id of sceneRegistry.byType.wall ?? []) {
266
+ const node = state.nodes[id as AnyNodeId]
267
+ const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh | undefined
268
+ if (node?.type !== 'wall' || !mesh?.isMesh) continue
269
+ wallMaterials.set(mesh, mesh.material)
270
+ mesh.material = getVisibleWallMaterials(
271
+ node,
272
+ viewer.shading,
273
+ viewer.textures,
274
+ viewer.colorPreset,
275
+ viewer.sceneTheme,
276
+ state.materials,
277
+ )
278
+ }
279
+ }
246
280
 
247
- if (captureMode === 'viewport') {
248
- ;({ w: outW, h: outH } = clampSnapshotSize(width, height))
249
- const offscreen = document.createElement('canvas')
250
- offscreen.width = outW
251
- offscreen.height = outH
252
- const ctx = offscreen.getContext('2d')!
253
- if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
254
- ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
255
- blob = await new Promise<Blob>((resolve, reject) =>
256
- offscreen.toBlob(
257
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
258
- SNAPSHOT_MIME,
259
- SNAPSHOT_QUALITY,
260
- ),
261
- )
262
- } else if (captureMode === 'area' && cropRegion) {
263
- const sx = Math.round(cropRegion.x * width)
264
- const sy = Math.round(cropRegion.y * height)
265
- const sourceW = Math.round(cropRegion.width * width)
266
- const sourceH = Math.round(cropRegion.height * height)
267
- ;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
268
- const offscreen = document.createElement('canvas')
269
- offscreen.width = outW
270
- offscreen.height = outH
271
- const ctx = offscreen.getContext('2d')!
272
- if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
273
- ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
274
- blob = await new Promise<Blob>((resolve, reject) =>
275
- offscreen.toBlob(
276
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
277
- SNAPSHOT_MIME,
278
- SNAPSHOT_QUALITY,
279
- ),
280
- )
281
+ // Geometry and presentation systems may have mounted new meshes
282
+ // since the viewport's last frame. Apply the same isolation to
283
+ // those meshes before either snapshot render path submits them,
284
+ // and restore fog afterwards so the atmosphere never leaks in.
285
+ restore(refreshIsolation(scene))
286
+ if (pipeline) return pipeline.capture({ captureMode, cropRegion, standardSize })
287
+ gl.render(scene, thumbnailCamera)
288
+ return undefined
289
+ })
290
+
291
+ let blob: Blob
292
+ if (pipeline) {
293
+ const result = await capturePromise
294
+ if (!result) throw new Error('Snapshot capture produced no image')
295
+ blob = result.blob
296
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
297
+ cameraData.resolution = { w: result.outW, h: result.outH }
281
298
  } else {
282
- const srcAspect = width / height
283
- const dstAspect = standardW / standardH
284
- let sx = 0,
285
- sy = 0,
286
- sWidth = width,
287
- sHeight = height
288
- if (srcAspect > dstAspect) {
289
- sWidth = Math.round(height * dstAspect)
290
- sx = Math.round((width - sWidth) / 2)
291
- } else if (srcAspect < dstAspect) {
292
- sHeight = Math.round(width / dstAspect)
293
- sy = Math.round((height - sHeight) / 2)
299
+ await capturePromise
300
+ let outW: number
301
+ let outH: number
302
+
303
+ if (captureMode === 'viewport') {
304
+ ;({ w: outW, h: outH } = clampSnapshotSize(width, height))
305
+ const offscreen = document.createElement('canvas')
306
+ offscreen.width = outW
307
+ offscreen.height = outH
308
+ const ctx = offscreen.getContext('2d')!
309
+ if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
310
+ ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
311
+ blob = await new Promise<Blob>((resolve, reject) =>
312
+ offscreen.toBlob(
313
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
314
+ SNAPSHOT_MIME,
315
+ SNAPSHOT_QUALITY,
316
+ ),
317
+ )
318
+ } else if (captureMode === 'area' && cropRegion) {
319
+ const sx = Math.round(cropRegion.x * width)
320
+ const sy = Math.round(cropRegion.y * height)
321
+ const sourceW = Math.round(cropRegion.width * width)
322
+ const sourceH = Math.round(cropRegion.height * height)
323
+ ;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
324
+ const offscreen = document.createElement('canvas')
325
+ offscreen.width = outW
326
+ offscreen.height = outH
327
+ const ctx = offscreen.getContext('2d')!
328
+ if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
329
+ ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
330
+ blob = await new Promise<Blob>((resolve, reject) =>
331
+ offscreen.toBlob(
332
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
333
+ SNAPSHOT_MIME,
334
+ SNAPSHOT_QUALITY,
335
+ ),
336
+ )
337
+ } else {
338
+ const srcAspect = width / height
339
+ const dstAspect = standardW / standardH
340
+ let sx = 0,
341
+ sy = 0,
342
+ sWidth = width,
343
+ sHeight = height
344
+ if (srcAspect > dstAspect) {
345
+ sWidth = Math.round(height * dstAspect)
346
+ sx = Math.round((width - sWidth) / 2)
347
+ } else if (srcAspect < dstAspect) {
348
+ sHeight = Math.round(width / dstAspect)
349
+ sy = Math.round((height - sHeight) / 2)
350
+ }
351
+ outW = standardW
352
+ outH = standardH
353
+ const offscreen = document.createElement('canvas')
354
+ offscreen.width = outW
355
+ offscreen.height = outH
356
+ offscreen
357
+ .getContext('2d')!
358
+ .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
359
+ blob = await new Promise<Blob>((resolve, reject) =>
360
+ offscreen.toBlob(
361
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
362
+ SNAPSHOT_MIME,
363
+ SNAPSHOT_QUALITY,
364
+ ),
365
+ )
294
366
  }
295
- outW = standardW
296
- outH = standardH
297
- const offscreen = document.createElement('canvas')
298
- offscreen.width = outW
299
- offscreen.height = outH
300
- offscreen
301
- .getContext('2d')!
302
- .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
303
- blob = await new Promise<Blob>((resolve, reject) =>
304
- offscreen.toBlob(
305
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
306
- SNAPSHOT_MIME,
307
- SNAPSHOT_QUALITY,
308
- ),
309
- )
310
- }
311
367
 
312
- if (captureMode !== undefined) cameraData.captureMode = captureMode
313
- cameraData.resolution = { w: outW, h: outH }
314
- }
368
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
369
+ cameraData.resolution = { w: outW, h: outH }
370
+ }
315
371
 
316
- onThumbnailCaptureRef.current?.(blob, cameraData)
317
- } catch (error) {
318
- console.error('❌ Failed to generate thumbnail:', error)
319
- } finally {
320
- isGenerating.current = false
321
- }
372
+ if (
373
+ version !== captureVersion.current ||
374
+ thumbnailCamera !== thumbnailCameraRef.current
375
+ ) {
376
+ throw new Error('The scene changed during capture. Try again.')
377
+ }
378
+ if (cameraPose && event.projectId !== useViewer.getState().projectId) {
379
+ throw new Error('The active project changed during capture')
380
+ }
381
+ await onCapture(blob, cameraData)
382
+ },
383
+ (failure) => emitter.emit('snapshot:capture-failed', failure),
384
+ )
322
385
  },
323
- [gl, scene, mainCamera, controls],
386
+ [gl, scene, getThree],
324
387
  )
325
388
 
326
389
  // Thumbnail request via emitter. Two call shapes:
@@ -330,30 +393,23 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
330
393
  // to their true positions first for a consistent auto-thumbnail angle.
331
394
  // The caller owns policy (when to fire, whether the tab is visible).
332
395
  useEffect(() => {
333
- if (!onThumbnailCapture) return
334
-
335
- const handleGenerateThumbnail = async (event: {
336
- captureMode?: 'standard' | 'viewport' | 'area'
337
- cropRegion?: { x: number; y: number; width: number; height: number }
338
- standardSize?: { w: number; h: number }
339
- snapLevels?: boolean
340
- // Preset/item captures keep the alpha channel (their thumbnails compose
341
- // onto arbitrary palette backgrounds); scene snapshots — studio renders
342
- // and project thumbnails — composite the theme backdrop + sky.
343
- transparent?: boolean
344
- }) => {
345
- await generate(
346
- event.snapLevels === true,
347
- event.captureMode,
348
- event.cropRegion,
349
- event.standardSize,
350
- event.transparent === true,
396
+ const handleGenerateThumbnail = async (event: ThumbnailGenerateEvent) => {
397
+ // A saved-frame notification can enqueue the next shot frame before
398
+ // its predecessor's host callback returns and releases the renderer.
399
+ await enqueueSnapshotCapture(
400
+ captureQueue.current,
401
+ captureVersion,
402
+ event,
403
+ generate,
404
+ (failure) => emitter.emit('snapshot:capture-failed', failure),
351
405
  )
352
406
  }
353
407
 
354
408
  emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
355
- return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
356
- }, [generate, onThumbnailCapture])
409
+ return () => {
410
+ emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
411
+ }
412
+ }, [generate])
357
413
 
358
414
  // Go-to-camera: animate camera to a saved snapshot position/target
359
415
  useEffect(() => {