@pascal-app/editor 0.5.1 → 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 (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -10
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { emitter, useScene } from '@pascal-app/core'
4
- import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
3
+ import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
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'
@@ -29,14 +30,24 @@ const THUMBNAIL_WIDTH = 1920
29
30
  const THUMBNAIL_HEIGHT = 1080
30
31
  const AUTO_SAVE_DELAY = 10_000
31
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
+
32
42
  interface ThumbnailGeneratorProps {
33
- onThumbnailCapture?: (blob: Blob) => void
43
+ onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
34
44
  }
35
45
 
36
46
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
37
47
  const gl = useThree((state) => state.gl)
38
48
  const scene = useThree((state) => state.scene)
39
49
  const mainCamera = useThree((state) => state.camera)
50
+ const controls = useThree((state) => state.controls) as CameraControls | null
40
51
  const isGenerating = useRef(false)
41
52
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
42
53
  const pendingAutoRef = useRef(false)
@@ -50,7 +61,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
50
61
  onThumbnailCaptureRef.current = onThumbnailCapture
51
62
  }, [onThumbnailCapture])
52
63
 
53
- // Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
64
+ // Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
54
65
  useEffect(() => {
55
66
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
56
67
  cam.layers.disable(EDITOR_LAYER)
@@ -64,7 +75,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
64
75
  if (!mounted) return
65
76
 
66
77
  // pass() handles MRT internally for all material types, including custom
67
- // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
78
+ // shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
68
79
  // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
69
80
  const scenePass = pass(scene, cam)
70
81
  scenePass.setMRT(
@@ -106,15 +117,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
106
117
  const ao = (denoisePass as any).r
107
118
  const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
108
119
 
109
- // FXAA requires a texture node as input; convertToTexture renders finalOutput
110
- // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
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.
111
122
  const aaOutput = fxaa(convertToTexture(finalOutput))
112
123
 
113
124
  const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
114
125
  pipeline.outputNode = aaOutput
115
126
  pipelineRef.current = pipeline
116
127
 
117
- // Dedicated render target pipeline outputs here instead of the canvas,
128
+ // Dedicated render target, pipeline outputs here instead of the canvas,
118
129
  // so R3F's main render loop can never overwrite our capture.
119
130
  const { width, height } = gl.domElement
120
131
  renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -137,178 +148,304 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
137
148
  }
138
149
  }, [gl, scene])
139
150
 
140
- const generate = useCallback(async () => {
141
- if (isGenerating.current) return
142
- if (!onThumbnailCaptureRef.current) return
143
-
144
- isGenerating.current = true
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
145
159
 
146
- try {
147
- const thumbnailCamera = thumbnailCameraRef.current
148
- if (!thumbnailCamera) return
160
+ isGenerating.current = true
149
161
 
150
- // Copy the main camera's transform and projection so the thumbnail
151
- // matches exactly what the user sees in the viewport.
152
- thumbnailCamera.position.copy(mainCamera.position)
153
- thumbnailCamera.quaternion.copy(mainCamera.quaternion)
154
- if (mainCamera instanceof THREE.PerspectiveCamera) {
155
- thumbnailCamera.fov = mainCamera.fov
156
- thumbnailCamera.near = mainCamera.near
157
- thumbnailCamera.far = mainCamera.far
158
- }
159
- const { width, height } = gl.domElement
160
- thumbnailCamera.aspect = width / height
161
- thumbnailCamera.updateProjectionMatrix()
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()
178
+
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
+ }
162
194
 
163
- const restoreLevels = snapLevelsToTruePositions()
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)
203
+ }
204
+ restoreLevels = snapLevelsToTruePositions()
205
+ }
164
206
 
165
- let blob: Blob
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
+ })()
166
228
 
167
- if (pipelineRef.current && renderTargetRef.current) {
168
- const rt = renderTargetRef.current
229
+ let blob: Blob
169
230
 
170
- // Resize RT if the canvas dimensions changed
171
- if (rt.width !== width || rt.height !== height) {
172
- rt.setSize(width, height)
173
- }
231
+ if (pipelineRef.current && renderTargetRef.current) {
232
+ const rt = renderTargetRef.current
174
233
 
175
- const renderer = gl as unknown as WebGPURenderer
176
-
177
- // Swap selected-item materials back to originals for the capture,
178
- // then re-apply highlights immediately after.
179
- emitter.emit('thumbnail:before-capture', undefined)
180
- ;(renderer as any).setClearAlpha(0)
181
- renderer.setRenderTarget(rt)
182
- pipelineRef.current.render()
183
- renderer.setRenderTarget(null)
184
- emitter.emit('thumbnail:after-capture', undefined)
185
-
186
- // Restore level positions immediately after the render — before the async GPU readback.
187
- restoreLevels()
188
-
189
- // Read pixels from the RT asynchronously.
190
- // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
191
- // depad the rows before constructing ImageData.
192
- const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
193
- rt,
194
- 0,
195
- 0,
196
- width,
197
- height,
198
- )) as Uint8Array
199
-
200
- const actualBytesPerRow = width * 4
201
- const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
202
- let tightPixels: Uint8ClampedArray
203
- if (paddedBytesPerRow === actualBytesPerRow) {
204
- // No padding — use the buffer directly
205
- tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
206
- } else {
207
- // Depad rows
208
- tightPixels = new Uint8ClampedArray(width * height * 4)
209
- for (let row = 0; row < height; row++) {
210
- tightPixels.set(
211
- pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow),
212
- row * actualBytesPerRow,
213
- )
234
+ // Resize RT if the canvas dimensions changed.
235
+ if (rt.width !== width || rt.height !== height) {
236
+ rt.setSize(width, height)
214
237
  }
215
- }
216
238
 
217
- // Crop to thumbnail aspect ratio and draw to offscreen canvas
218
- const srcAspect = width / height
219
- const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
220
- let sx = 0,
221
- sy = 0,
222
- sWidth = width,
223
- sHeight = height
224
- if (srcAspect > dstAspect) {
225
- sWidth = Math.round(height * dstAspect)
226
- sx = Math.round((width - sWidth) / 2)
227
- } else if (srcAspect < dstAspect) {
228
- sHeight = Math.round(width / dstAspect)
229
- sy = Math.round((height - sHeight) / 2)
230
- }
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()
255
+
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
266
+
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
+ }
231
284
 
232
- const imageData = new ImageData(
233
- tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
234
- width,
235
- height,
236
- )
237
- const srcCanvas = new OffscreenCanvas(width, height)
238
- srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
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
+ }
239
332
 
240
- const offscreen = new OffscreenCanvas(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
241
- offscreen
242
- .getContext('2d')!
243
- .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
333
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
334
+ cameraData.resolution = { w: outW, h: outH }
335
+ } else {
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
+ }
244
406
 
245
- blob = await offscreen.convertToBlob({ type: 'image/png' })
246
- } else {
247
- // Fallback: plain render directly to the canvas
248
- gl.render(scene, thumbnailCamera)
249
- restoreLevels()
250
-
251
- const srcAspect = width / height
252
- const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
253
- let sx = 0,
254
- sy = 0,
255
- sWidth = width,
256
- sHeight = height
257
- if (srcAspect > dstAspect) {
258
- sWidth = Math.round(height * dstAspect)
259
- sx = Math.round((width - sWidth) / 2)
260
- } else if (srcAspect < dstAspect) {
261
- sHeight = Math.round(width / dstAspect)
262
- sy = Math.round((height - sHeight) / 2)
407
+ if (captureMode !== undefined) cameraData.captureMode = captureMode
408
+ cameraData.resolution = { w: outW, h: outH }
263
409
  }
264
410
 
265
- const offscreen = document.createElement('canvas')
266
- offscreen.width = THUMBNAIL_WIDTH
267
- offscreen.height = THUMBNAIL_HEIGHT
268
- const ctx = offscreen.getContext('2d')!
269
- ctx.drawImage(
270
- gl.domElement,
271
- sx,
272
- sy,
273
- sWidth,
274
- sHeight,
275
- 0,
276
- 0,
277
- THUMBNAIL_WIDTH,
278
- THUMBNAIL_HEIGHT,
279
- )
280
-
281
- blob = await new Promise<Blob>((resolve, reject) =>
282
- offscreen.toBlob(
283
- (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
284
- 'image/png',
285
- ),
286
- )
411
+ onThumbnailCaptureRef.current?.(blob, cameraData)
412
+ } catch (error) {
413
+ console.error('❌ Failed to generate thumbnail:', error)
414
+ } finally {
415
+ isGenerating.current = false
287
416
  }
288
-
289
- onThumbnailCaptureRef.current?.(blob)
290
- } catch (error) {
291
- console.error('❌ Failed to generate thumbnail:', error)
292
- } finally {
293
- isGenerating.current = false
294
- }
295
- }, [gl, scene, mainCamera])
296
-
297
- // Manual trigger via emitter
417
+ },
418
+ [gl, scene, mainCamera, controls],
419
+ )
420
+
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.
298
426
  useEffect(() => {
299
- const handleGenerateThumbnail = async () => {
300
- 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)
301
435
  }
302
436
 
303
437
  emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
304
438
  return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
305
- }, [generate])
439
+ }, [generate, onThumbnailCapture])
306
440
 
307
- // 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.
308
443
  useEffect(() => {
309
444
  if (!onThumbnailCapture) return
310
445
 
311
- const triggerNow = () => generate()
446
+ const triggerNow = () => {
447
+ void generate(true)
448
+ }
312
449
 
313
450
  const scheduleOrDefer = () => {
314
451
  if (document.visibilityState === 'visible') {
@@ -341,7 +478,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
341
478
  unsubscribe()
342
479
  document.removeEventListener('visibilitychange', onVisibilityChange)
343
480
  }
344
- }, [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])
345
507
 
346
508
  return null
347
509
  }