@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pascal building editor component",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,8 +11,8 @@
11
11
  "check-types": "tsc --noEmit"
12
12
  },
13
13
  "peerDependencies": {
14
- "@pascal-app/core": "*",
15
- "@pascal-app/viewer": "*",
14
+ "@pascal-app/core": "^0.5.1",
15
+ "@pascal-app/viewer": "^0.5.1",
16
16
  "@react-three/drei": "^10",
17
17
  "@react-three/fiber": "^9",
18
18
  "next": ">=15",
@@ -50,8 +50,8 @@
50
50
  "zustand": "^5.0.11"
51
51
  },
52
52
  "devDependencies": {
53
- "@pascal-app/core": "*",
54
- "@pascal-app/viewer": "*",
53
+ "@pascal-app/core": "^0.5.1",
54
+ "@pascal-app/viewer": "^0.5.1",
55
55
  "@pascal/typescript-config": "*",
56
56
  "@types/howler": "^2.2.12",
57
57
  "@types/react": "19.2.2",
@@ -3,10 +3,13 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ type CeilingNode,
6
7
  DoorNode,
8
+ FenceNode,
7
9
  ItemNode,
8
10
  RoofNode,
9
11
  RoofSegmentNode,
12
+ type SlabNode,
10
13
  StairNode,
11
14
  StairSegmentNode,
12
15
  sceneRegistry,
@@ -31,18 +34,22 @@ const ALLOWED_TYPES = [
31
34
  'stair',
32
35
  'stair-segment',
33
36
  'wall',
37
+ 'fence',
34
38
  'slab',
39
+ 'ceiling',
35
40
  ]
36
- const DELETE_ONLY_TYPES = ['wall', 'slab']
41
+ const DELETE_ONLY_TYPES = ['wall']
42
+ const HOLE_TYPES = ['slab', 'ceiling']
37
43
 
38
44
  export function FloatingActionMenu() {
39
45
  const selectedIds = useViewer((s) => s.selection.selectedIds)
40
46
  const nodes = useScene((s) => s.nodes)
47
+ const updateNode = useScene((s) => s.updateNode)
41
48
  const mode = useEditor((s) => s.mode)
42
- const setMode = useEditor((s) => s.setMode)
43
49
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
44
50
  const setMovingNode = useEditor((s) => s.setMovingNode)
45
51
  const setSelection = useViewer((s) => s.setSelection)
52
+ const setEditingHole = useEditor((s) => s.setEditingHole)
46
53
 
47
54
  const groupRef = useRef<THREE.Group>(null)
48
55
 
@@ -61,8 +68,8 @@ export function FloatingActionMenu() {
61
68
  if (!box.isEmpty()) {
62
69
  const center = box.getCenter(new THREE.Vector3())
63
70
  // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
64
- const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
65
- const yOffset = isDeleteOnly ? 0.8 : 0.3
71
+ const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
72
+ const yOffset = isStructural ? 0.8 : 0.3
66
73
  groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
67
74
  }
68
75
  }
@@ -77,6 +84,7 @@ export function FloatingActionMenu() {
77
84
  node.type === 'item' ||
78
85
  node.type === 'window' ||
79
86
  node.type === 'door' ||
87
+ node.type === 'fence' ||
80
88
  node.type === 'roof' ||
81
89
  node.type === 'roof-segment' ||
82
90
  node.type === 'stair' ||
@@ -108,11 +116,18 @@ export function FloatingActionMenu() {
108
116
  duplicate = WindowNode.parse(duplicateInfo)
109
117
  } else if (node.type === 'item') {
110
118
  duplicate = ItemNode.parse(duplicateInfo)
119
+ } else if (node.type === 'fence') {
120
+ duplicate = FenceNode.parse(duplicateInfo)
121
+ duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
122
+ duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
111
123
  } else if (node.type === 'roof') {
112
124
  duplicate = RoofNode.parse(duplicateInfo)
113
125
  } else if (node.type === 'roof-segment') {
114
126
  duplicate = RoofSegmentNode.parse(duplicateInfo)
115
127
  } else if (node.type === 'stair') {
128
+ duplicateInfo.children = []
129
+ duplicateInfo.metadata = { ...duplicateInfo.metadata }
130
+ delete duplicateInfo.metadata?.isNew
116
131
  duplicate = StairNode.parse(duplicateInfo)
117
132
  } else if (node.type === 'stair-segment') {
118
133
  duplicate = StairSegmentNode.parse(duplicateInfo)
@@ -125,6 +140,8 @@ export function FloatingActionMenu() {
125
140
  if (duplicate) {
126
141
  if (duplicate.type === 'door' || duplicate.type === 'window') {
127
142
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
143
+ } else if (duplicate.type === 'fence') {
144
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
128
145
  } else if (
129
146
  duplicate.type === 'roof' ||
130
147
  duplicate.type === 'roof-segment' ||
@@ -139,7 +156,35 @@ export function FloatingActionMenu() {
139
156
  duplicate.position[2] + 1,
140
157
  ]
141
158
  }
142
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
159
+ if (node.type === 'stair' && duplicate.type === 'stair') {
160
+ const nodesState = useScene.getState().nodes
161
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
162
+ { node: duplicate, parentId: duplicate.parentId as AnyNodeId },
163
+ ]
164
+
165
+ for (const childId of node.children ?? []) {
166
+ const childNode = nodesState[childId]
167
+ if (childNode?.type !== 'stair-segment') {
168
+ continue
169
+ }
170
+
171
+ let childDuplicateInfo = structuredClone(childNode) as any
172
+ delete childDuplicateInfo.id
173
+ childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
174
+ delete childDuplicateInfo.metadata?.isNew
175
+
176
+ try {
177
+ const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
178
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
179
+ } catch (e) {
180
+ console.error('Failed to duplicate stair segment', e)
181
+ }
182
+ }
183
+
184
+ useScene.getState().createNodes(createOps)
185
+ } else {
186
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
187
+ }
143
188
 
144
189
  // Duplicate children for roof nodes
145
190
  if (node.type === 'roof' && node.children) {
@@ -161,49 +206,67 @@ export function FloatingActionMenu() {
161
206
  }
162
207
 
163
208
  // Duplicate children for stair nodes
164
- if (node.type === 'stair' && node.children) {
165
- const nodesState = useScene.getState().nodes
166
- for (const childId of node.children) {
167
- const childNode = nodesState[childId]
168
- if (childNode && childNode.type === 'stair-segment') {
169
- let childDuplicateInfo = structuredClone(childNode) as any
170
- delete childDuplicateInfo.id
171
- childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
172
- try {
173
- const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
174
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
175
- } catch (e) {
176
- console.error('Failed to duplicate stair segment', e)
177
- }
178
- }
179
- }
180
- }
181
209
  }
182
210
  if (
183
211
  duplicate.type === 'item' ||
212
+ duplicate.type === 'fence' ||
184
213
  duplicate.type === 'window' ||
185
214
  duplicate.type === 'door' ||
186
215
  duplicate.type === 'roof' ||
187
216
  duplicate.type === 'roof-segment' ||
188
- duplicate.type === 'stair' ||
189
217
  duplicate.type === 'stair-segment'
190
218
  ) {
191
219
  setMovingNode(duplicate as any)
220
+ } else if (duplicate.type === 'stair') {
221
+ setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
222
+ }
223
+ if (duplicate.type !== 'stair') {
224
+ setSelection({ selectedIds: [] })
192
225
  }
193
- setSelection({ selectedIds: [] })
194
226
  }
195
227
  },
196
228
  [node, setMovingNode, setSelection],
197
229
  )
198
230
 
231
+ const handleAddHole = useCallback(
232
+ (e: React.MouseEvent) => {
233
+ e.stopPropagation()
234
+ if (!(node && selectedId && (node.type === 'slab' || node.type === 'ceiling'))) return
235
+
236
+ const polygon = (node as SlabNode | CeilingNode).polygon
237
+ let cx = 0
238
+ let cz = 0
239
+ for (const [x, z] of polygon) {
240
+ cx += x
241
+ cz += z
242
+ }
243
+ cx /= polygon.length
244
+ cz /= polygon.length
245
+
246
+ const holeSize = 0.5
247
+ const newHole: Array<[number, number]> = [
248
+ [cx - holeSize, cz - holeSize],
249
+ [cx + holeSize, cz - holeSize],
250
+ [cx + holeSize, cz + holeSize],
251
+ [cx - holeSize, cz + holeSize],
252
+ ]
253
+ const currentHoles = (node as SlabNode | CeilingNode).holes || []
254
+ updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] })
255
+ setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
256
+ // Re-assert selection so the node stays selected
257
+ setSelection({ selectedIds: [selectedId] })
258
+ },
259
+ [node, selectedId, updateNode, setEditingHole, setSelection],
260
+ )
261
+
199
262
  const handleDelete = useCallback(
200
263
  (e: React.MouseEvent) => {
201
264
  e.stopPropagation()
202
- // Activate delete mode (sledgehammer tool) instead of deleting directly
265
+ if (!selectedId) return
203
266
  setSelection({ selectedIds: [] })
204
- setMode('delete')
267
+ useScene.getState().deleteNode(selectedId as AnyNodeId)
205
268
  },
206
- [setSelection, setMode],
269
+ [selectedId, setSelection],
207
270
  )
208
271
 
209
272
  if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
@@ -219,9 +282,18 @@ export function FloatingActionMenu() {
219
282
  zIndexRange={[100, 0]}
220
283
  >
221
284
  <NodeActionMenu
285
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
222
286
  onDelete={handleDelete}
223
- onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
224
- onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
287
+ onDuplicate={
288
+ node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
289
+ ? handleDuplicate
290
+ : undefined
291
+ }
292
+ onMove={
293
+ node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
294
+ ? handleMove
295
+ : undefined
296
+ }
225
297
  onPointerDown={(e) => e.stopPropagation()}
226
298
  onPointerUp={(e) => e.stopPropagation()}
227
299
  />
@@ -0,0 +1,69 @@
1
+ 'use client'
2
+
3
+ import { type BuildingNode, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Html } from '@react-three/drei'
6
+ import { useFrame } from '@react-three/fiber'
7
+ import { useCallback, useRef } from 'react'
8
+ import * as THREE from 'three'
9
+ import { sfxEmitter } from '../../lib/sfx-bus'
10
+ import useEditor from '../../store/use-editor'
11
+ import { NodeActionMenu } from './node-action-menu'
12
+
13
+ export function FloatingBuildingActionMenu() {
14
+ const buildingId = useViewer((s) => s.selection.buildingId)
15
+ const levelId = useViewer((s) => s.selection.levelId)
16
+ const setMovingNode = useEditor((s) => s.setMovingNode)
17
+ const setSelection = useViewer((s) => s.setSelection)
18
+ const nodes = useScene((s) => s.nodes)
19
+
20
+ const groupRef = useRef<THREE.Group>(null)
21
+
22
+ useFrame(() => {
23
+ if (!(buildingId && !levelId && groupRef.current)) return
24
+
25
+ const obj = sceneRegistry.nodes.get(buildingId)
26
+ if (obj) {
27
+ const box = new THREE.Box3().setFromObject(obj)
28
+ if (!box.isEmpty()) {
29
+ const center = box.getCenter(new THREE.Vector3())
30
+ groupRef.current.position.set(center.x, 1.5, center.z)
31
+ }
32
+ }
33
+ })
34
+
35
+ const handleMove = useCallback(
36
+ (e: React.MouseEvent) => {
37
+ e.stopPropagation()
38
+ if (!buildingId) return
39
+ const node = nodes[buildingId]
40
+ if (!node || node.type !== 'building') return
41
+ sfxEmitter.emit('sfx:item-pick')
42
+ setMovingNode(node as BuildingNode)
43
+ setSelection({ buildingId: null })
44
+ },
45
+ [buildingId, nodes, setMovingNode, setSelection],
46
+ )
47
+
48
+ // Only show when a building is selected without a level
49
+ if (!buildingId || levelId) return null
50
+
51
+ return (
52
+ <group ref={groupRef}>
53
+ <Html
54
+ center
55
+ style={{
56
+ pointerEvents: 'auto',
57
+ touchAction: 'none',
58
+ }}
59
+ zIndexRange={[100, 0]}
60
+ >
61
+ <NodeActionMenu
62
+ onMove={handleMove}
63
+ onPointerDown={(e) => e.stopPropagation()}
64
+ onPointerUp={(e) => e.stopPropagation()}
65
+ />
66
+ </Html>
67
+ </group>
68
+ )
69
+ }
@@ -4593,6 +4593,12 @@ export function FloorplanPanel() {
4593
4593
  levelNode?.type === 'level' && levelNode.parentId
4594
4594
  ? (levelNode.parentId as BuildingNode['id'])
4595
4595
  : (buildingId as BuildingNode['id'] | null)
4596
+ const buildingRotationY = useScene((state) => {
4597
+ if (!currentBuildingId) return 0
4598
+ const node = state.nodes[currentBuildingId]
4599
+ return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
4600
+ })
4601
+ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
4596
4602
  const site = useScene((state) => {
4597
4603
  for (const rootNodeId of state.rootNodeIds) {
4598
4604
  const node = state.nodes[rootNodeId]
@@ -5664,7 +5670,7 @@ export function FloorplanPanel() {
5664
5670
  }, [fittedViewport, levelId])
5665
5671
 
5666
5672
  useEffect(() => {
5667
- if (!(phase === 'site' && levelNode?.type === 'level' && levelNode.level > 0)) {
5673
+ if (!(phase === 'site' && levelNode?.type === 'level')) {
5668
5674
  return
5669
5675
  }
5670
5676
 
@@ -5963,9 +5969,14 @@ export function FloorplanPanel() {
5963
5969
  return null
5964
5970
  }
5965
5971
 
5972
+ if (buildingRotationY !== 0) {
5973
+ const [unrotX, unrotY] = rotatePlanVector(svgPoint.x, svgPoint.y, buildingRotationY)
5974
+ return toPlanPointFromSvgPoint({ x: unrotX, y: unrotY })
5975
+ }
5976
+
5966
5977
  return toPlanPointFromSvgPoint(svgPoint)
5967
5978
  },
5968
- [getSvgPointFromClientPoint],
5979
+ [getSvgPointFromClientPoint, buildingRotationY],
5969
5980
  )
5970
5981
  useEffect(() => {
5971
5982
  siteBoundaryDraftRef.current = siteBoundaryDraft
@@ -6973,6 +6984,7 @@ export function FloorplanPanel() {
6973
6984
  emitter.emit(`grid:${eventType}` as any, {
6974
6985
  nativeEvent: nativeEvent.nativeEvent as any,
6975
6986
  position: [snappedPoint[0], worldY, snappedPoint[1]],
6987
+ localPosition: [snappedPoint[0], worldY, snappedPoint[1]],
6976
6988
  })
6977
6989
 
6978
6990
  return snappedPoint
@@ -8051,6 +8063,7 @@ export function FloorplanPanel() {
8051
8063
  ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
8052
8064
  isNew: true,
8053
8065
  }
8066
+ cloned.children = []
8054
8067
 
8055
8068
  try {
8056
8069
  const duplicate = ItemNodeSchema.parse(cloned)
@@ -8179,8 +8192,8 @@ export function FloorplanPanel() {
8179
8192
  delete cloned.id
8180
8193
  cloned.metadata = {
8181
8194
  ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
8182
- isNew: true,
8183
8195
  }
8196
+ delete (cloned.metadata as Record<string, unknown>).isNew
8184
8197
 
8185
8198
  const nextPosition =
8186
8199
  Array.isArray(cloned.position) && cloned.position.length >= 3
@@ -8195,9 +8208,11 @@ export function FloorplanPanel() {
8195
8208
 
8196
8209
  try {
8197
8210
  const duplicate = StairNodeSchema.parse(cloned)
8198
- useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId)
8199
-
8200
8211
  const nodesState = useScene.getState().nodes
8212
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
8213
+ { node: duplicate, parentId: stair.parentId as AnyNodeId },
8214
+ ]
8215
+
8201
8216
  for (const childId of stair.children ?? []) {
8202
8217
  const childNode = nodesState[childId]
8203
8218
  if (childNode?.type !== 'stair-segment') {
@@ -8210,19 +8225,20 @@ export function FloorplanPanel() {
8210
8225
  ...(typeof childClone.metadata === 'object' && childClone.metadata !== null
8211
8226
  ? childClone.metadata
8212
8227
  : {}),
8213
- isNew: true,
8214
8228
  }
8229
+ delete (childClone.metadata as Record<string, unknown>).isNew
8215
8230
 
8216
8231
  const childDuplicate = StairSegmentNodeSchema.parse(childClone)
8217
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
8232
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
8218
8233
  }
8219
8234
 
8220
- setMovingNode(duplicate)
8221
- setSelection({ selectedIds: [] })
8235
+ useScene.getState().createNodes(createOps)
8236
+
8237
+ setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
8222
8238
  } catch (error) {
8223
8239
  console.error('Failed to duplicate stair', error)
8224
8240
  }
8225
- }, [selectedStairEntry, setMovingNode, setSelection])
8241
+ }, [selectedStairEntry, setSelection])
8226
8242
  const handleSelectedStairDuplicate = useCallback(
8227
8243
  (event: ReactMouseEvent<HTMLButtonElement>) => {
8228
8244
  event.stopPropagation()
@@ -9296,9 +9312,10 @@ export function FloorplanPanel() {
9296
9312
  y={viewBox.minY}
9297
9313
  />
9298
9314
 
9299
- <FloorplanGridLayer
9300
- majorGridPath={majorGridPath}
9301
- minorGridPath={minorGridPath}
9315
+ <g transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}>
9316
+ <FloorplanGridLayer
9317
+ majorGridPath={majorGridPath}
9318
+ minorGridPath={minorGridPath}
9302
9319
  palette={palette}
9303
9320
  showGrid={showGrid}
9304
9321
  />
@@ -9601,6 +9618,7 @@ export function FloorplanPanel() {
9601
9618
  vectorEffect="non-scaling-stroke"
9602
9619
  />
9603
9620
  )}
9621
+ </g>
9604
9622
  </svg>
9605
9623
  )}
9606
9624
  </div>