@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.
- package/package.json +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- 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/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- 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-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- 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/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- 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/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- 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/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- 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 +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- 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 +377 -50
- 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 +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -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 +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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
72
|
+
const buildPipeline = async () => {
|
|
73
|
+
try {
|
|
74
|
+
if ((gl as any).init) await (gl as any).init()
|
|
75
|
+
if (!mounted) return
|
|
35
76
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
89
|
+
const scenePassColor = scenePass.getTextureNode('output')
|
|
90
|
+
const scenePassDepth = scenePass.getTextureNode('depth')
|
|
91
|
+
const scenePassNormal = scenePass.getTextureNode('normal')
|
|
46
92
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
138
|
+
}
|
|
56
139
|
|
|
57
|
-
|
|
58
|
-
thumbnailCamera.aspect = width / height
|
|
59
|
-
thumbnailCamera.updateProjectionMatrix()
|
|
140
|
+
buildPipeline()
|
|
60
141
|
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
}, [gl, scene])
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
[gl, scene, mainCamera, controls],
|
|
419
|
+
)
|
|
115
420
|
|
|
116
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
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 = () =>
|
|
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
|
-
}, [
|
|
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
|
}
|