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