@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
@@ -1,12 +1,36 @@
1
- import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type DoorNode,
5
+ getScaledDimensions,
6
+ type ItemNode,
7
+ useScene,
8
+ type WallNode,
9
+ WallNode as WallSchema,
10
+ type WindowNode,
11
+ } from '@pascal-app/core'
2
12
  import { useViewer } from '@pascal-app/viewer'
3
13
  import { sfxEmitter } from '../../../lib/sfx-bus'
14
+ import useEditor from '../../../store/use-editor'
4
15
 
5
16
  export type WallPlanPoint = [number, number]
6
17
 
7
18
  export const WALL_GRID_STEP = 0.5
8
19
  export const WALL_JOIN_SNAP_RADIUS = 0.35
9
20
  export const WALL_MIN_LENGTH = 0.01
21
+ const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
22
+
23
+ const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
24
+ 0.5: Math.PI / 4,
25
+ 0.25: Math.PI / 8,
26
+ 0.1: Math.PI / 12,
27
+ 0.05: Math.PI / 36,
28
+ }
29
+
30
+ type WallSplitIntersection = {
31
+ wallId: WallNode['id']
32
+ point: WallPlanPoint
33
+ }
10
34
 
11
35
  function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
12
36
  const dx = a[0] - b[0]
@@ -14,7 +38,11 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
14
38
  return dx * dx + dz * dz
15
39
  }
16
40
 
17
- function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
41
+ export function getWallGridStep(): number {
42
+ return useEditor.getState().gridSnapStep
43
+ }
44
+
45
+ export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
18
46
  return Math.round(value / step) * step
19
47
  }
20
48
 
@@ -22,17 +50,26 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
22
50
  return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
23
51
  }
24
52
 
25
- export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
53
+ export function snapPointTo45Degrees(
54
+ start: WallPlanPoint,
55
+ cursor: WallPlanPoint,
56
+ step = WALL_GRID_STEP,
57
+ angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
58
+ ): WallPlanPoint {
26
59
  const dx = cursor[0] - start[0]
27
60
  const dz = cursor[1] - start[1]
28
61
  const angle = Math.atan2(dz, dx)
29
- const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
62
+ const snappedAngle = Math.round(angle / angleStep) * angleStep
30
63
  const distance = Math.sqrt(dx * dx + dz * dz)
31
64
 
32
65
  return snapPointToGrid([
33
66
  start[0] + Math.cos(snappedAngle) * distance,
34
67
  start[1] + Math.sin(snappedAngle) * distance,
35
- ])
68
+ ], step)
69
+ }
70
+
71
+ export function getWallAngleSnapStep(step = getWallGridStep()): number {
72
+ return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
36
73
  }
37
74
 
38
75
  function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
@@ -53,6 +90,237 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
53
90
  return [x1 + dx * t, z1 + dz * t]
54
91
  }
55
92
 
93
+ function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
94
+ const { id: _id, parentId: _parentId, children, ...rest } = wall
95
+
96
+ const first = WallSchema.parse({
97
+ ...rest,
98
+ start: wall.start,
99
+ end: splitPoint,
100
+ children: [],
101
+ })
102
+ const second = WallSchema.parse({
103
+ ...rest,
104
+ start: splitPoint,
105
+ end: wall.end,
106
+ children: [],
107
+ })
108
+
109
+ return [first, second]
110
+ }
111
+
112
+ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
113
+ return distanceSquared(a, b) <= tolerance * tolerance
114
+ }
115
+
116
+ function findWallIntersection(
117
+ point: WallPlanPoint,
118
+ walls: WallNode[],
119
+ ignoreWallIds?: string[],
120
+ ): WallSplitIntersection | null {
121
+ const ignore = new Set(ignoreWallIds ?? [])
122
+ let best: WallSplitIntersection | null = null
123
+ let bestDistanceSquared = Number.POSITIVE_INFINITY
124
+
125
+ for (const wall of walls) {
126
+ if (ignore.has(wall.id)) continue
127
+
128
+ const projected = projectPointOntoWall(point, wall)
129
+ if (!projected) continue
130
+
131
+ const candidateDistanceSquared = distanceSquared(point, projected)
132
+ if (
133
+ candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
134
+ candidateDistanceSquared >= bestDistanceSquared
135
+ ) {
136
+ continue
137
+ }
138
+
139
+ best = { wallId: wall.id, point: projected }
140
+ bestDistanceSquared = candidateDistanceSquared
141
+ }
142
+
143
+ return best
144
+ }
145
+
146
+ function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
147
+ if ((wall.children?.length ?? 0) > 0) {
148
+ return true
149
+ }
150
+
151
+ return Object.values(nodes).some((node) => {
152
+ if (!node) return false
153
+ if ('parentId' in node && node.parentId === wall.id) return true
154
+ if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
155
+ return false
156
+ })
157
+ }
158
+
159
+ function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
160
+ return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
161
+ }
162
+
163
+ function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
164
+ if (node.type === 'door') {
165
+ const door = node as DoorNode
166
+ return {
167
+ min: door.position[0] - door.width / 2,
168
+ max: door.position[0] + door.width / 2,
169
+ center: door.position[0],
170
+ }
171
+ }
172
+
173
+ if (node.type === 'window') {
174
+ const win = node as WindowNode
175
+ return {
176
+ min: win.position[0] - win.width / 2,
177
+ max: win.position[0] + win.width / 2,
178
+ center: win.position[0],
179
+ }
180
+ }
181
+
182
+ if (node.type === 'item') {
183
+ const item = node as ItemNode
184
+ if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
185
+ return null
186
+ }
187
+
188
+ const [width] = getScaledDimensions(item)
189
+ return {
190
+ min: item.position[0] - width / 2,
191
+ max: item.position[0] + width / 2,
192
+ center: item.position[0],
193
+ }
194
+ }
195
+
196
+ return null
197
+ }
198
+
199
+ function remapAttachmentToWall(
200
+ node: AnyNode,
201
+ nextWallId: WallNode['id'],
202
+ nextLocalX: number,
203
+ nextWallLength: number,
204
+ ): Partial<AnyNode> | null {
205
+ const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
206
+
207
+ if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
208
+ const currentPosition = 'position' in node ? node.position : null
209
+ if (!currentPosition) return null
210
+
211
+ const nextPosition: typeof currentPosition = [
212
+ clampedX,
213
+ currentPosition[1],
214
+ currentPosition[2],
215
+ ] as typeof currentPosition
216
+
217
+ return {
218
+ parentId: nextWallId,
219
+ position: nextPosition,
220
+ ...(node.type === 'item'
221
+ ? {
222
+ wallId: nextWallId,
223
+ wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
224
+ }
225
+ : {
226
+ wallId: nextWallId,
227
+ }),
228
+ } as Partial<AnyNode>
229
+ }
230
+
231
+ return null
232
+ }
233
+
234
+ function buildAttachmentMigrationPlan(
235
+ wall: WallNode,
236
+ splitPoint: WallPlanPoint,
237
+ firstWall: WallNode,
238
+ secondWall: WallNode,
239
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
240
+ ): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
241
+ const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
242
+ const firstLength = wallLength(firstWall)
243
+ const secondLength = wallLength(secondWall)
244
+ const tolerance = 1e-4
245
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
246
+
247
+ for (const childId of wall.children ?? []) {
248
+ const childNode = nodes[childId as AnyNodeId]
249
+ if (!childNode) continue
250
+
251
+ const span = getWallAttachmentSpan(childNode)
252
+ if (!span) {
253
+ return null
254
+ }
255
+
256
+ if (span.max <= splitDistance + tolerance) {
257
+ const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
258
+ if (!nextUpdate) return null
259
+ updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
260
+ continue
261
+ }
262
+
263
+ if (span.min >= splitDistance - tolerance) {
264
+ const nextUpdate = remapAttachmentToWall(
265
+ childNode,
266
+ secondWall.id,
267
+ span.center - splitDistance,
268
+ secondLength,
269
+ )
270
+ if (!nextUpdate) return null
271
+ updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
272
+ continue
273
+ }
274
+
275
+ return null
276
+ }
277
+
278
+ return updates
279
+ }
280
+
281
+ function splitWallIfNeeded(
282
+ intersection: WallSplitIntersection | null,
283
+ walls: WallNode[],
284
+ nodes: ReturnType<typeof useScene.getState>['nodes'],
285
+ createNodes: ReturnType<typeof useScene.getState>['createNodes'],
286
+ updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
287
+ deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
288
+ ): { walls: WallNode[]; point: WallPlanPoint } | null {
289
+ if (!intersection) return null
290
+
291
+ const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
292
+ if (!wallToSplit) {
293
+ return { walls, point: intersection.point }
294
+ }
295
+
296
+ const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
297
+ const attachmentUpdates = buildAttachmentMigrationPlan(
298
+ wallToSplit,
299
+ intersection.point,
300
+ first,
301
+ second,
302
+ nodes,
303
+ )
304
+
305
+ if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
306
+ return { walls, point: intersection.point }
307
+ }
308
+
309
+ createNodes([
310
+ { node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
311
+ { node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
312
+ ])
313
+ if (attachmentUpdates && attachmentUpdates.length > 0) {
314
+ updateNodes(attachmentUpdates)
315
+ }
316
+ deleteNode(wallToSplit.id as AnyNodeId)
317
+
318
+ return {
319
+ walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
320
+ point: intersection.point,
321
+ }
322
+ }
323
+
56
324
  export function findWallSnapTarget(
57
325
  point: WallPlanPoint,
58
326
  walls: WallNode[],
@@ -102,7 +370,12 @@ export function snapWallDraftPoint(args: {
102
370
  ignoreWallIds?: string[]
103
371
  }): WallPlanPoint {
104
372
  const { point, walls, start, angleSnap = false, ignoreWallIds } = args
105
- const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
373
+ const step = getWallGridStep()
374
+ const angleStep = getWallAngleSnapStep(step)
375
+ const basePoint =
376
+ start && angleSnap
377
+ ? snapPointTo45Degrees(start, point, step, angleStep)
378
+ : snapPointToGrid(point, step)
106
379
 
107
380
  return (
108
381
  findWallSnapTarget(basePoint, walls, {
@@ -120,17 +393,66 @@ export function createWallOnCurrentLevel(
120
393
  end: WallPlanPoint,
121
394
  ): WallNode | null {
122
395
  const currentLevelId = useViewer.getState().selection.levelId
123
- const { createNode, nodes } = useScene.getState()
396
+ const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
397
+ const { updateNodes } = useScene.getState()
124
398
 
125
399
  if (!(currentLevelId && isWallLongEnough(start, end))) {
126
400
  return null
127
401
  }
128
402
 
403
+ let workingWalls = Object.values(nodes).filter(
404
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
405
+ )
406
+
407
+ let resolvedStart = start
408
+ let resolvedEnd = end
409
+
410
+ const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
411
+ const splitEnd = splitWallIfNeeded(
412
+ endIntersection,
413
+ workingWalls,
414
+ nodes,
415
+ createNodes,
416
+ updateNodes,
417
+ deleteNode,
418
+ )
419
+ if (splitEnd) {
420
+ workingWalls = splitEnd.walls
421
+ resolvedEnd = splitEnd.point
422
+ }
423
+
424
+ const startIntersection = findWallIntersection(resolvedStart, workingWalls)
425
+ const splitStart = splitWallIfNeeded(
426
+ startIntersection,
427
+ workingWalls,
428
+ nodes,
429
+ createNodes,
430
+ updateNodes,
431
+ deleteNode,
432
+ )
433
+ if (splitStart) {
434
+ workingWalls = splitStart.walls
435
+ resolvedStart = splitStart.point
436
+ }
437
+
438
+ if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
439
+ return null
440
+ }
441
+
442
+ const duplicateWall = workingWalls.some(
443
+ (wall) =>
444
+ (pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
445
+ (pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
446
+ )
447
+ if (duplicateWall) {
448
+ return null
449
+ }
450
+
129
451
  const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
130
452
  const wall = WallSchema.parse({
131
453
  name: `Wall ${wallCount + 1}`,
132
- start,
133
- end,
454
+ start: resolvedStart,
455
+ end: resolvedEnd,
134
456
  })
135
457
 
136
458
  createNode(wall, currentLevelId)
@@ -78,31 +78,28 @@ export const WallTool: React.FC = () => {
78
78
  let gridPosition: WallPlanPoint = [0, 0]
79
79
  let previousWallEnd: [number, number] | null = null
80
80
 
81
+ // All positions are building-local: this tool is inside the ToolManager building group,
82
+ // so local coords are used for both data and visual positioning.
81
83
  const onGridMove = (event: GridEvent) => {
82
84
  if (!(cursorRef.current && wallPreviewRef.current)) return
83
85
 
84
86
  const walls = getCurrentLevelWalls()
85
- const cursorPoint: WallPlanPoint = [event.position[0], event.position[2]]
86
- gridPosition = snapWallDraftPoint({
87
- point: cursorPoint,
88
- walls,
89
- })
87
+ // event.localPosition is building-local consistent with stored wall start/end
88
+ const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
89
+ gridPosition = snapWallDraftPoint({ point: localPoint, walls })
90
90
 
91
91
  if (buildingState.current === 1) {
92
- const snappedPoint = snapWallDraftPoint({
93
- point: cursorPoint,
92
+ const snappedLocal = snapWallDraftPoint({
93
+ point: localPoint,
94
94
  walls,
95
95
  start: [startingPoint.current.x, startingPoint.current.z],
96
96
  angleSnap: !shiftPressed.current,
97
97
  })
98
- const snapped = new Vector3(snappedPoint[0], event.position[1], snappedPoint[1])
99
- endingPoint.current.copy(snapped)
100
-
101
- // Position the cursor at the end of the wall being drawn
102
- cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
98
+ endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
99
+ cursorRef.current.position.copy(endingPoint.current)
103
100
 
104
101
  // Play snap sound only when the actual wall end position changes
105
- const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
102
+ const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
106
103
  if (
107
104
  previousWallEnd &&
108
105
  (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
@@ -111,43 +108,36 @@ export const WallTool: React.FC = () => {
111
108
  }
112
109
  previousWallEnd = currentWallEnd
113
110
 
114
- // Update wall preview geometry
115
111
  updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
116
112
  } else {
117
113
  // Not drawing a wall yet, show the snapped anchor point.
118
- cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
114
+ cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
119
115
  }
120
116
  }
121
117
 
122
118
  const onGridClick = (event: GridEvent) => {
123
119
  const walls = getCurrentLevelWalls()
124
- const clickPoint: WallPlanPoint = [event.position[0], event.position[2]]
120
+ const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
125
121
 
126
122
  if (buildingState.current === 0) {
127
- const snappedStart = snapWallDraftPoint({
128
- point: clickPoint,
129
- walls,
130
- })
123
+ const snappedStart = snapWallDraftPoint({ point: localClick, walls })
131
124
  gridPosition = snappedStart
132
- startingPoint.current.set(snappedStart[0], event.position[1], snappedStart[1])
125
+ startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
133
126
  endingPoint.current.copy(startingPoint.current)
134
127
  buildingState.current = 1
135
128
  wallPreviewRef.current.visible = true
136
129
  } else if (buildingState.current === 1) {
137
130
  const snappedEnd = snapWallDraftPoint({
138
- point: clickPoint,
131
+ point: localClick,
139
132
  walls,
140
133
  start: [startingPoint.current.x, startingPoint.current.z],
141
134
  angleSnap: !shiftPressed.current,
142
135
  })
143
- endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
144
- const dx = endingPoint.current.x - startingPoint.current.x
145
- const dz = endingPoint.current.z - startingPoint.current.z
136
+ const dx = snappedEnd[0] - startingPoint.current.x
137
+ const dz = snappedEnd[1] - startingPoint.current.z
146
138
  if (dx * dx + dz * dz < 0.01 * 0.01) return
147
- createWallOnCurrentLevel(
148
- [startingPoint.current.x, startingPoint.current.z],
149
- [endingPoint.current.x, endingPoint.current.z],
150
- )
139
+ // Both start and end are building-local ✓
140
+ createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
151
141
  wallPreviewRef.current.visible = false
152
142
  buildingState.current = 0
153
143
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  emitter,
4
+ isCurvedWall,
4
5
  sceneRegistry,
5
6
  spatialGridManager,
6
7
  useScene,
@@ -112,6 +113,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
112
113
 
113
114
  const onWallEnter = (event: WallEvent) => {
114
115
  if (!isValidWallSideFace(event.normal)) return
116
+ if (isCurvedWall(event.node)) {
117
+ hideCursor()
118
+ return
119
+ }
115
120
  // Only interact with walls on the current level
116
121
  if (event.node.parentId !== getLevelId()) return
117
122
 
@@ -168,6 +173,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
168
173
 
169
174
  const onWallMove = (event: WallEvent) => {
170
175
  if (!isValidWallSideFace(event.normal)) return
176
+ if (isCurvedWall(event.node)) {
177
+ hideCursor()
178
+ return
179
+ }
171
180
  // Only interact with walls on the current level
172
181
  if (event.node.parentId !== getLevelId()) return
173
182
 
@@ -185,17 +194,26 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
185
194
  movingWindowNode.height,
186
195
  )
187
196
 
188
- useScene.getState().updateNode(movingWindowNode.id, {
189
- position: [clampedX, clampedY, 0],
190
- rotation: [0, itemRotation, 0],
191
- side,
192
- parentId: event.node.id,
193
- wallId: event.node.id,
194
- })
195
-
196
197
  if (currentWallId !== event.node.id) {
198
+ // Wall changed mid-move: must updateNode to reparent
199
+ useScene.getState().updateNode(movingWindowNode.id, {
200
+ position: [clampedX, clampedY, 0],
201
+ rotation: [0, itemRotation, 0],
202
+ side,
203
+ parentId: event.node.id,
204
+ wallId: event.node.id,
205
+ })
197
206
  markWallDirty(currentWallId)
198
207
  currentWallId = event.node.id
208
+ } else {
209
+ // Same wall: update Three.js mesh directly to avoid store churn
210
+ // collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
211
+ const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
212
+ if (windowMesh) {
213
+ windowMesh.position.set(clampedX, clampedY, 0)
214
+ windowMesh.rotation.set(0, itemRotation, 0)
215
+ windowMesh.updateMatrixWorld(true)
216
+ }
199
217
  }
200
218
  markWallDirty(event.node.id)
201
219
 
@@ -224,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
224
242
 
225
243
  const onWallClick = (event: WallEvent) => {
226
244
  if (!isValidWallSideFace(event.normal)) return
245
+ if (isCurvedWall(event.node)) return
227
246
  // Only interact with walls on the current level
228
247
  if (event.node.parentId !== getLevelId()) return
229
248
 
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
77
77
  const newLeft = clampedX - halfW
78
78
  const newRight = clampedX + halfW
79
79
 
80
- for (const childId of wallNode.children) {
80
+ for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
81
81
  if (childId === ignoreId) continue
82
82
  const child = nodes[childId as AnyNodeId]
83
83
  if (!child) continue
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNodeId,
3
3
  emitter,
4
+ isCurvedWall,
4
5
  sceneRegistry,
5
6
  spatialGridManager,
6
7
  useScene,
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
86
87
 
87
88
  const onWallEnter = (event: WallEvent) => {
88
89
  if (!isValidWallSideFace(event.normal)) return
90
+ if (isCurvedWall(event.node)) {
91
+ destroyDraft()
92
+ hideCursor()
93
+ return
94
+ }
89
95
  const levelId = getLevelId()
90
96
  if (!levelId) return
91
97
  // Only interact with walls on the current level
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
135
141
 
136
142
  const onWallMove = (event: WallEvent) => {
137
143
  if (!isValidWallSideFace(event.normal)) return
144
+ if (isCurvedWall(event.node)) {
145
+ destroyDraft()
146
+ hideCursor()
147
+ return
148
+ }
138
149
  // Only interact with walls on the current level
139
150
  if (event.node.parentId !== getLevelId()) return
140
151
 
@@ -151,13 +162,25 @@ export const WindowTool: React.FC = () => {
151
162
  const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
152
163
 
153
164
  if (draftRef.current) {
154
- useScene.getState().updateNode(draftRef.current.id, {
155
- position: [clampedX, clampedY, 0],
156
- rotation: [0, itemRotation, 0],
157
- side,
158
- parentId: event.node.id,
159
- wallId: event.node.id,
160
- })
165
+ if (event.node.id !== draftRef.current.parentId) {
166
+ // Wall changed without enter/leave: must updateNode to reparent
167
+ useScene.getState().updateNode(draftRef.current.id, {
168
+ position: [clampedX, clampedY, 0],
169
+ rotation: [0, itemRotation, 0],
170
+ side,
171
+ parentId: event.node.id,
172
+ wallId: event.node.id,
173
+ })
174
+ } else {
175
+ // Same wall: update Three.js mesh directly to avoid store churn
176
+ const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
177
+ if (draftMesh) {
178
+ draftMesh.position.set(clampedX, clampedY, 0)
179
+ draftMesh.rotation.set(0, itemRotation, 0)
180
+ draftMesh.updateMatrixWorld(true)
181
+ }
182
+ markWallDirty(event.node.id)
183
+ }
161
184
  }
162
185
 
163
186
  const valid = !hasWallChildOverlap(
@@ -186,6 +209,7 @@ export const WindowTool: React.FC = () => {
186
209
  const onWallClick = (event: WallEvent) => {
187
210
  if (!draftRef.current) return
188
211
  if (!isValidWallSideFace(event.normal)) return
212
+ if (isCurvedWall(event.node)) return
189
213
  // Only interact with walls on the current level
190
214
  if (event.node.parentId !== getLevelId()) return
191
215
 
@@ -174,18 +174,18 @@ export const ZoneTool: React.FC = () => {
174
174
  if (!cursorRef.current) return
175
175
 
176
176
  // Snap to 0.5 grid
177
- const gridX = Math.round(event.position[0] * 2) / 2
178
- const gridZ = Math.round(event.position[2] * 2) / 2
177
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
178
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
179
179
  cursorPosition = [gridX, gridZ]
180
- levelYRef.current = event.position[1]
180
+ levelYRef.current = event.localPosition[1]
181
181
 
182
182
  // If we have points, snap to axis from last point
183
183
  const lastPoint = pointsRef.current[pointsRef.current.length - 1]
184
184
  if (lastPoint) {
185
185
  const snapped = calculateSnapPoint(lastPoint, cursorPosition)
186
- cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
186
+ cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
187
187
  } else {
188
- cursorRef.current.position.set(gridX, event.position[1], gridZ)
188
+ cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
189
189
  }
190
190
 
191
191
  updatePreview()
@@ -194,8 +194,8 @@ export const ZoneTool: React.FC = () => {
194
194
  const onGridClick = (event: GridEvent) => {
195
195
  if (!currentLevelId) return
196
196
 
197
- const gridX = Math.round(event.position[0] * 2) / 2
198
- const gridZ = Math.round(event.position[2] * 2) / 2
197
+ const gridX = Math.round(event.localPosition[0] * 2) / 2
198
+ const gridZ = Math.round(event.localPosition[2] * 2) / 2
199
199
  let clickPoint: [number, number] = [gridX, gridZ]
200
200
 
201
201
  // Snap to axis from last point