@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.
Files changed (61) hide show
  1. package/package.json +5 -5
  2. package/src/components/editor/floating-action-menu.tsx +101 -29
  3. package/src/components/editor/floating-building-action-menu.tsx +69 -0
  4. package/src/components/editor/floorplan-panel.tsx +31 -13
  5. package/src/components/editor/index.tsx +219 -167
  6. package/src/components/editor/node-action-menu.tsx +26 -10
  7. package/src/components/editor/selection-manager.tsx +38 -2
  8. package/src/components/editor/thumbnail-generator.tsx +245 -64
  9. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  10. package/src/components/tools/building/move-building-tool.tsx +157 -0
  11. package/src/components/tools/door/door-math.ts +1 -1
  12. package/src/components/tools/door/door-tool.tsx +19 -7
  13. package/src/components/tools/door/move-door-tool.tsx +17 -8
  14. package/src/components/tools/fence/fence-drafting.ts +125 -0
  15. package/src/components/tools/fence/fence-tool.tsx +190 -0
  16. package/src/components/tools/fence/move-fence-tool.tsx +223 -0
  17. package/src/components/tools/item/item-tool.tsx +3 -3
  18. package/src/components/tools/item/move-tool.tsx +7 -0
  19. package/src/components/tools/item/placement-strategies.ts +15 -7
  20. package/src/components/tools/item/use-placement-coordinator.tsx +89 -14
  21. package/src/components/tools/roof/move-roof-tool.tsx +5 -2
  22. package/src/components/tools/roof/roof-tool.tsx +6 -6
  23. package/src/components/tools/select/box-select-tool.tsx +2 -2
  24. package/src/components/tools/shared/polygon-editor.tsx +2 -2
  25. package/src/components/tools/slab/slab-tool.tsx +4 -4
  26. package/src/components/tools/stair/stair-defaults.ts +10 -0
  27. package/src/components/tools/stair/stair-tool.tsx +29 -6
  28. package/src/components/tools/tool-manager.tsx +42 -14
  29. package/src/components/tools/wall/wall-tool.tsx +19 -29
  30. package/src/components/tools/window/move-window-tool.tsx +17 -8
  31. package/src/components/tools/window/window-math.ts +1 -1
  32. package/src/components/tools/window/window-tool.tsx +19 -7
  33. package/src/components/tools/zone/zone-tool.tsx +7 -7
  34. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  35. package/src/components/ui/helpers/building-helper.tsx +32 -0
  36. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  37. package/src/components/ui/panels/fence-panel.tsx +184 -0
  38. package/src/components/ui/panels/panel-manager.tsx +3 -0
  39. package/src/components/ui/panels/stair-panel.tsx +206 -33
  40. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +22 -15
  41. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +60 -52
  42. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +35 -24
  43. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +65 -0
  44. package/src/components/ui/sidebar/panels/site-panel/index.tsx +59 -40
  45. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  46. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +59 -52
  47. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +27 -22
  48. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +66 -49
  49. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +35 -36
  50. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +66 -49
  51. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +11 -11
  52. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +17 -14
  53. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +57 -53
  54. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +35 -24
  55. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +22 -27
  56. package/src/components/viewer-overlay.tsx +1 -0
  57. package/src/hooks/use-auto-save.ts +3 -6
  58. package/src/hooks/use-contextual-tools.ts +10 -2
  59. package/src/hooks/use-grid-events.ts +13 -1
  60. package/src/hooks/use-keyboard.ts +4 -0
  61. package/src/store/use-editor.tsx +7 -0
@@ -1,10 +1,28 @@
1
1
  'use client'
2
2
 
3
- import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
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 = new THREE.PerspectiveCamera(
38
- 60,
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
- const visibilitySnapshot = new Map<string, boolean>()
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
- gl.render(scene, thumbnailCamera)
75
-
76
- restoreLevels()
77
- visibilitySnapshot.forEach((wasVisible, id) => {
78
- const obj = sceneRegistry.nodes.get(id)
79
- if (obj) obj.visible = wasVisible
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
- const offscreen = document.createElement('canvas')
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
- offscreen.toBlob((blob) => {
103
- if (blob) {
104
- onThumbnailCaptureRef.current?.(blob)
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
- console.error('❌ Failed to create blob from canvas')
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
- isGenerating.current = false
109
- }, 'image/png')
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
- useScene.getState().updateNode(draftRef.current.id, {
147
- position: [clampedX, clampedY, 0],
148
- rotation: [0, itemRotation, 0],
149
- side,
150
- parentId: event.node.id,
151
- wallId: event.node.id,
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