@pascal-app/editor 0.5.1 → 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 (79) 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 +255 -34
  4. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  5. package/src/components/editor/floorplan-panel.tsx +1323 -713
  6. package/src/components/editor/index.tsx +2 -0
  7. package/src/components/editor/node-action-menu.tsx +14 -1
  8. package/src/components/editor/selection-manager.tsx +200 -8
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +319 -157
  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/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  15. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  16. package/src/components/tools/door/door-tool.tsx +12 -0
  17. package/src/components/tools/door/move-door-tool.tsx +10 -0
  18. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  19. package/src/components/tools/fence/fence-drafting.ts +19 -7
  20. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  21. package/src/components/tools/fence/move-fence-tool.tsx +8 -0
  22. package/src/components/tools/item/move-tool.tsx +9 -0
  23. package/src/components/tools/item/placement-math.ts +14 -6
  24. package/src/components/tools/item/placement-strategies.ts +2 -2
  25. package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
  26. package/src/components/tools/roof/move-roof-tool.tsx +89 -28
  27. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  28. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  29. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  30. package/src/components/tools/stair/stair-tool.tsx +11 -3
  31. package/src/components/tools/tool-manager.tsx +12 -0
  32. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  33. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  34. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  35. package/src/components/tools/wall/wall-drafting.ts +331 -9
  36. package/src/components/tools/window/move-window-tool.tsx +10 -0
  37. package/src/components/tools/window/window-tool.tsx +12 -0
  38. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  39. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  40. package/src/components/ui/command-palette/index.tsx +0 -1
  41. package/src/components/ui/controls/material-picker.tsx +127 -94
  42. package/src/components/ui/controls/slider-control.tsx +28 -14
  43. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  44. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  45. package/src/components/ui/panels/door-panel.tsx +5 -5
  46. package/src/components/ui/panels/fence-panel.tsx +97 -12
  47. package/src/components/ui/panels/item-panel.tsx +5 -5
  48. package/src/components/ui/panels/panel-manager.tsx +31 -29
  49. package/src/components/ui/panels/reference-panel.tsx +5 -4
  50. package/src/components/ui/panels/roof-panel.tsx +91 -22
  51. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  52. package/src/components/ui/panels/slab-panel.tsx +63 -15
  53. package/src/components/ui/panels/stair-panel.tsx +173 -19
  54. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  55. package/src/components/ui/panels/wall-panel.tsx +159 -11
  56. package/src/components/ui/panels/window-panel.tsx +5 -7
  57. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +7 -3
  58. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  59. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  60. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  61. package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
  62. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  63. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
  64. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  65. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  66. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  67. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  68. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
  69. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  70. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  71. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
  72. package/src/components/ui/viewer-toolbar.tsx +55 -2
  73. package/src/components/viewer-overlay.tsx +25 -19
  74. package/src/hooks/use-contextual-tools.ts +14 -13
  75. package/src/hooks/use-keyboard.ts +3 -2
  76. package/src/index.tsx +2 -1
  77. package/src/lib/history.ts +20 -0
  78. package/src/lib/sfx-player.ts +96 -13
  79. package/src/store/use-editor.tsx +118 -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)
@@ -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
 
@@ -233,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
233
242
 
234
243
  const onWallClick = (event: WallEvent) => {
235
244
  if (!isValidWallSideFace(event.normal)) return
245
+ if (isCurvedWall(event.node)) return
236
246
  // Only interact with walls on the current level
237
247
  if (event.node.parentId !== getLevelId()) return
238
248
 
@@ -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
 
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
198
209
  const onWallClick = (event: WallEvent) => {
199
210
  if (!draftRef.current) return
200
211
  if (!isValidWallSideFace(event.normal)) return
212
+ if (isCurvedWall(event.node)) return
201
213
  // Only interact with walls on the current level
202
214
  if (event.node.parentId !== getLevelId()) return
203
215
 
@@ -90,12 +90,17 @@ export function ControlModes() {
90
90
  const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
91
91
  const levelId = useViewer((s) => s.selection.levelId)
92
92
 
93
- const levelNode = useScene((state) =>
94
- levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
95
- )
93
+ // Only subscribe to the primitive `level` number — when walls are added to
94
+ // this level the object ref changes but this number doesn't, so Object.is
95
+ // dedupes and we avoid a re-render.
96
+ const levelIndex = useScene((state) => {
97
+ if (!levelId) return null
98
+ const node = state.nodes[levelId]
99
+ return node?.type === 'level' ? (node as LevelNode).level : null
100
+ })
96
101
 
97
102
  const isSiteEditing = phase === 'site'
98
- const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
103
+ const isGroundFloor = levelIndex === 0
99
104
  const canEnterSiteEdit = isGroundFloor || isSiteEditing
100
105
 
101
106
  const structureLayer = useEditor((state) => state.structureLayer)
@@ -35,6 +35,7 @@ import {
35
35
  } from 'lucide-react'
36
36
  import { useEffect } from 'react'
37
37
  import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
38
+ import { runRedo, runUndo } from '../../../lib/history'
38
39
  import { useCommandRegistry } from '../../../store/use-command-registry'
39
40
  import type { StructureTool } from '../../../store/use-editor'
40
41
  import useEditor from '../../../store/use-editor'
@@ -44,8 +45,12 @@ export function EditorCommands() {
44
45
  const register = useCommandRegistry((s) => s.register)
45
46
  const { navigateTo, setInputValue, setOpen } = useCommandPalette()
46
47
 
47
- const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
48
- useEditor()
48
+ const setPhase = useEditor((s) => s.setPhase)
49
+ const setMode = useEditor((s) => s.setMode)
50
+ const setTool = useEditor((s) => s.setTool)
51
+ const setStructureLayer = useEditor((s) => s.setStructureLayer)
52
+ const isPreviewMode = useEditor((s) => s.isPreviewMode)
53
+ const setPreviewMode = useEditor((s) => s.setPreviewMode)
49
54
 
50
55
  const exportScene = useViewer((s) => s.exportScene)
51
56
 
@@ -309,7 +314,7 @@ export function EditorCommands() {
309
314
  group: 'History',
310
315
  icon: <Undo2 className="h-4 w-4" />,
311
316
  keywords: ['undo', 'revert', 'back'],
312
- execute: () => run(() => useScene.temporal.getState().undo()),
317
+ execute: () => run(() => runUndo()),
313
318
  },
314
319
  {
315
320
  id: 'editor.history.redo',
@@ -317,7 +322,7 @@ export function EditorCommands() {
317
322
  group: 'History',
318
323
  icon: <Redo2 className="h-4 w-4" />,
319
324
  keywords: ['redo', 'forward', 'repeat'],
320
- execute: () => run(() => useScene.temporal.getState().redo()),
325
+ execute: () => run(() => runRedo()),
321
326
  },
322
327
 
323
328
  // ── Export & Share ───────────────────────────────────────────────────
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
219
219
  const views = usePaletteViewRegistry((s) => s.views)
220
220
 
221
221
  const activeLevelId = useViewer((s) => s.selection.levelId)
222
- const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
223
222
 
224
223
  const wallMode = useViewer((s) => s.wallMode)
225
224
  const setWallMode = useViewer((s) => s.setWallMode)