@pascal-app/editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -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
+ }
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
36
36
 
37
37
  return (
38
38
  <PolygonEditor
39
+ allowPolygonMove
39
40
  color="#ef4444"
40
41
  levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
41
42
  minVertices={3}
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
+ import { sfxEmitter } from '../../../lib/sfx-bus'
8
+ import useEditor from '../../../store/use-editor'
9
+ import { CursorSphere } from '../shared/cursor-sphere'
10
+ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
11
+
12
+ function snap(value: number) {
13
+ return Math.round(value * 2) / 2
14
+ }
15
+
16
+ function translatePolygon(
17
+ polygon: Array<[number, number]>,
18
+ deltaX: number,
19
+ deltaZ: number,
20
+ ): Array<[number, number]> {
21
+ return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
22
+ }
23
+
24
+ function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
25
+ if (polygon.length === 0) return [0, 0]
26
+ let sumX = 0
27
+ let sumZ = 0
28
+ for (const [x, z] of polygon) {
29
+ sumX += x
30
+ sumZ += z
31
+ }
32
+ return [sumX / polygon.length, sumZ / polygon.length]
33
+ }
34
+
35
+ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
36
+ const activatedAtRef = useRef<number>(Date.now())
37
+ const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
38
+ const originalHolesRef = useRef(
39
+ (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
40
+ )
41
+ const dragAnchorRef = useRef<[number, number] | null>(null)
42
+ const previousGridPosRef = useRef<[number, number] | null>(null)
43
+ const previousCursorPosRef = useRef<[number, number, number] | null>(null)
44
+ const previousDeltaRef = useRef<[number, number] | null>(null)
45
+ const previewRef = useRef<{
46
+ polygon: Array<[number, number]>
47
+ holes: Array<Array<[number, number]>>
48
+ } | null>(null)
49
+
50
+ const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
51
+ const center = getPolygonCenter(node.polygon)
52
+ return [center[0], node.height ?? 2.5, center[1]]
53
+ })
54
+ const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
55
+ const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
56
+
57
+ const exitMoveMode = useCallback(() => {
58
+ useEditor.getState().setMovingNode(null)
59
+ }, [])
60
+
61
+ useEffect(() => {
62
+ const originalPolygon = originalPolygonRef.current
63
+ const originalHoles = originalHolesRef.current
64
+
65
+ useScene.temporal.getState().pause()
66
+ let wasCommitted = false
67
+
68
+ const applyPreview = (
69
+ polygon: Array<[number, number]>,
70
+ holes: Array<Array<[number, number]>>,
71
+ ) => {
72
+ previewRef.current = { polygon, holes }
73
+ setPreviewPolygon(polygon)
74
+ setPreviewHoles(holes)
75
+ const center = getPolygonCenter(polygon)
76
+ const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
77
+ if (
78
+ !previousCursorPosRef.current ||
79
+ previousCursorPosRef.current[0] !== nextCursorPos[0] ||
80
+ previousCursorPosRef.current[1] !== nextCursorPos[1] ||
81
+ previousCursorPosRef.current[2] !== nextCursorPos[2]
82
+ ) {
83
+ previousCursorPosRef.current = nextCursorPos
84
+ setCursorLocalPos(nextCursorPos)
85
+ }
86
+ useScene.getState().updateNode(node.id, { polygon, holes })
87
+ useScene.getState().markDirty(node.id as AnyNodeId)
88
+ }
89
+
90
+ const restoreOriginal = () => {
91
+ setPreviewPolygon(originalPolygon)
92
+ setPreviewHoles(originalHoles)
93
+ useScene.getState().updateNode(node.id, {
94
+ holes: originalHoles,
95
+ polygon: originalPolygon,
96
+ })
97
+ useScene.getState().markDirty(node.id as AnyNodeId)
98
+ }
99
+
100
+ const onGridMove = (event: GridEvent) => {
101
+ const localX = snap(event.localPosition[0])
102
+ const localZ = snap(event.localPosition[2])
103
+
104
+ if (
105
+ previousGridPosRef.current &&
106
+ (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
107
+ ) {
108
+ sfxEmitter.emit('sfx:grid-snap')
109
+ }
110
+ previousGridPosRef.current = [localX, localZ]
111
+
112
+ const anchor = dragAnchorRef.current ?? [localX, localZ]
113
+ dragAnchorRef.current = anchor
114
+
115
+ const deltaX = localX - anchor[0]
116
+ const deltaZ = localZ - anchor[1]
117
+
118
+ if (
119
+ previousDeltaRef.current &&
120
+ previousDeltaRef.current[0] === deltaX &&
121
+ previousDeltaRef.current[1] === deltaZ
122
+ ) {
123
+ return
124
+ }
125
+ previousDeltaRef.current = [deltaX, deltaZ]
126
+
127
+ applyPreview(
128
+ translatePolygon(originalPolygon, deltaX, deltaZ),
129
+ originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
130
+ )
131
+ }
132
+
133
+ const onGridClick = (event: GridEvent) => {
134
+ if (Date.now() - activatedAtRef.current < 150) {
135
+ event.nativeEvent?.stopPropagation?.()
136
+ return
137
+ }
138
+
139
+ const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
140
+
141
+ wasCommitted = true
142
+
143
+ // Restore original baseline while paused so the next resume+update
144
+ // registers as a single tracked change (undo reverts to original).
145
+ useScene.getState().updateNode(node.id, {
146
+ polygon: originalPolygon,
147
+ holes: originalHoles,
148
+ })
149
+
150
+ useScene.temporal.getState().resume()
151
+ useScene.getState().updateNode(node.id, preview)
152
+ useScene.getState().markDirty(node.id as AnyNodeId)
153
+ useScene.temporal.getState().pause()
154
+
155
+ sfxEmitter.emit('sfx:item-place')
156
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
157
+ exitMoveMode()
158
+ event.nativeEvent?.stopPropagation?.()
159
+ }
160
+
161
+ const onCancel = () => {
162
+ restoreOriginal()
163
+ useViewer.getState().setSelection({ selectedIds: [node.id] })
164
+ useScene.temporal.getState().resume()
165
+ markToolCancelConsumed()
166
+ exitMoveMode()
167
+ }
168
+
169
+ emitter.on('grid:move', onGridMove)
170
+ emitter.on('grid:click', onGridClick)
171
+ emitter.on('tool:cancel', onCancel)
172
+
173
+ return () => {
174
+ if (!wasCommitted) {
175
+ restoreOriginal()
176
+ }
177
+ useScene.temporal.getState().resume()
178
+ emitter.off('grid:move', onGridMove)
179
+ emitter.off('grid:click', onGridClick)
180
+ emitter.off('tool:cancel', onCancel)
181
+ }
182
+ }, [exitMoveMode, node.height, node.id])
183
+
184
+ const previewFillGeometry = useMemo(
185
+ () => createCeilingPreviewGeometry(previewPolygon, previewHoles),
186
+ [previewHoles, previewPolygon],
187
+ )
188
+
189
+ const previewOutlineGeometry = useMemo(
190
+ () => createCeilingOutlineGeometry(previewPolygon),
191
+ [previewPolygon],
192
+ )
193
+
194
+ return (
195
+ <group>
196
+ <mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
197
+ <meshBasicMaterial
198
+ color="#f5f5f4"
199
+ depthWrite={false}
200
+ opacity={0.3}
201
+ side={DoubleSide}
202
+ transparent
203
+ />
204
+ </mesh>
205
+ <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
206
+ <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
207
+ </line>
208
+ <CursorSphere position={cursorLocalPos} showTooltip={false} />
209
+ </group>
210
+ )
211
+ }
212
+
213
+ function createCeilingPreviewGeometry(
214
+ polygon: Array<[number, number]>,
215
+ holes: Array<Array<[number, number]>>,
216
+ ): BufferGeometry {
217
+ if (polygon.length < 3) return new BufferGeometry()
218
+
219
+ const shape = new Shape()
220
+ const [firstX, firstZ] = polygon[0]!
221
+ shape.moveTo(firstX, -firstZ)
222
+
223
+ for (let i = 1; i < polygon.length; i++) {
224
+ const [x, z] = polygon[i]!
225
+ shape.lineTo(x, -z)
226
+ }
227
+ shape.closePath()
228
+
229
+ for (const holePolygon of holes) {
230
+ if (holePolygon.length < 3) continue
231
+ const hole = new Path()
232
+ const [hx, hz] = holePolygon[0]!
233
+ hole.moveTo(hx, -hz)
234
+ for (let i = 1; i < holePolygon.length; i++) {
235
+ const [x, z] = holePolygon[i]!
236
+ hole.lineTo(x, -z)
237
+ }
238
+ hole.closePath()
239
+ shape.holes.push(hole)
240
+ }
241
+
242
+ const geometry = new ShapeGeometry(shape)
243
+ geometry.rotateX(-Math.PI / 2)
244
+ geometry.computeVertexNormals()
245
+ return geometry
246
+ }
247
+
248
+ function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
249
+ const geometry = new BufferGeometry()
250
+ if (polygon.length < 2) return geometry
251
+
252
+ const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
253
+ const [firstX, firstZ] = polygon[0]!
254
+ points.push(new Vector3(firstX, 0, firstZ))
255
+ geometry.setFromPoints(points)
256
+ return geometry
257
+ }
@@ -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
@@ -2,6 +2,7 @@ import {
2
2
  type AnyNodeId,
3
3
  DoorNode,
4
4
  emitter,
5
+ isCurvedWall,
5
6
  sceneRegistry,
6
7
  spatialGridManager,
7
8
  useScene,
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
84
85
 
85
86
  const onWallEnter = (event: WallEvent) => {
86
87
  if (!isValidWallSideFace(event.normal)) return
88
+ if (isCurvedWall(event.node)) {
89
+ destroyDraft()
90
+ hideCursor()
91
+ return
92
+ }
87
93
  const levelId = getLevelId()
88
94
  if (!levelId) return
89
95
  if (event.node.parentId !== levelId) return
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
130
136
 
131
137
  const onWallMove = (event: WallEvent) => {
132
138
  if (!isValidWallSideFace(event.normal)) return
139
+ if (isCurvedWall(event.node)) {
140
+ destroyDraft()
141
+ hideCursor()
142
+ return
143
+ }
133
144
  if (event.node.parentId !== getLevelId()) return
134
145
 
135
146
  const side = getSideFromNormal(event.normal)
@@ -143,13 +154,25 @@ export const DoorTool: React.FC = () => {
143
154
  const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
144
155
 
145
156
  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
- })
157
+ if (event.node.id !== draftRef.current.parentId) {
158
+ // Wall changed without enter/leave: must updateNode to reparent
159
+ useScene.getState().updateNode(draftRef.current.id, {
160
+ position: [clampedX, clampedY, 0],
161
+ rotation: [0, itemRotation, 0],
162
+ side,
163
+ parentId: event.node.id,
164
+ wallId: event.node.id,
165
+ })
166
+ } else {
167
+ // Same wall: update Three.js mesh directly to avoid store churn
168
+ const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
169
+ if (draftMesh) {
170
+ draftMesh.position.set(clampedX, clampedY, 0)
171
+ draftMesh.rotation.set(0, itemRotation, 0)
172
+ draftMesh.updateMatrixWorld(true)
173
+ }
174
+ markWallDirty(event.node.id)
175
+ }
153
176
  }
154
177
 
155
178
  const valid = !hasWallChildOverlap(
@@ -178,6 +201,7 @@ export const DoorTool: React.FC = () => {
178
201
  const onWallClick = (event: WallEvent) => {
179
202
  if (!draftRef.current) return
180
203
  if (!isValidWallSideFace(event.normal)) return
204
+ if (isCurvedWall(event.node)) return
181
205
  if (event.node.parentId !== getLevelId()) return
182
206
 
183
207
  const side = getSideFromNormal(event.normal)
@@ -2,6 +2,7 @@ import {
2
2
  type AnyNodeId,
3
3
  DoorNode,
4
4
  emitter,
5
+ isCurvedWall,
5
6
  sceneRegistry,
6
7
  spatialGridManager,
7
8
  useScene,
@@ -98,6 +99,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
98
99
 
99
100
  const onWallEnter = (event: WallEvent) => {
100
101
  if (!isValidWallSideFace(event.normal)) return
102
+ if (isCurvedWall(event.node)) {
103
+ hideCursor()
104
+ return
105
+ }
101
106
  if (event.node.parentId !== getLevelId()) return
102
107
 
103
108
  const side = getSideFromNormal(event.normal)
@@ -151,6 +156,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
151
156
 
152
157
  const onWallMove = (event: WallEvent) => {
153
158
  if (!isValidWallSideFace(event.normal)) return
159
+ if (isCurvedWall(event.node)) {
160
+ hideCursor()
161
+ return
162
+ }
154
163
  if (event.node.parentId !== getLevelId()) return
155
164
 
156
165
  const side = getSideFromNormal(event.normal)
@@ -165,17 +174,26 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
165
174
  movingDoorNode.height,
166
175
  )
167
176
 
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
177
  if (currentWallId !== event.node.id) {
178
+ // Wall changed mid-move: must updateNode to reparent
179
+ useScene.getState().updateNode(movingDoorNode.id, {
180
+ position: [clampedX, clampedY, 0],
181
+ rotation: [0, itemRotation, 0],
182
+ side,
183
+ parentId: event.node.id,
184
+ wallId: event.node.id,
185
+ })
177
186
  markWallDirty(currentWallId)
178
187
  currentWallId = event.node.id
188
+ } else {
189
+ // Same wall: update Three.js mesh directly to avoid store churn
190
+ // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
191
+ const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
192
+ if (doorMesh) {
193
+ doorMesh.position.set(clampedX, clampedY, 0)
194
+ doorMesh.rotation.set(0, itemRotation, 0)
195
+ doorMesh.updateMatrixWorld(true)
196
+ }
179
197
  }
180
198
  markWallDirty(event.node.id)
181
199
 
@@ -204,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
204
222
 
205
223
  const onWallClick = (event: WallEvent) => {
206
224
  if (!isValidWallSideFace(event.normal)) return
225
+ if (isCurvedWall(event.node)) return
207
226
  if (event.node.parentId !== getLevelId()) return
208
227
 
209
228
  const side = getSideFromNormal(event.normal)