@pascal-app/editor 0.4.0 → 0.5.1
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 +5 -5
- package/src/components/editor/floating-action-menu.tsx +101 -29
- package/src/components/editor/floating-building-action-menu.tsx +69 -0
- package/src/components/editor/floorplan-panel.tsx +31 -13
- package/src/components/editor/index.tsx +219 -167
- package/src/components/editor/node-action-menu.tsx +26 -10
- package/src/components/editor/selection-manager.tsx +38 -2
- package/src/components/editor/thumbnail-generator.tsx +245 -64
- 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/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +19 -7
- package/src/components/tools/door/move-door-tool.tsx +17 -8
- package/src/components/tools/fence/fence-drafting.ts +125 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-tool.tsx +223 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +7 -0
- package/src/components/tools/item/placement-strategies.ts +15 -7
- package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
- package/src/components/tools/roof/move-roof-tool.tsx +5 -2
- 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 +2 -2
- 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 +29 -6
- package/src/components/tools/tool-manager.tsx +42 -14
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +17 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +19 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/panels/fence-panel.tsx +184 -0
- package/src/components/ui/panels/panel-manager.tsx +3 -0
- package/src/components/ui/panels/stair-panel.tsx +206 -33
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
- 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 +59 -52
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
- package/src/components/viewer-overlay.tsx +1 -0
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +10 -2
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +4 -0
- package/src/store/use-editor.tsx +7 -0
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { emitter,
|
|
4
|
-
import { snapLevelsToTruePositions } from '@pascal-app/viewer'
|
|
3
|
+
import { emitter, useScene } from '@pascal-app/core'
|
|
4
|
+
import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
|
|
5
5
|
import { useThree } from '@react-three/fiber'
|
|
6
6
|
import { useCallback, useEffect, useRef } from 'react'
|
|
7
7
|
import * as THREE from 'three'
|
|
8
|
+
import { UnsignedByteType } from 'three'
|
|
9
|
+
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
|
10
|
+
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
|
11
|
+
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
|
|
12
|
+
import {
|
|
13
|
+
colorToDirection,
|
|
14
|
+
convertToTexture,
|
|
15
|
+
diffuseColor,
|
|
16
|
+
directionToColor,
|
|
17
|
+
float,
|
|
18
|
+
mrt,
|
|
19
|
+
normalView,
|
|
20
|
+
output,
|
|
21
|
+
pass,
|
|
22
|
+
sample,
|
|
23
|
+
vec4,
|
|
24
|
+
} from 'three/tsl'
|
|
25
|
+
import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
|
|
8
26
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
9
27
|
|
|
10
28
|
const THUMBNAIL_WIDTH = 1920
|
|
@@ -18,15 +36,107 @@ interface ThumbnailGeneratorProps {
|
|
|
18
36
|
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
|
|
19
37
|
const gl = useThree((state) => state.gl)
|
|
20
38
|
const scene = useThree((state) => state.scene)
|
|
39
|
+
const mainCamera = useThree((state) => state.camera)
|
|
21
40
|
const isGenerating = useRef(false)
|
|
22
41
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
23
42
|
const pendingAutoRef = useRef(false)
|
|
24
43
|
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
|
25
44
|
|
|
45
|
+
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
|
|
46
|
+
const pipelineRef = useRef<RenderPipeline | null>(null)
|
|
47
|
+
const renderTargetRef = useRef<RenderTarget | null>(null)
|
|
48
|
+
|
|
26
49
|
useEffect(() => {
|
|
27
50
|
onThumbnailCaptureRef.current = onThumbnailCapture
|
|
28
51
|
}, [onThumbnailCapture])
|
|
29
52
|
|
|
53
|
+
// Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
|
56
|
+
cam.layers.disable(EDITOR_LAYER)
|
|
57
|
+
thumbnailCameraRef.current = cam
|
|
58
|
+
|
|
59
|
+
let mounted = true
|
|
60
|
+
|
|
61
|
+
const buildPipeline = async () => {
|
|
62
|
+
try {
|
|
63
|
+
if ((gl as any).init) await (gl as any).init()
|
|
64
|
+
if (!mounted) return
|
|
65
|
+
|
|
66
|
+
// pass() handles MRT internally for all material types, including custom
|
|
67
|
+
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
|
|
68
|
+
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
|
|
69
|
+
const scenePass = pass(scene, cam)
|
|
70
|
+
scenePass.setMRT(
|
|
71
|
+
mrt({
|
|
72
|
+
output,
|
|
73
|
+
diffuseColor,
|
|
74
|
+
normal: directionToColor(normalView),
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const scenePassColor = scenePass.getTextureNode('output')
|
|
79
|
+
const scenePassDepth = scenePass.getTextureNode('depth')
|
|
80
|
+
const scenePassNormal = scenePass.getTextureNode('normal')
|
|
81
|
+
|
|
82
|
+
scenePass.getTexture('diffuseColor').type = UnsignedByteType
|
|
83
|
+
scenePass.getTexture('normal').type = UnsignedByteType
|
|
84
|
+
|
|
85
|
+
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
|
|
86
|
+
|
|
87
|
+
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
|
|
88
|
+
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
|
89
|
+
giPass.stepCount.value = SSGI_PARAMS.stepCount
|
|
90
|
+
giPass.radius.value = SSGI_PARAMS.radius
|
|
91
|
+
giPass.expFactor.value = SSGI_PARAMS.expFactor
|
|
92
|
+
giPass.thickness.value = SSGI_PARAMS.thickness
|
|
93
|
+
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
|
|
94
|
+
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
|
|
95
|
+
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
|
|
96
|
+
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
|
|
97
|
+
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
|
98
|
+
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
|
99
|
+
|
|
100
|
+
const giTexture = (giPass as any).getTextureNode()
|
|
101
|
+
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
|
102
|
+
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
|
|
103
|
+
denoisePass.index.value = 0
|
|
104
|
+
denoisePass.radius.value = 4
|
|
105
|
+
|
|
106
|
+
const ao = (denoisePass as any).r
|
|
107
|
+
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
|
|
108
|
+
|
|
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.
|
|
111
|
+
const aaOutput = fxaa(convertToTexture(finalOutput))
|
|
112
|
+
|
|
113
|
+
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
|
|
114
|
+
pipeline.outputNode = aaOutput
|
|
115
|
+
pipelineRef.current = pipeline
|
|
116
|
+
|
|
117
|
+
// Dedicated render target — pipeline outputs here instead of the canvas,
|
|
118
|
+
// so R3F's main render loop can never overwrite our capture.
|
|
119
|
+
const { width, height } = gl.domElement
|
|
120
|
+
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error(
|
|
123
|
+
'[thumbnail] Failed to build post-processing pipeline, will use fallback render.',
|
|
124
|
+
error,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
buildPipeline()
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
mounted = false
|
|
133
|
+
pipelineRef.current?.dispose()
|
|
134
|
+
pipelineRef.current = null
|
|
135
|
+
renderTargetRef.current?.dispose()
|
|
136
|
+
renderTargetRef.current = null
|
|
137
|
+
}
|
|
138
|
+
}, [gl, scene])
|
|
139
|
+
|
|
30
140
|
const generate = useCallback(async () => {
|
|
31
141
|
if (isGenerating.current) return
|
|
32
142
|
if (!onThumbnailCaptureRef.current) return
|
|
@@ -34,84 +144,155 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|
|
34
144
|
isGenerating.current = true
|
|
35
145
|
|
|
36
146
|
try {
|
|
37
|
-
const thumbnailCamera =
|
|
38
|
-
|
|
39
|
-
THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
|
|
40
|
-
0.1,
|
|
41
|
-
1000,
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
const nodes = useScene.getState().nodes
|
|
45
|
-
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
|
|
46
|
-
|
|
47
|
-
if (siteNode?.camera) {
|
|
48
|
-
const { position, target } = siteNode.camera
|
|
49
|
-
thumbnailCamera.position.set(position[0], position[1], position[2])
|
|
50
|
-
thumbnailCamera.lookAt(target[0], target[1], target[2])
|
|
51
|
-
} else {
|
|
52
|
-
thumbnailCamera.position.set(8, 8, 8)
|
|
53
|
-
thumbnailCamera.lookAt(0, 0, 0)
|
|
54
|
-
}
|
|
55
|
-
thumbnailCamera.layers.disable(EDITOR_LAYER)
|
|
147
|
+
const thumbnailCamera = thumbnailCameraRef.current
|
|
148
|
+
if (!thumbnailCamera) return
|
|
56
149
|
|
|
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
|
+
}
|
|
57
159
|
const { width, height } = gl.domElement
|
|
58
160
|
thumbnailCamera.aspect = width / height
|
|
59
161
|
thumbnailCamera.updateProjectionMatrix()
|
|
60
162
|
|
|
61
163
|
const restoreLevels = snapLevelsToTruePositions()
|
|
62
164
|
|
|
63
|
-
|
|
64
|
-
for (const type of ['scan', 'guide'] as const) {
|
|
65
|
-
sceneRegistry.byType[type].forEach((id) => {
|
|
66
|
-
const obj = sceneRegistry.nodes.get(id)
|
|
67
|
-
if (obj) {
|
|
68
|
-
visibilitySnapshot.set(id, obj.visible)
|
|
69
|
-
obj.visible = false
|
|
70
|
-
}
|
|
71
|
-
})
|
|
72
|
-
}
|
|
165
|
+
let blob: Blob
|
|
73
166
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const srcAspect = width / height
|
|
83
|
-
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
|
84
|
-
let sx = 0,
|
|
85
|
-
sy = 0,
|
|
86
|
-
sWidth = width,
|
|
87
|
-
sHeight = height
|
|
88
|
-
if (srcAspect > dstAspect) {
|
|
89
|
-
sWidth = Math.round(height * dstAspect)
|
|
90
|
-
sx = Math.round((width - sWidth) / 2)
|
|
91
|
-
} else if (srcAspect < dstAspect) {
|
|
92
|
-
sHeight = Math.round(width / dstAspect)
|
|
93
|
-
sy = Math.round((height - sHeight) / 2)
|
|
94
|
-
}
|
|
167
|
+
if (pipelineRef.current && renderTargetRef.current) {
|
|
168
|
+
const rt = renderTargetRef.current
|
|
169
|
+
|
|
170
|
+
// Resize RT if the canvas dimensions changed
|
|
171
|
+
if (rt.width !== width || rt.height !== height) {
|
|
172
|
+
rt.setSize(width, height)
|
|
173
|
+
}
|
|
95
174
|
|
|
96
|
-
|
|
97
|
-
offscreen.width = THUMBNAIL_WIDTH
|
|
98
|
-
offscreen.height = THUMBNAIL_HEIGHT
|
|
99
|
-
const ctx = offscreen.getContext('2d')!
|
|
100
|
-
ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
|
175
|
+
const renderer = gl as unknown as WebGPURenderer
|
|
101
176
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
105
206
|
} else {
|
|
106
|
-
|
|
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
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
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)
|
|
107
230
|
}
|
|
108
|
-
|
|
109
|
-
|
|
231
|
+
|
|
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)
|
|
239
|
+
|
|
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)
|
|
244
|
+
|
|
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)
|
|
263
|
+
}
|
|
264
|
+
|
|
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
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
onThumbnailCaptureRef.current?.(blob)
|
|
110
290
|
} catch (error) {
|
|
111
291
|
console.error('❌ Failed to generate thumbnail:', error)
|
|
292
|
+
} finally {
|
|
112
293
|
isGenerating.current = false
|
|
113
294
|
}
|
|
114
|
-
}, [gl, scene])
|
|
295
|
+
}, [gl, scene, mainCamera])
|
|
115
296
|
|
|
116
297
|
// Manual trigger via emitter
|
|
117
298
|
useEffect(() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
-
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Imperatively toggles the Three.js visibility of stair objects based on the
|
|
@@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react'
|
|
|
17
17
|
*/
|
|
18
18
|
export const StairEditSystem = () => {
|
|
19
19
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
20
|
+
const selectedStairSignature = useScene(
|
|
21
|
+
useCallback(
|
|
22
|
+
(state) =>
|
|
23
|
+
selectedIds
|
|
24
|
+
.map((id) => {
|
|
25
|
+
const node = state.nodes[id as AnyNodeId]
|
|
26
|
+
if (!node) return null
|
|
27
|
+
if (node.type === 'stair') {
|
|
28
|
+
return `${node.id}:${node.stairType}`
|
|
29
|
+
}
|
|
30
|
+
if (node.type === 'stair-segment' && node.parentId) {
|
|
31
|
+
const parent = state.nodes[node.parentId as AnyNodeId] as StairNode | undefined
|
|
32
|
+
return parent?.type === 'stair' ? `${parent.id}:${parent.stairType}` : null
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
})
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join('|'),
|
|
38
|
+
[selectedIds],
|
|
39
|
+
),
|
|
40
|
+
)
|
|
20
41
|
const prevActiveStairIds = useRef(new Set<string>())
|
|
21
42
|
|
|
22
43
|
useEffect(() => {
|
|
@@ -41,14 +62,15 @@ export const StairEditSystem = () => {
|
|
|
41
62
|
const group = sceneRegistry.nodes.get(stairId)
|
|
42
63
|
if (!group) continue
|
|
43
64
|
|
|
65
|
+
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
|
66
|
+
const isCurved = stairNode?.stairType === 'curved' || stairNode?.stairType === 'spiral'
|
|
44
67
|
const mergedMesh = group.getObjectByName('merged-stair')
|
|
45
68
|
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
|
46
69
|
const isActive = activeStairIds.has(stairId)
|
|
47
70
|
|
|
48
|
-
if (mergedMesh) mergedMesh.visible = !isActive
|
|
49
|
-
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
|
71
|
+
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
|
|
72
|
+
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
|
50
73
|
|
|
51
|
-
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
|
52
74
|
if (stairNode?.children?.length) {
|
|
53
75
|
const wasActive = prevActiveStairIds.current.has(stairId)
|
|
54
76
|
if (isActive !== wasActive) {
|
|
@@ -63,7 +85,7 @@ export const StairEditSystem = () => {
|
|
|
63
85
|
}
|
|
64
86
|
|
|
65
87
|
prevActiveStairIds.current = activeStairIds
|
|
66
|
-
}, [selectedIds])
|
|
88
|
+
}, [selectedIds, selectedStairSignature])
|
|
67
89
|
|
|
68
90
|
return null
|
|
69
91
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
emitter,
|
|
6
|
+
type GridEvent,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
11
|
+
import { useFrame } from '@react-three/fiber'
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import * as THREE from 'three'
|
|
14
|
+
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
15
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
16
|
+
import useEditor from '../../../store/use-editor'
|
|
17
|
+
import { CursorSphere } from '../shared/cursor-sphere'
|
|
18
|
+
|
|
19
|
+
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
|
20
|
+
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
21
|
+
|
|
22
|
+
// Stable refs so the effect never needs node in its dependency array
|
|
23
|
+
const nodeIdRef = useRef(node.id)
|
|
24
|
+
const originalPositionRef = useRef<[number, number, number]>([...node.position] as [
|
|
25
|
+
number,
|
|
26
|
+
number,
|
|
27
|
+
number,
|
|
28
|
+
])
|
|
29
|
+
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
|
30
|
+
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
|
|
31
|
+
|
|
32
|
+
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
|
|
33
|
+
const obj = sceneRegistry.nodes.get(node.id)
|
|
34
|
+
if (obj) {
|
|
35
|
+
const pos = new THREE.Vector3()
|
|
36
|
+
obj.getWorldPosition(pos)
|
|
37
|
+
return [pos.x, pos.y, pos.z]
|
|
38
|
+
}
|
|
39
|
+
return [node.position[0], node.position[1], node.position[2]]
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const exitMoveMode = useCallback(() => {
|
|
43
|
+
useEditor.getState().setMovingNode(null)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const nodeId = nodeIdRef.current
|
|
48
|
+
const originalPosition = originalPositionRef.current
|
|
49
|
+
|
|
50
|
+
useScene.temporal.getState().pause()
|
|
51
|
+
|
|
52
|
+
let wasCommitted = false
|
|
53
|
+
|
|
54
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
55
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ROTATION_STEP = Math.PI / 2
|
|
60
|
+
let rotationDelta = 0
|
|
61
|
+
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
|
62
|
+
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
|
63
|
+
|
|
64
|
+
if (rotationDelta !== 0) {
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
67
|
+
pendingRotationRef.current += rotationDelta
|
|
68
|
+
|
|
69
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
70
|
+
if (mesh) mesh.rotation.y = pendingRotationRef.current
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onGridMove = (event: GridEvent) => {
|
|
75
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
76
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
previousGridPosRef.current &&
|
|
80
|
+
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
|
81
|
+
) {
|
|
82
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
previousGridPosRef.current = [gridX, gridZ]
|
|
86
|
+
setCursorWorldPos([gridX, 0, gridZ])
|
|
87
|
+
|
|
88
|
+
// Directly update the Three.js group — no store update during drag
|
|
89
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
90
|
+
if (mesh) {
|
|
91
|
+
mesh.position.x = gridX
|
|
92
|
+
mesh.position.z = gridZ
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const onGridClick = (event: GridEvent) => {
|
|
97
|
+
const gridX = Math.round(event.position[0] * 2) / 2
|
|
98
|
+
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
99
|
+
|
|
100
|
+
wasCommitted = true
|
|
101
|
+
|
|
102
|
+
useScene.temporal.getState().resume()
|
|
103
|
+
useScene.getState().updateNode(nodeId, {
|
|
104
|
+
position: [gridX, originalPosition[1], gridZ],
|
|
105
|
+
rotation: [0, pendingRotationRef.current, 0],
|
|
106
|
+
})
|
|
107
|
+
useScene.temporal.getState().pause()
|
|
108
|
+
|
|
109
|
+
sfxEmitter.emit('sfx:item-place')
|
|
110
|
+
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
|
|
111
|
+
exitMoveMode()
|
|
112
|
+
event.nativeEvent?.stopPropagation?.()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const onCancel = () => {
|
|
116
|
+
// Revert mesh position and rotation immediately
|
|
117
|
+
const mesh = sceneRegistry.nodes.get(nodeId)
|
|
118
|
+
if (mesh) {
|
|
119
|
+
mesh.position.x = originalPosition[0]
|
|
120
|
+
mesh.position.z = originalPosition[2]
|
|
121
|
+
mesh.rotation.y = originalRotationRef.current
|
|
122
|
+
}
|
|
123
|
+
pendingRotationRef.current = originalRotationRef.current
|
|
124
|
+
// Restore building selection
|
|
125
|
+
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
|
|
126
|
+
useScene.temporal.getState().resume()
|
|
127
|
+
// Tell the keyboard handler we handled this, so it doesn't also clear the selection
|
|
128
|
+
markToolCancelConsumed()
|
|
129
|
+
exitMoveMode()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
emitter.on('grid:move', onGridMove)
|
|
133
|
+
emitter.on('grid:click', onGridClick)
|
|
134
|
+
emitter.on('tool:cancel', onCancel)
|
|
135
|
+
window.addEventListener('keydown', onKeyDown)
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
if (!wasCommitted) {
|
|
139
|
+
useScene.getState().updateNode(nodeId, {
|
|
140
|
+
position: originalPosition,
|
|
141
|
+
rotation: [0, originalRotationRef.current, 0],
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
useScene.temporal.getState().resume()
|
|
145
|
+
emitter.off('grid:move', onGridMove)
|
|
146
|
+
emitter.off('grid:click', onGridClick)
|
|
147
|
+
emitter.off('tool:cancel', onCancel)
|
|
148
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
149
|
+
}
|
|
150
|
+
}, [exitMoveMode]) // stable — node values captured via refs at mount
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<group>
|
|
154
|
+
<CursorSphere position={cursorWorldPos} showTooltip={false} />
|
|
155
|
+
</group>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -70,7 +70,7 @@ export function hasWallChildOverlap(
|
|
|
70
70
|
const newLeft = clampedX - halfW
|
|
71
71
|
const newRight = clampedX + halfW
|
|
72
72
|
|
|
73
|
-
for (const childId of wallNode.children) {
|
|
73
|
+
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
|
74
74
|
if (childId === ignoreId) continue
|
|
75
75
|
const child = nodes[childId as AnyNodeId]
|
|
76
76
|
if (!child) continue
|
|
@@ -143,13 +143,25 @@ export const DoorTool: React.FC = () => {
|
|
|
143
143
|
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
|
144
144
|
|
|
145
145
|
if (draftRef.current) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
if (event.node.id !== draftRef.current.parentId) {
|
|
147
|
+
// Wall changed without enter/leave: must updateNode to reparent
|
|
148
|
+
useScene.getState().updateNode(draftRef.current.id, {
|
|
149
|
+
position: [clampedX, clampedY, 0],
|
|
150
|
+
rotation: [0, itemRotation, 0],
|
|
151
|
+
side,
|
|
152
|
+
parentId: event.node.id,
|
|
153
|
+
wallId: event.node.id,
|
|
154
|
+
})
|
|
155
|
+
} else {
|
|
156
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
157
|
+
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
|
158
|
+
if (draftMesh) {
|
|
159
|
+
draftMesh.position.set(clampedX, clampedY, 0)
|
|
160
|
+
draftMesh.rotation.set(0, itemRotation, 0)
|
|
161
|
+
draftMesh.updateMatrixWorld(true)
|
|
162
|
+
}
|
|
163
|
+
markWallDirty(event.node.id)
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
const valid = !hasWallChildOverlap(
|
|
@@ -165,17 +165,26 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|
|
165
165
|
movingDoorNode.height,
|
|
166
166
|
)
|
|
167
167
|
|
|
168
|
-
useScene.getState().updateNode(movingDoorNode.id, {
|
|
169
|
-
position: [clampedX, clampedY, 0],
|
|
170
|
-
rotation: [0, itemRotation, 0],
|
|
171
|
-
side,
|
|
172
|
-
parentId: event.node.id,
|
|
173
|
-
wallId: event.node.id,
|
|
174
|
-
})
|
|
175
|
-
|
|
176
168
|
if (currentWallId !== event.node.id) {
|
|
169
|
+
// Wall changed mid-move: must updateNode to reparent
|
|
170
|
+
useScene.getState().updateNode(movingDoorNode.id, {
|
|
171
|
+
position: [clampedX, clampedY, 0],
|
|
172
|
+
rotation: [0, itemRotation, 0],
|
|
173
|
+
side,
|
|
174
|
+
parentId: event.node.id,
|
|
175
|
+
wallId: event.node.id,
|
|
176
|
+
})
|
|
177
177
|
markWallDirty(currentWallId)
|
|
178
178
|
currentWallId = event.node.id
|
|
179
|
+
} else {
|
|
180
|
+
// Same wall: update Three.js mesh directly to avoid store churn
|
|
181
|
+
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
|
182
|
+
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
|
183
|
+
if (doorMesh) {
|
|
184
|
+
doorMesh.position.set(clampedX, clampedY, 0)
|
|
185
|
+
doorMesh.rotation.set(0, itemRotation, 0)
|
|
186
|
+
doorMesh.updateMatrixWorld(true)
|
|
187
|
+
}
|
|
179
188
|
}
|
|
180
189
|
markWallDirty(event.node.id)
|
|
181
190
|
|