@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- 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-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- 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 +138 -56
- 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 +9 -5
- 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/spawn-tree-node.tsx +82 -0
- 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 +12 -6
- 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 +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -20,12 +20,18 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function SiteEdgeLabels() {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Narrow subscription to just the site node — subscribing to the full
|
|
24
|
+
// s.nodes dict re-rendered this on every wall/level mutation even though
|
|
25
|
+
// the site itself rarely changes.
|
|
26
|
+
const siteNode = useScene((state) => {
|
|
27
|
+
const firstRoot = state.rootNodeIds[0]
|
|
28
|
+
if (!firstRoot) return null
|
|
29
|
+
const node = state.nodes[firstRoot]
|
|
30
|
+
return node?.type === 'site' ? (node as SiteNode) : null
|
|
31
|
+
})
|
|
25
32
|
const unit = useViewer((state) => state.unit)
|
|
26
33
|
const theme = useViewer((state) => state.theme)
|
|
27
34
|
|
|
28
|
-
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
|
|
29
35
|
const siteNodeId = siteNode?.id
|
|
30
36
|
|
|
31
37
|
const isNight = theme === 'dark'
|
|
@@ -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,335 @@ 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 tightTotal = actualBytesPerRow * height
|
|
269
|
+
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
|
270
|
+
// Two readback shapes to handle:
|
|
271
|
+
// - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
|
|
272
|
+
// when width*4 isn't already a multiple of 256.
|
|
273
|
+
// - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
|
|
274
|
+
// (OpenGL framebuffer convention).
|
|
275
|
+
// `isWebGPURenderer` lies — it stays true even when the renderer
|
|
276
|
+
// falls back to the WebGL backend. Inspect the actual backend
|
|
277
|
+
// instead (presence of a GPU device, or backend constructor name).
|
|
278
|
+
const backend = (renderer as any).backend
|
|
279
|
+
const isWebGPU =
|
|
280
|
+
!!backend?.device ||
|
|
281
|
+
backend?.isWebGPUBackend === true ||
|
|
282
|
+
backend?.constructor?.name === 'WebGPUBackend'
|
|
283
|
+
let tightPixels: Uint8ClampedArray
|
|
284
|
+
if (isWebGPU) {
|
|
285
|
+
// WebGPU: depad rows if needed; orientation is already top-down.
|
|
286
|
+
if (paddedBytesPerRow === actualBytesPerRow) {
|
|
287
|
+
tightPixels = new Uint8ClampedArray(
|
|
288
|
+
pixels.buffer,
|
|
289
|
+
pixels.byteOffset,
|
|
290
|
+
Math.min(pixels.byteLength, tightTotal),
|
|
291
|
+
)
|
|
292
|
+
} else {
|
|
293
|
+
tightPixels = new Uint8ClampedArray(tightTotal)
|
|
294
|
+
for (let row = 0; row < height; row++) {
|
|
295
|
+
tightPixels.set(
|
|
296
|
+
pixels.subarray(
|
|
297
|
+
row * paddedBytesPerRow,
|
|
298
|
+
row * paddedBytesPerRow + actualBytesPerRow,
|
|
299
|
+
),
|
|
300
|
+
row * actualBytesPerRow,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
// WebGL2: tight buffer in bottom-up order — flip rows.
|
|
306
|
+
tightPixels = new Uint8ClampedArray(tightTotal)
|
|
307
|
+
for (let row = 0; row < height; row++) {
|
|
308
|
+
const srcStart = (height - 1 - row) * actualBytesPerRow
|
|
309
|
+
tightPixels.set(
|
|
310
|
+
pixels.subarray(srcStart, srcStart + actualBytesPerRow),
|
|
311
|
+
row * actualBytesPerRow,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
231
315
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
316
|
+
const imageData = new ImageData(
|
|
317
|
+
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
|
|
318
|
+
width,
|
|
319
|
+
height,
|
|
320
|
+
)
|
|
321
|
+
const srcCanvas = new OffscreenCanvas(width, height)
|
|
322
|
+
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
|
|
323
|
+
|
|
324
|
+
let outW: number
|
|
325
|
+
let outH: number
|
|
326
|
+
|
|
327
|
+
if (captureMode === 'viewport') {
|
|
328
|
+
outW = width
|
|
329
|
+
outH = height
|
|
330
|
+
const offscreen = new OffscreenCanvas(outW, outH)
|
|
331
|
+
offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
|
|
332
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
|
333
|
+
} else if (captureMode === 'area' && cropRegion) {
|
|
334
|
+
const sx = Math.round(cropRegion.x * width)
|
|
335
|
+
const sy = Math.round(cropRegion.y * height)
|
|
336
|
+
outW = Math.round(cropRegion.width * width)
|
|
337
|
+
outH = Math.round(cropRegion.height * height)
|
|
338
|
+
const offscreen = new OffscreenCanvas(outW, outH)
|
|
339
|
+
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
|
|
340
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
|
341
|
+
} else {
|
|
342
|
+
const srcAspect = width / height
|
|
343
|
+
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
|
344
|
+
let sx = 0,
|
|
345
|
+
sy = 0,
|
|
346
|
+
sWidth = width,
|
|
347
|
+
sHeight = height
|
|
348
|
+
if (srcAspect > dstAspect) {
|
|
349
|
+
sWidth = Math.round(height * dstAspect)
|
|
350
|
+
sx = Math.round((width - sWidth) / 2)
|
|
351
|
+
} else if (srcAspect < dstAspect) {
|
|
352
|
+
sHeight = Math.round(width / dstAspect)
|
|
353
|
+
sy = Math.round((height - sHeight) / 2)
|
|
354
|
+
}
|
|
355
|
+
outW = THUMBNAIL_WIDTH
|
|
356
|
+
outH = THUMBNAIL_HEIGHT
|
|
357
|
+
const offscreen = new OffscreenCanvas(outW, outH)
|
|
358
|
+
offscreen
|
|
359
|
+
.getContext('2d')!
|
|
360
|
+
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
|
|
361
|
+
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
|
362
|
+
}
|
|
239
363
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
364
|
+
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
|
365
|
+
cameraData.resolution = { w: outW, h: outH }
|
|
366
|
+
} else {
|
|
367
|
+
// Fallback: plain render directly to the canvas.
|
|
368
|
+
emitter.emit('thumbnail:before-capture', undefined)
|
|
369
|
+
gl.render(scene, thumbnailCamera)
|
|
370
|
+
emitter.emit('thumbnail:after-capture', undefined)
|
|
371
|
+
restoreLevels()
|
|
372
|
+
restoreLevelMode?.()
|
|
373
|
+
restoreNodeVisibility()
|
|
374
|
+
|
|
375
|
+
let outW: number
|
|
376
|
+
let outH: number
|
|
377
|
+
|
|
378
|
+
if (captureMode === 'viewport') {
|
|
379
|
+
outW = width
|
|
380
|
+
outH = height
|
|
381
|
+
const offscreen = document.createElement('canvas')
|
|
382
|
+
offscreen.width = outW
|
|
383
|
+
offscreen.height = outH
|
|
384
|
+
offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
|
|
385
|
+
blob = await new Promise<Blob>((resolve, reject) =>
|
|
386
|
+
offscreen.toBlob(
|
|
387
|
+
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
388
|
+
'image/png',
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
} else if (captureMode === 'area' && cropRegion) {
|
|
392
|
+
const sx = Math.round(cropRegion.x * width)
|
|
393
|
+
const sy = Math.round(cropRegion.y * height)
|
|
394
|
+
outW = Math.round(cropRegion.width * width)
|
|
395
|
+
outH = Math.round(cropRegion.height * height)
|
|
396
|
+
const offscreen = document.createElement('canvas')
|
|
397
|
+
offscreen.width = outW
|
|
398
|
+
offscreen.height = outH
|
|
399
|
+
offscreen
|
|
400
|
+
.getContext('2d')!
|
|
401
|
+
.drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
|
|
402
|
+
blob = await new Promise<Blob>((resolve, reject) =>
|
|
403
|
+
offscreen.toBlob(
|
|
404
|
+
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
405
|
+
'image/png',
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
} else {
|
|
409
|
+
const srcAspect = width / height
|
|
410
|
+
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
|
411
|
+
let sx = 0,
|
|
412
|
+
sy = 0,
|
|
413
|
+
sWidth = width,
|
|
414
|
+
sHeight = height
|
|
415
|
+
if (srcAspect > dstAspect) {
|
|
416
|
+
sWidth = Math.round(height * dstAspect)
|
|
417
|
+
sx = Math.round((width - sWidth) / 2)
|
|
418
|
+
} else if (srcAspect < dstAspect) {
|
|
419
|
+
sHeight = Math.round(width / dstAspect)
|
|
420
|
+
sy = Math.round((height - sHeight) / 2)
|
|
421
|
+
}
|
|
422
|
+
outW = THUMBNAIL_WIDTH
|
|
423
|
+
outH = THUMBNAIL_HEIGHT
|
|
424
|
+
const offscreen = document.createElement('canvas')
|
|
425
|
+
offscreen.width = outW
|
|
426
|
+
offscreen.height = outH
|
|
427
|
+
offscreen
|
|
428
|
+
.getContext('2d')!
|
|
429
|
+
.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
|
|
430
|
+
blob = await new Promise<Blob>((resolve, reject) =>
|
|
431
|
+
offscreen.toBlob(
|
|
432
|
+
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
433
|
+
'image/png',
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
}
|
|
244
437
|
|
|
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)
|
|
438
|
+
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
|
439
|
+
cameraData.resolution = { w: outW, h: outH }
|
|
263
440
|
}
|
|
264
441
|
|
|
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
|
-
)
|
|
442
|
+
onThumbnailCaptureRef.current?.(blob, cameraData)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error('❌ Failed to generate thumbnail:', error)
|
|
445
|
+
} finally {
|
|
446
|
+
isGenerating.current = false
|
|
287
447
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// Manual trigger via emitter
|
|
448
|
+
},
|
|
449
|
+
[gl, scene, mainCamera, controls],
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
// Thumbnail request via emitter. Two call shapes:
|
|
453
|
+
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
|
|
454
|
+
// the current pose with the supplied crop.
|
|
455
|
+
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
|
|
456
|
+
// their true positions first for a consistent auto-thumbnail angle.
|
|
298
457
|
useEffect(() => {
|
|
299
|
-
|
|
300
|
-
|
|
458
|
+
if (!onThumbnailCapture) return
|
|
459
|
+
|
|
460
|
+
const handleGenerateThumbnail = async (event: {
|
|
461
|
+
captureMode?: 'standard' | 'viewport' | 'area'
|
|
462
|
+
cropRegion?: { x: number; y: number; width: number; height: number }
|
|
463
|
+
snapLevels?: boolean
|
|
464
|
+
}) => {
|
|
465
|
+
await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
|
|
301
466
|
}
|
|
302
467
|
|
|
303
468
|
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
|
304
469
|
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
|
305
|
-
}, [generate])
|
|
470
|
+
}, [generate, onThumbnailCapture])
|
|
306
471
|
|
|
307
|
-
//
|
|
472
|
+
// OSS adaptation: keep local debounced auto-capture behavior because the
|
|
473
|
+
// community host-side autosave hook is not part of this repo.
|
|
308
474
|
useEffect(() => {
|
|
309
475
|
if (!onThumbnailCapture) return
|
|
310
476
|
|
|
311
|
-
const triggerNow = () =>
|
|
477
|
+
const triggerNow = () => {
|
|
478
|
+
void generate(true)
|
|
479
|
+
}
|
|
312
480
|
|
|
313
481
|
const scheduleOrDefer = () => {
|
|
314
482
|
if (document.visibilityState === 'visible') {
|
|
@@ -341,7 +509,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|
|
341
509
|
unsubscribe()
|
|
342
510
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
343
511
|
}
|
|
344
|
-
}, [
|
|
512
|
+
}, [generate, onThumbnailCapture])
|
|
513
|
+
|
|
514
|
+
// Go-to-camera: animate camera to a saved snapshot position or target.
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
const handler = ({
|
|
517
|
+
position,
|
|
518
|
+
target,
|
|
519
|
+
}: {
|
|
520
|
+
position: [number, number, number]
|
|
521
|
+
target: [number, number, number]
|
|
522
|
+
}) => {
|
|
523
|
+
if (controls && 'setLookAt' in controls) {
|
|
524
|
+
;(controls as any).setLookAt(
|
|
525
|
+
position[0],
|
|
526
|
+
position[1],
|
|
527
|
+
position[2],
|
|
528
|
+
target[0],
|
|
529
|
+
target[1],
|
|
530
|
+
target[2],
|
|
531
|
+
true,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
emitter.on('camera:go-to-position', handler)
|
|
536
|
+
return () => emitter.off('camera:go-to-position', handler)
|
|
537
|
+
}, [controls])
|
|
345
538
|
|
|
346
539
|
return null
|
|
347
540
|
}
|