@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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +173 -19
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- 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
|
|
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
|
|
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
|
|
110
|
-
// into an intermediate RT so FXAA can sample it with
|
|
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
|
|
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(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
const thumbnailCamera = thumbnailCameraRef.current
|
|
148
|
-
if (!thumbnailCamera) return
|
|
160
|
+
isGenerating.current = true
|
|
149
161
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
thumbnailCamera.
|
|
157
|
-
thumbnailCamera.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
const rt = renderTargetRef.current
|
|
229
|
+
let blob: Blob
|
|
169
230
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
rt.setSize(width, height)
|
|
173
|
-
}
|
|
231
|
+
if (pipelineRef.current && renderTargetRef.current) {
|
|
232
|
+
const rt = renderTargetRef.current
|
|
174
233
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
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 = () =>
|
|
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
|
-
}, [
|
|
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
|
}
|