@pascal-app/editor 0.4.0 → 0.6.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 (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -1,133 +1,451 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
4
- import { snapLevelsToTruePositions } from '@pascal-app/viewer'
4
+ import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
5
+ import type { CameraControls } from '@react-three/drei'
5
6
  import { useThree } from '@react-three/fiber'
6
7
  import { useCallback, useEffect, useRef } from 'react'
7
8
  import * as THREE from 'three'
9
+ import { UnsignedByteType } from 'three'
10
+ import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
11
+ import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
12
+ import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
13
+ import {
14
+ colorToDirection,
15
+ convertToTexture,
16
+ diffuseColor,
17
+ directionToColor,
18
+ float,
19
+ mrt,
20
+ normalView,
21
+ output,
22
+ pass,
23
+ sample,
24
+ vec4,
25
+ } from 'three/tsl'
26
+ import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
8
27
  import { EDITOR_LAYER } from '../../lib/constants'
9
28
 
10
29
  const THUMBNAIL_WIDTH = 1920
11
30
  const THUMBNAIL_HEIGHT = 1080
12
31
  const AUTO_SAVE_DELAY = 10_000
13
32
 
33
+ export interface SnapshotCameraData {
34
+ position: [number, number, number]
35
+ target: [number, number, number] | null
36
+ type?: 'perspective' | 'orthographic'
37
+ zoom?: number
38
+ captureMode?: 'standard' | 'viewport' | 'area'
39
+ resolution?: { w: number; h: number }
40
+ }
41
+
14
42
  interface ThumbnailGeneratorProps {
15
- onThumbnailCapture?: (blob: Blob) => void
43
+ onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
16
44
  }
17
45
 
18
46
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
19
47
  const gl = useThree((state) => state.gl)
20
48
  const scene = useThree((state) => state.scene)
49
+ const mainCamera = useThree((state) => state.camera)
50
+ const controls = useThree((state) => state.controls) as CameraControls | null
21
51
  const isGenerating = useRef(false)
22
52
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
23
53
  const pendingAutoRef = useRef(false)
24
54
  const onThumbnailCaptureRef = useRef(onThumbnailCapture)
25
55
 
56
+ const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
57
+ const pipelineRef = useRef<RenderPipeline | null>(null)
58
+ const renderTargetRef = useRef<RenderTarget | null>(null)
59
+
26
60
  useEffect(() => {
27
61
  onThumbnailCaptureRef.current = onThumbnailCapture
28
62
  }, [onThumbnailCapture])
29
63
 
30
- const generate = useCallback(async () => {
31
- if (isGenerating.current) return
32
- if (!onThumbnailCaptureRef.current) return
64
+ // Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
65
+ useEffect(() => {
66
+ const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
67
+ cam.layers.disable(EDITOR_LAYER)
68
+ thumbnailCameraRef.current = cam
69
+
70
+ let mounted = true
33
71
 
34
- isGenerating.current = true
72
+ const buildPipeline = async () => {
73
+ try {
74
+ if ((gl as any).init) await (gl as any).init()
75
+ if (!mounted) return
35
76
 
36
- try {
37
- const thumbnailCamera = new THREE.PerspectiveCamera(
38
- 60,
39
- THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
40
- 0.1,
41
- 1000,
42
- )
77
+ // pass() handles MRT internally for all material types, including custom
78
+ // shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
79
+ // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
80
+ const scenePass = pass(scene, cam)
81
+ scenePass.setMRT(
82
+ mrt({
83
+ output,
84
+ diffuseColor,
85
+ normal: directionToColor(normalView),
86
+ }),
87
+ )
43
88
 
44
- const nodes = useScene.getState().nodes
45
- const siteNode = Object.values(nodes).find((n) => n.type === 'site')
89
+ const scenePassColor = scenePass.getTextureNode('output')
90
+ const scenePassDepth = scenePass.getTextureNode('depth')
91
+ const scenePassNormal = scenePass.getTextureNode('normal')
46
92
 
47
- if (siteNode?.camera) {
48
- const { position, target } = siteNode.camera
49
- thumbnailCamera.position.set(position[0], position[1], position[2])
50
- thumbnailCamera.lookAt(target[0], target[1], target[2])
51
- } else {
52
- thumbnailCamera.position.set(8, 8, 8)
53
- thumbnailCamera.lookAt(0, 0, 0)
93
+ scenePass.getTexture('diffuseColor').type = UnsignedByteType
94
+ scenePass.getTexture('normal').type = UnsignedByteType
95
+
96
+ const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
97
+
98
+ const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
99
+ giPass.sliceCount.value = SSGI_PARAMS.sliceCount
100
+ giPass.stepCount.value = SSGI_PARAMS.stepCount
101
+ giPass.radius.value = SSGI_PARAMS.radius
102
+ giPass.expFactor.value = SSGI_PARAMS.expFactor
103
+ giPass.thickness.value = SSGI_PARAMS.thickness
104
+ giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
105
+ giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
106
+ giPass.giIntensity.value = SSGI_PARAMS.giIntensity
107
+ giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
108
+ giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
109
+ giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
110
+
111
+ const giTexture = (giPass as any).getTextureNode()
112
+ const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
113
+ const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
114
+ denoisePass.index.value = 0
115
+ denoisePass.radius.value = 4
116
+
117
+ const ao = (denoisePass as any).r
118
+ const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
119
+
120
+ // FXAA requires a texture node as input, convertToTexture renders finalOutput
121
+ // into an intermediate RT so FXAA can sample it with neighbor UV offsets.
122
+ const aaOutput = fxaa(convertToTexture(finalOutput))
123
+
124
+ const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
125
+ pipeline.outputNode = aaOutput
126
+ pipelineRef.current = pipeline
127
+
128
+ // Dedicated render target, pipeline outputs here instead of the canvas,
129
+ // so R3F's main render loop can never overwrite our capture.
130
+ const { width, height } = gl.domElement
131
+ renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
132
+ } catch (error) {
133
+ console.error(
134
+ '[thumbnail] Failed to build post-processing pipeline, will use fallback render.',
135
+ error,
136
+ )
54
137
  }
55
- thumbnailCamera.layers.disable(EDITOR_LAYER)
138
+ }
56
139
 
57
- const { width, height } = gl.domElement
58
- thumbnailCamera.aspect = width / height
59
- thumbnailCamera.updateProjectionMatrix()
140
+ buildPipeline()
60
141
 
61
- const restoreLevels = snapLevelsToTruePositions()
142
+ return () => {
143
+ mounted = false
144
+ pipelineRef.current?.dispose()
145
+ pipelineRef.current = null
146
+ renderTargetRef.current?.dispose()
147
+ renderTargetRef.current = null
148
+ }
149
+ }, [gl, scene])
150
+
151
+ const generate = useCallback(
152
+ async (
153
+ snapLevels: boolean,
154
+ captureMode?: 'standard' | 'viewport' | 'area',
155
+ cropRegion?: { x: number; y: number; width: number; height: number },
156
+ ) => {
157
+ if (isGenerating.current) return
158
+ if (!onThumbnailCaptureRef.current) return
159
+
160
+ isGenerating.current = true
161
+
162
+ try {
163
+ const thumbnailCamera = thumbnailCameraRef.current
164
+ if (!thumbnailCamera) return
165
+
166
+ // Copy the main camera's transform and projection so the thumbnail
167
+ // matches exactly what the user sees in the viewport.
168
+ thumbnailCamera.position.copy(mainCamera.position)
169
+ thumbnailCamera.quaternion.copy(mainCamera.quaternion)
170
+ if (mainCamera instanceof THREE.PerspectiveCamera) {
171
+ thumbnailCamera.fov = mainCamera.fov
172
+ thumbnailCamera.near = mainCamera.near
173
+ thumbnailCamera.far = mainCamera.far
174
+ }
175
+ const { width, height } = gl.domElement
176
+ thumbnailCamera.aspect = width / height
177
+ thumbnailCamera.updateProjectionMatrix()
62
178
 
63
- const visibilitySnapshot = new Map<string, boolean>()
64
- for (const type of ['scan', 'guide'] as const) {
65
- sceneRegistry.byType[type].forEach((id) => {
66
- const obj = sceneRegistry.nodes.get(id)
67
- if (obj) {
68
- visibilitySnapshot.set(id, obj.visible)
69
- obj.visible = false
179
+ // Capture camera data for snapshot storage.
180
+ const pos = mainCamera.position
181
+ let tgt: [number, number, number] | null = null
182
+ if (controls && 'getTarget' in controls) {
183
+ const v = new THREE.Vector3()
184
+ ;(controls as any).getTarget(v)
185
+ tgt = [v.x, v.y, v.z]
186
+ }
187
+ const isOrtho = mainCamera instanceof THREE.OrthographicCamera
188
+ const cameraData: SnapshotCameraData = {
189
+ position: [pos.x, pos.y, pos.z],
190
+ target: tgt,
191
+ type: isOrtho ? 'orthographic' : 'perspective',
192
+ ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
193
+ }
194
+
195
+ // For auto-save, snap levels to stacked positions and reset levelMode.
196
+ let restoreLevelMode: (() => void) | null = null
197
+ let restoreLevels: () => void = () => {}
198
+ if (snapLevels) {
199
+ const prevMode = useViewer.getState().levelMode
200
+ if (prevMode !== 'stacked') {
201
+ useViewer.getState().setLevelMode('stacked')
202
+ restoreLevelMode = () => useViewer.getState().setLevelMode(prevMode)
70
203
  }
71
- })
72
- }
204
+ restoreLevels = snapLevelsToTruePositions()
205
+ }
73
206
 
74
- gl.render(scene, thumbnailCamera)
75
-
76
- restoreLevels()
77
- visibilitySnapshot.forEach((wasVisible, id) => {
78
- const obj = sceneRegistry.nodes.get(id)
79
- if (obj) obj.visible = wasVisible
80
- })
81
-
82
- const srcAspect = width / height
83
- const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
84
- let sx = 0,
85
- sy = 0,
86
- sWidth = width,
87
- sHeight = height
88
- if (srcAspect > dstAspect) {
89
- sWidth = Math.round(height * dstAspect)
90
- sx = Math.round((width - sWidth) / 2)
91
- } else if (srcAspect < dstAspect) {
92
- sHeight = Math.round(width / dstAspect)
93
- sy = Math.round((height - sHeight) / 2)
94
- }
207
+ // Hide scan and guide nodes directly so they are excluded from the
208
+ // thumbnail regardless of whether ScanSystem or GuideSystem listeners are
209
+ // registered. Returns a function that restores the original visibility.
210
+ const restoreNodeVisibility = (() => {
211
+ const saved = new Map<THREE.Object3D, boolean>()
212
+ for (const type of ['scan', 'guide'] as const) {
213
+ const ids = sceneRegistry.byType[type]
214
+ ids.forEach((id) => {
215
+ const node = sceneRegistry.nodes.get(id)
216
+ if (node) {
217
+ saved.set(node, node.visible)
218
+ node.visible = false
219
+ }
220
+ })
221
+ }
222
+ return () => {
223
+ saved.forEach((wasVisible, node) => {
224
+ node.visible = wasVisible
225
+ })
226
+ }
227
+ })()
228
+
229
+ let blob: Blob
230
+
231
+ if (pipelineRef.current && renderTargetRef.current) {
232
+ const rt = renderTargetRef.current
233
+
234
+ // Resize RT if the canvas dimensions changed.
235
+ if (rt.width !== width || rt.height !== height) {
236
+ rt.setSize(width, height)
237
+ }
238
+
239
+ const renderer = gl as unknown as WebGPURenderer
240
+
241
+ // Notify other systems (wall cutouts, selection manager) to restore
242
+ // their overrides before capture and re-apply them after.
243
+ emitter.emit('thumbnail:before-capture', undefined)
244
+ ;(renderer as any).setClearAlpha(0)
245
+ renderer.setRenderTarget(rt)
246
+ pipelineRef.current.render()
247
+ renderer.setRenderTarget(null)
248
+ emitter.emit('thumbnail:after-capture', undefined)
249
+
250
+ // Restore level positions, levelMode, and node visibility immediately after the
251
+ // render, before the async GPU readback.
252
+ restoreLevels()
253
+ restoreLevelMode?.()
254
+ restoreNodeVisibility()
95
255
 
96
- const offscreen = document.createElement('canvas')
97
- offscreen.width = THUMBNAIL_WIDTH
98
- offscreen.height = THUMBNAIL_HEIGHT
99
- const ctx = offscreen.getContext('2d')!
100
- ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
256
+ // Read pixels from the RT asynchronously.
257
+ // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
258
+ // depad the rows before constructing ImageData.
259
+ const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
260
+ rt,
261
+ 0,
262
+ 0,
263
+ width,
264
+ height,
265
+ )) as Uint8Array
101
266
 
102
- offscreen.toBlob((blob) => {
103
- if (blob) {
104
- onThumbnailCaptureRef.current?.(blob)
267
+ const actualBytesPerRow = width * 4
268
+ const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
269
+ let tightPixels: Uint8ClampedArray
270
+ if (paddedBytesPerRow === actualBytesPerRow) {
271
+ tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
272
+ } else {
273
+ tightPixels = new Uint8ClampedArray(width * height * 4)
274
+ for (let row = 0; row < height; row++) {
275
+ tightPixels.set(
276
+ pixels.subarray(
277
+ row * paddedBytesPerRow,
278
+ row * paddedBytesPerRow + actualBytesPerRow,
279
+ ),
280
+ row * actualBytesPerRow,
281
+ )
282
+ }
283
+ }
284
+
285
+ const imageData = new ImageData(
286
+ tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
287
+ width,
288
+ height,
289
+ )
290
+ const srcCanvas = new OffscreenCanvas(width, height)
291
+ srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
292
+
293
+ let outW: number
294
+ let outH: number
295
+
296
+ if (captureMode === 'viewport') {
297
+ outW = width
298
+ outH = height
299
+ const offscreen = new OffscreenCanvas(outW, outH)
300
+ offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
301
+ blob = await offscreen.convertToBlob({ type: 'image/png' })
302
+ } else if (captureMode === 'area' && cropRegion) {
303
+ const sx = Math.round(cropRegion.x * width)
304
+ const sy = Math.round(cropRegion.y * height)
305
+ outW = Math.round(cropRegion.width * width)
306
+ outH = Math.round(cropRegion.height * height)
307
+ const offscreen = new OffscreenCanvas(outW, outH)
308
+ offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
309
+ blob = await offscreen.convertToBlob({ type: 'image/png' })
310
+ } else {
311
+ const srcAspect = width / height
312
+ const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
313
+ let sx = 0,
314
+ sy = 0,
315
+ sWidth = width,
316
+ sHeight = height
317
+ if (srcAspect > dstAspect) {
318
+ sWidth = Math.round(height * dstAspect)
319
+ sx = Math.round((width - sWidth) / 2)
320
+ } else if (srcAspect < dstAspect) {
321
+ sHeight = Math.round(width / dstAspect)
322
+ sy = Math.round((height - sHeight) / 2)
323
+ }
324
+ outW = THUMBNAIL_WIDTH
325
+ outH = THUMBNAIL_HEIGHT
326
+ const offscreen = new OffscreenCanvas(outW, outH)
327
+ offscreen
328
+ .getContext('2d')!
329
+ .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
330
+ blob = await offscreen.convertToBlob({ type: 'image/png' })
331
+ }
332
+
333
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
334
+ cameraData.resolution = { w: outW, h: outH }
105
335
  } else {
106
- console.error('❌ Failed to create blob from canvas')
336
+ // Fallback: plain render directly to the canvas.
337
+ emitter.emit('thumbnail:before-capture', undefined)
338
+ gl.render(scene, thumbnailCamera)
339
+ emitter.emit('thumbnail:after-capture', undefined)
340
+ restoreLevels()
341
+ restoreLevelMode?.()
342
+ restoreNodeVisibility()
343
+
344
+ let outW: number
345
+ let outH: number
346
+
347
+ if (captureMode === 'viewport') {
348
+ outW = width
349
+ outH = height
350
+ const offscreen = document.createElement('canvas')
351
+ offscreen.width = outW
352
+ offscreen.height = outH
353
+ offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
354
+ blob = await new Promise<Blob>((resolve, reject) =>
355
+ offscreen.toBlob(
356
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
357
+ 'image/png',
358
+ ),
359
+ )
360
+ } else if (captureMode === 'area' && cropRegion) {
361
+ const sx = Math.round(cropRegion.x * width)
362
+ const sy = Math.round(cropRegion.y * height)
363
+ outW = Math.round(cropRegion.width * width)
364
+ outH = Math.round(cropRegion.height * height)
365
+ const offscreen = document.createElement('canvas')
366
+ offscreen.width = outW
367
+ offscreen.height = outH
368
+ offscreen
369
+ .getContext('2d')!
370
+ .drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
371
+ blob = await new Promise<Blob>((resolve, reject) =>
372
+ offscreen.toBlob(
373
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
374
+ 'image/png',
375
+ ),
376
+ )
377
+ } else {
378
+ const srcAspect = width / height
379
+ const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
380
+ let sx = 0,
381
+ sy = 0,
382
+ sWidth = width,
383
+ sHeight = height
384
+ if (srcAspect > dstAspect) {
385
+ sWidth = Math.round(height * dstAspect)
386
+ sx = Math.round((width - sWidth) / 2)
387
+ } else if (srcAspect < dstAspect) {
388
+ sHeight = Math.round(width / dstAspect)
389
+ sy = Math.round((height - sHeight) / 2)
390
+ }
391
+ outW = THUMBNAIL_WIDTH
392
+ outH = THUMBNAIL_HEIGHT
393
+ const offscreen = document.createElement('canvas')
394
+ offscreen.width = outW
395
+ offscreen.height = outH
396
+ offscreen
397
+ .getContext('2d')!
398
+ .drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
399
+ blob = await new Promise<Blob>((resolve, reject) =>
400
+ offscreen.toBlob(
401
+ (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
402
+ 'image/png',
403
+ ),
404
+ )
405
+ }
406
+
407
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
408
+ cameraData.resolution = { w: outW, h: outH }
107
409
  }
410
+
411
+ onThumbnailCaptureRef.current?.(blob, cameraData)
412
+ } catch (error) {
413
+ console.error('❌ Failed to generate thumbnail:', error)
414
+ } finally {
108
415
  isGenerating.current = false
109
- }, 'image/png')
110
- } catch (error) {
111
- console.error('❌ Failed to generate thumbnail:', error)
112
- isGenerating.current = false
113
- }
114
- }, [gl, scene])
416
+ }
417
+ },
418
+ [gl, scene, mainCamera, controls],
419
+ )
115
420
 
116
- // Manual trigger via emitter
421
+ // Thumbnail request via emitter. Two call shapes:
422
+ // - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
423
+ // the current pose with the supplied crop.
424
+ // - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
425
+ // their true positions first for a consistent auto-thumbnail angle.
117
426
  useEffect(() => {
118
- const handleGenerateThumbnail = async () => {
119
- await generate()
427
+ if (!onThumbnailCapture) return
428
+
429
+ const handleGenerateThumbnail = async (event: {
430
+ captureMode?: 'standard' | 'viewport' | 'area'
431
+ cropRegion?: { x: number; y: number; width: number; height: number }
432
+ snapLevels?: boolean
433
+ }) => {
434
+ await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
120
435
  }
121
436
 
122
437
  emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
123
438
  return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
124
- }, [generate])
439
+ }, [generate, onThumbnailCapture])
125
440
 
126
- // Auto-trigger: debounced on scene changes, deferred if tab is hidden
441
+ // OSS adaptation: keep local debounced auto-capture behavior because the
442
+ // community host-side autosave hook is not part of this repo.
127
443
  useEffect(() => {
128
444
  if (!onThumbnailCapture) return
129
445
 
130
- const triggerNow = () => generate()
446
+ const triggerNow = () => {
447
+ void generate(true)
448
+ }
131
449
 
132
450
  const scheduleOrDefer = () => {
133
451
  if (document.visibilityState === 'visible') {
@@ -160,7 +478,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
160
478
  unsubscribe()
161
479
  document.removeEventListener('visibilitychange', onVisibilityChange)
162
480
  }
163
- }, [onThumbnailCapture, generate])
481
+ }, [generate, onThumbnailCapture])
482
+
483
+ // Go-to-camera: animate camera to a saved snapshot position or target.
484
+ useEffect(() => {
485
+ const handler = ({
486
+ position,
487
+ target,
488
+ }: {
489
+ position: [number, number, number]
490
+ target: [number, number, number]
491
+ }) => {
492
+ if (controls && 'setLookAt' in controls) {
493
+ ;(controls as any).setLookAt(
494
+ position[0],
495
+ position[1],
496
+ position[2],
497
+ target[0],
498
+ target[1],
499
+ target[2],
500
+ true,
501
+ )
502
+ }
503
+ }
504
+ emitter.on('camera:go-to-position', handler)
505
+ return () => emitter.off('camera:go-to-position', handler)
506
+ }, [controls])
164
507
 
165
508
  return null
166
509
  }