@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
@@ -1,16 +1,27 @@
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
+ SNAPSHOT_MAX_EDGE,
17
+ SNAPSHOT_MIME,
18
+ SNAPSHOT_QUALITY,
9
19
  type SnapshotPipeline,
10
20
  snapLevelsToTruePositions,
11
21
  THUMBNAIL_HEIGHT,
12
22
  THUMBNAIL_WIDTH,
13
23
  temporarilyHideNodeTypes,
24
+ temporarilyShowShadowOnly,
14
25
  useViewer,
15
26
  } from '@pascal-app/viewer'
16
27
  import type { CameraControls } from '@react-three/drei'
@@ -19,9 +30,19 @@ import { useCallback, useEffect, useRef } from 'react'
19
30
  import * as THREE from 'three'
20
31
  import type { WebGPURenderer } from 'three/webgpu'
21
32
  import { EDITOR_LAYER } from '../../lib/constants'
33
+ import {
34
+ applySnapshotCapturePose,
35
+ captureSnapshotScene,
36
+ createSnapshotQueue,
37
+ enqueueSnapshotCapture,
38
+ runSnapshotCapture,
39
+ } from './snapshot-capture'
22
40
 
23
41
  export interface SnapshotCameraData {
42
+ requestId?: string
24
43
  position: [number, number, number]
44
+ quaternion?: [number, number, number, number]
45
+ fov?: number
25
46
  target: [number, number, number] | null
26
47
  type?: 'perspective' | 'orthographic'
27
48
  zoom?: number
@@ -33,16 +54,29 @@ interface ThumbnailGeneratorProps {
33
54
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
34
55
  }
35
56
 
57
+ /** Metres ahead of a controls-less camera to place the stored snapshot target. */
58
+ const FIRST_PERSON_TARGET_DISTANCE = 8
59
+
60
+ function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
61
+ const maxEdge = Math.max(width, height)
62
+ if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
63
+
64
+ const scale = SNAPSHOT_MAX_EDGE / maxEdge
65
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
66
+ }
67
+
36
68
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
37
69
  const gl = useThree((state) => state.gl)
38
70
  const scene = useThree((state) => state.scene)
39
- const mainCamera = useThree((state) => state.camera)
71
+ const getThree = useThree((state) => state.get)
40
72
  const controls = useThree((state) => state.controls) as CameraControls | null
41
73
  const isGenerating = useRef(false)
74
+ const captureQueue = useRef(createSnapshotQueue())
42
75
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
43
76
 
44
77
  const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
45
78
  const pipelineRef = useRef<SnapshotPipeline | null>(null)
79
+ const captureVersion = useRef(0)
46
80
 
47
81
  useEffect(() => {
48
82
  onThumbnailCaptureRef.current = onThumbnailCapture
@@ -50,6 +84,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
50
84
 
51
85
  // Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
52
86
  useEffect(() => {
87
+ captureVersion.current += 1
53
88
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
54
89
  cam.layers.disable(EDITOR_LAYER)
55
90
  cam.layers.disable(GRID_LAYER)
@@ -74,237 +109,272 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
74
109
 
75
110
  return () => {
76
111
  mounted = false
112
+ captureVersion.current += 1
113
+ thumbnailCameraRef.current = null
77
114
  pipelineRef.current?.dispose()
78
115
  pipelineRef.current = null
79
116
  }
80
117
  }, [gl, scene])
81
118
 
82
119
  const generate = useCallback(
83
- async (
84
- snapLevels: boolean,
85
- captureMode?: 'standard' | 'viewport' | 'area',
86
- cropRegion?: { x: number; y: number; width: number; height: number },
87
- standardSize?: { w: number; h: number },
88
- transparent = false,
89
- ) => {
120
+ async (event: ThumbnailGenerateEvent) => {
121
+ const { captureMode, cropRegion, standardSize, cameraPose, requestId } = event
122
+ const snapLevels = event.snapLevels === true
123
+ const transparent = event.transparent === true
90
124
  const standardW = standardSize?.w ?? THUMBNAIL_WIDTH
91
125
  const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT
92
- if (isGenerating.current) return
93
- if (!onThumbnailCaptureRef.current) return
94
-
95
- isGenerating.current = true
96
-
97
- try {
98
- const thumbnailCamera = thumbnailCameraRef.current
99
- if (!thumbnailCamera) return
100
-
101
- // Copy the main camera's transform and projection so the thumbnail
102
- // matches exactly what the user sees in the viewport.
103
- thumbnailCamera.position.copy(mainCamera.position)
104
- thumbnailCamera.quaternion.copy(mainCamera.quaternion)
105
- if (mainCamera instanceof THREE.PerspectiveCamera) {
106
- thumbnailCamera.fov = mainCamera.fov
107
- thumbnailCamera.near = mainCamera.near
108
- thumbnailCamera.far = mainCamera.far
109
- }
110
- const { width, height } = gl.domElement
111
- thumbnailCamera.aspect = width / height
112
- thumbnailCamera.updateProjectionMatrix()
113
- // The capture camera never joins the scene graph, so its matrixWorld
114
- // is only refreshed by the render itself — too late for the backdrop
115
- // uniforms below.
116
- thumbnailCamera.updateMatrixWorld()
117
-
118
- const pipeline = pipelineRef.current
119
- pipeline?.applyEnvironment({
120
- theme: useViewer.getState().sceneTheme,
121
- transparent,
122
- grade: useViewer.getState().shading === 'rendered',
123
- // Preset/item captures stay clean; scene captures mirror the canvas.
124
- edges: transparent ? 'off' : useViewer.getState().edges,
125
- camera: thumbnailCamera,
126
- })
127
-
128
- // Capture camera data for snapshot storage
129
- const pos = mainCamera.position
130
- let tgt: [number, number, number] | null = null
131
- if (controls && 'getTarget' in controls) {
132
- const v = new THREE.Vector3()
133
- ;(controls as any).getTarget(v)
134
- tgt = [v.x, v.y, v.z]
135
- }
136
- const isOrtho = mainCamera instanceof THREE.OrthographicCamera
137
- const cameraData: SnapshotCameraData = {
138
- position: [pos.x, pos.y, pos.z],
139
- target: tgt,
140
- type: isOrtho ? 'orthographic' : 'perspective',
141
- ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
142
- }
143
-
144
- // For auto-save: snap levels to stacked positions and reset levelMode
145
- let restoreLevelMode: (() => void) | null = null
146
- let restoreLevels: () => void = () => {}
147
- if (snapLevels) {
148
- const prevMode = useViewer.getState().levelMode
149
- if (prevMode !== 'stacked') {
150
- useViewer.getState().setLevelMode('stacked')
151
- 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')
152
140
  }
153
- restoreLevels = snapLevelsToTruePositions()
154
- }
155
-
156
- // Hide scan, guide, and spawn nodes directly so they are excluded from
157
- // the thumbnail regardless of whether ScanSystem/GuideSystem listeners
158
- // are registered. Spawn renders on SCENE_LAYER for occlusion, so the
159
- // thumbnail camera's layer mask can't filter it either. Returns a
160
- // function that restores the original visibility.
161
- const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
162
-
163
- // Auto-save shots don't copy the user's mid-edit camera — they re-pose
164
- // onto the same computed hero angle the published thumbnail uses, so a
165
- // project's card never shows a half-zoomed working view. Measured after
166
- // the level snap so stacked positions frame correctly. User-driven
167
- // captures (captureMode set) keep the exact viewport pose.
168
- if (snapLevels) {
169
- const framing = computeHeroFraming()
170
- if (framing) {
171
- const pose = heroCameraPose({
172
- boxes: framing.boxes,
173
- aim: framing.aim,
174
- azimuthRad: framing.azimuthRad,
175
- aspect: width / height,
176
- })
177
- thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
178
- thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
179
- thumbnailCamera.updateMatrixWorld()
180
- pipeline?.applyEnvironment({
181
- theme: useViewer.getState().sceneTheme,
182
- transparent,
183
- grade: useViewer.getState().shading === 'rendered',
184
- edges: transparent ? 'off' : useViewer.getState().edges,
185
- camera: thumbnailCamera,
186
- })
187
- cameraData.position = pose.position
188
- cameraData.target = pose.target
141
+ if (cameraPose && !pipelineRef.current) {
142
+ throw new Error('Snapshot renderer is not ready. Try again.')
189
143
  }
190
- }
191
-
192
- let blob: Blob
193
-
194
- if (pipeline) {
195
- let capturePromise: ReturnType<SnapshotPipeline['capture']>
196
144
 
197
- // Notify other systems (wall cutouts, selection manager) to restore
198
- // their overrides before capture and re-apply them after.
199
- try {
200
- emitter.emit('thumbnail:before-capture', undefined)
201
- capturePromise = pipeline.capture({
202
- captureMode,
203
- cropRegion,
204
- standardSize,
205
- })
206
- } finally {
207
- // Restore level positions, levelMode, and node visibility immediately
208
- // after the render — before the async GPU readback. Runs in `finally`
209
- // so a render failure can't leave helpers permanently hidden.
210
- emitter.emit('thumbnail:after-capture', undefined)
211
- restoreLevels()
212
- restoreLevelMode?.()
213
- 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 }),
214
211
  }
215
212
 
216
- const result = await capturePromise
217
- 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
+ }
218
249
 
219
- if (captureMode !== undefined) cameraData.captureMode = captureMode
220
- cameraData.resolution = { w: result.outW, h: result.outH }
221
- } else {
222
- // Fallback: plain render directly to the canvas
223
- try {
250
+ restore(() => emitter.emit('thumbnail:after-capture', undefined))
224
251
  emitter.emit('thumbnail:before-capture', undefined)
225
- gl.render(scene, thumbnailCamera)
226
- } finally {
227
- emitter.emit('thumbnail:after-capture', undefined)
228
- restoreLevels()
229
- restoreLevelMode?.()
230
- restoreNodeVisibility()
231
- }
232
-
233
- let outW: number
234
- 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
+ }
235
276
 
236
- if (captureMode === 'viewport') {
237
- outW = width
238
- outH = height
239
- const offscreen = document.createElement('canvas')
240
- offscreen.width = outW
241
- offscreen.height = outH
242
- offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
243
- blob = await new Promise<Blob>((resolve, reject) =>
244
- offscreen.toBlob(
245
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
246
- 'image/png',
247
- ),
248
- )
249
- } else if (captureMode === 'area' && cropRegion) {
250
- const sx = Math.round(cropRegion.x * width)
251
- const sy = Math.round(cropRegion.y * height)
252
- outW = Math.round(cropRegion.width * width)
253
- outH = Math.round(cropRegion.height * height)
254
- const offscreen = document.createElement('canvas')
255
- offscreen.width = outW
256
- offscreen.height = outH
257
- offscreen
258
- .getContext('2d')!
259
- .drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
260
- blob = await new Promise<Blob>((resolve, reject) =>
261
- offscreen.toBlob(
262
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
263
- 'image/png',
264
- ),
265
- )
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 }
266
289
  } else {
267
- const srcAspect = width / height
268
- const dstAspect = standardW / standardH
269
- let sx = 0,
270
- sy = 0,
271
- sWidth = width,
272
- sHeight = height
273
- if (srcAspect > dstAspect) {
274
- sWidth = Math.round(height * dstAspect)
275
- sx = Math.round((width - sWidth) / 2)
276
- } else if (srcAspect < dstAspect) {
277
- sHeight = Math.round(width / dstAspect)
278
- 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
+ )
279
357
  }
280
- outW = standardW
281
- outH = standardH
282
- const offscreen = document.createElement('canvas')
283
- offscreen.width = outW
284
- offscreen.height = outH
285
- offscreen
286
- .getContext('2d')!
287
- .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
288
- blob = await new Promise<Blob>((resolve, reject) =>
289
- offscreen.toBlob(
290
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
291
- 'image/png',
292
- ),
293
- )
294
- }
295
358
 
296
- if (captureMode !== undefined) cameraData.captureMode = captureMode
297
- cameraData.resolution = { w: outW, h: outH }
298
- }
359
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
360
+ cameraData.resolution = { w: outW, h: outH }
361
+ }
299
362
 
300
- onThumbnailCaptureRef.current?.(blob, cameraData)
301
- } catch (error) {
302
- console.error('❌ Failed to generate thumbnail:', error)
303
- } finally {
304
- isGenerating.current = false
305
- }
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
+ )
306
376
  },
307
- [gl, scene, mainCamera, controls],
377
+ [gl, scene, getThree],
308
378
  )
309
379
 
310
380
  // Thumbnail request via emitter. Two call shapes:
@@ -314,30 +384,23 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
314
384
  // to their true positions first for a consistent auto-thumbnail angle.
315
385
  // The caller owns policy (when to fire, whether the tab is visible).
316
386
  useEffect(() => {
317
- if (!onThumbnailCapture) return
318
-
319
- const handleGenerateThumbnail = async (event: {
320
- captureMode?: 'standard' | 'viewport' | 'area'
321
- cropRegion?: { x: number; y: number; width: number; height: number }
322
- standardSize?: { w: number; h: number }
323
- snapLevels?: boolean
324
- // Preset/item captures keep the alpha channel (their thumbnails compose
325
- // onto arbitrary palette backgrounds); scene snapshots — studio renders
326
- // and project thumbnails — composite the theme backdrop + sky.
327
- transparent?: boolean
328
- }) => {
329
- await generate(
330
- event.snapLevels === true,
331
- event.captureMode,
332
- event.cropRegion,
333
- event.standardSize,
334
- 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),
335
396
  )
336
397
  }
337
398
 
338
399
  emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
339
- return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
340
- }, [generate, onThumbnailCapture])
400
+ return () => {
401
+ emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
402
+ }
403
+ }, [generate])
341
404
 
342
405
  // Go-to-camera: animate camera to a saved snapshot position/target
343
406
  useEffect(() => {