@pascal-app/editor 0.8.0 → 0.9.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 (223) hide show
  1. package/package.json +8 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +754 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +58 -51
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +201 -83
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +8 -8
  10. package/src/components/editor/first-person-controls.tsx +825 -20
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5829 -12005
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +178 -149
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +433 -156
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +136 -62
  28. package/src/components/editor/thumbnail-generator.tsx +9 -3
  29. package/src/components/editor/use-floorplan-background-placement.ts +119 -32
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  45. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  46. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  47. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  48. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  49. package/src/components/editor-2d/svg-paths.ts +10 -2
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +26 -3
  51. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  52. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  53. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  54. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  55. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  56. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  57. package/src/components/tools/fence/fence-drafting.ts +64 -22
  58. package/src/components/tools/item/move-tool.tsx +41 -105
  59. package/src/components/tools/item/placement-math.test.ts +10 -0
  60. package/src/components/tools/item/placement-math.ts +31 -5
  61. package/src/components/tools/item/placement-strategies.ts +190 -15
  62. package/src/components/tools/item/placement-types.ts +10 -1
  63. package/src/components/tools/item/use-draft-node.ts +20 -1
  64. package/src/components/tools/item/use-placement-coordinator.tsx +694 -191
  65. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  66. package/src/components/tools/roof/roof-tool.tsx +108 -12
  67. package/src/components/tools/select/box-select-state.ts +77 -0
  68. package/src/components/tools/select/box-select-tool.tsx +324 -538
  69. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  70. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  71. package/src/components/tools/select/select-candidates.test.ts +109 -0
  72. package/src/components/tools/select/select-candidates.ts +85 -0
  73. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  74. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  75. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  76. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  77. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  78. package/src/components/tools/shared/placement-box.tsx +123 -0
  79. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  80. package/src/components/tools/shared/segment-angle.ts +56 -0
  81. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  82. package/src/components/tools/site/site-flag-model.ts +18 -0
  83. package/src/components/tools/stair/stair-defaults.ts +1 -1
  84. package/src/components/tools/stair/stair-tool.tsx +269 -35
  85. package/src/components/tools/tool-manager.tsx +172 -56
  86. package/src/components/tools/wall/wall-drafting.ts +98 -98
  87. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  88. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  89. package/src/components/tools/zone/zone-tool.tsx +16 -6
  90. package/src/components/ui/action-menu/action-button.tsx +21 -1
  91. package/src/components/ui/action-menu/control-modes.tsx +5 -82
  92. package/src/components/ui/action-menu/index.tsx +1 -113
  93. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  94. package/src/components/ui/action-menu/view-toggles.tsx +243 -6
  95. package/src/components/ui/command-palette/editor-commands.tsx +18 -13
  96. package/src/components/ui/command-palette/index.tsx +2 -1
  97. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  98. package/src/components/ui/controls/material-picker.tsx +11 -32
  99. package/src/components/ui/controls/metric-control.tsx +33 -11
  100. package/src/components/ui/floating-level-selector.tsx +47 -10
  101. package/src/components/ui/helpers/helper-manager.tsx +16 -17
  102. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  103. package/src/components/ui/item-catalog/catalog-items.tsx +1 -0
  104. package/src/components/ui/item-catalog/item-catalog.tsx +3 -0
  105. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  106. package/src/components/ui/panels/node-display.ts +1 -0
  107. package/src/components/ui/panels/panel-manager.tsx +35 -47
  108. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  109. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  110. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  111. package/src/components/ui/scene-loader.tsx +1 -3
  112. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  113. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  114. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  115. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  116. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  117. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  118. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  119. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  120. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  121. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  122. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  123. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  125. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  126. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  127. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  128. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -36
  133. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  134. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  135. package/src/components/viewer-overlay.tsx +169 -52
  136. package/src/components/viewer-zone-system.tsx +1 -1
  137. package/src/hooks/use-auto-save.ts +10 -3
  138. package/src/hooks/use-ceiling-events.ts +175 -0
  139. package/src/hooks/use-drag-action.ts +124 -0
  140. package/src/hooks/use-keyboard.ts +115 -62
  141. package/src/hooks/use-selection.ts +64 -0
  142. package/src/index.tsx +237 -5
  143. package/src/lib/constants.ts +6 -2
  144. package/src/lib/editor-api.ts +62 -0
  145. package/src/lib/elevator-support.ts +96 -0
  146. package/src/lib/floorplan/apply-alignment.ts +111 -0
  147. package/src/lib/floorplan/index.ts +7 -0
  148. package/src/lib/floorplan/selection-tool.ts +40 -0
  149. package/src/lib/floorplan/stairs.ts +12 -1
  150. package/src/lib/fresh-planar-placement.test.ts +102 -0
  151. package/src/lib/fresh-planar-placement.ts +63 -0
  152. package/src/lib/history.ts +2 -1
  153. package/src/lib/level-duplication.ts +54 -42
  154. package/src/lib/level-name.ts +11 -0
  155. package/src/lib/material-paint.ts +211 -19
  156. package/src/lib/placement-metadata.ts +29 -0
  157. package/src/lib/planar-cursor-placement.test.ts +43 -0
  158. package/src/lib/planar-cursor-placement.ts +42 -0
  159. package/src/lib/roof-duplication.ts +1 -1
  160. package/src/lib/scene-clipboard.ts +267 -0
  161. package/src/lib/scene.ts +13 -4
  162. package/src/lib/sfx-bus.ts +23 -1
  163. package/src/lib/sfx-player.ts +70 -22
  164. package/src/lib/site-boundary.ts +1 -0
  165. package/src/lib/stair-levels.test.ts +146 -0
  166. package/src/lib/stair-levels.ts +177 -0
  167. package/src/lib/world-grid-snap.ts +199 -0
  168. package/src/lib/zone-content.ts +130 -0
  169. package/src/store/use-alignment-guides.ts +21 -0
  170. package/src/store/use-editor.tsx +320 -37
  171. package/src/store/use-placement-preview.ts +31 -0
  172. package/src/store/use-wall-move-ghosts.ts +36 -0
  173. package/src/store/use-wall-snap-indicator.ts +32 -0
  174. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  175. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  176. package/src/components/tools/building/move-building-tool.tsx +0 -157
  177. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  178. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  179. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  180. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  181. package/src/components/tools/column/column-tool.tsx +0 -97
  182. package/src/components/tools/column/move-column-tool.tsx +0 -105
  183. package/src/components/tools/door/door-math.ts +0 -110
  184. package/src/components/tools/door/door-tool.tsx +0 -324
  185. package/src/components/tools/door/move-door-tool.tsx +0 -412
  186. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  187. package/src/components/tools/fence/fence-tool.tsx +0 -346
  188. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  189. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  190. package/src/components/tools/item/item-tool.tsx +0 -26
  191. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  192. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  193. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  194. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  195. package/src/components/tools/slab/slab-tool.tsx +0 -322
  196. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  197. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  198. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  199. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  200. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  201. package/src/components/tools/wall/wall-tool.tsx +0 -332
  202. package/src/components/tools/window/move-window-tool.tsx +0 -447
  203. package/src/components/tools/window/window-math.ts +0 -117
  204. package/src/components/tools/window/window-tool.tsx +0 -332
  205. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  206. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  207. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  208. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  209. package/src/components/ui/panels/column-panel.tsx +0 -759
  210. package/src/components/ui/panels/door-panel.tsx +0 -1299
  211. package/src/components/ui/panels/fence-panel.tsx +0 -222
  212. package/src/components/ui/panels/item-panel.tsx +0 -306
  213. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  214. package/src/components/ui/panels/roof-panel.tsx +0 -280
  215. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  216. package/src/components/ui/panels/slab-panel.tsx +0 -250
  217. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  218. package/src/components/ui/panels/stair-panel.tsx +0 -577
  219. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  220. package/src/components/ui/panels/wall-panel.tsx +0 -273
  221. package/src/components/ui/panels/window-panel.tsx +0 -968
  222. package/src/contexts/presets-context.tsx +0 -121
  223. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,227 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type CeilingNode,
7
+ getWallMidpointHandlePoint,
8
+ nodeRegistry,
9
+ type SlabNode,
10
+ useLiveNodeOverrides,
11
+ useScene,
12
+ type WallNode,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { useEffect, useState } from 'react'
16
+ import { createPortal } from 'react-dom'
17
+ import { sfxEmitter } from '../../lib/sfx-bus'
18
+ import useEditor from '../../store/use-editor'
19
+ import { NodeActionMenu } from '../editor/node-action-menu'
20
+
21
+ /**
22
+ * Floating Move / Duplicate / Delete buttons that appear above the
23
+ * selected registered kind in the floor plan view.
24
+ *
25
+ * Lives outside the floorplan-panel.tsx monolith. Reads selection from
26
+ * `useViewer`, finds the rendered `[data-node-id]` <g> inside the floor
27
+ * plan scene, polls its bounding rect via rAF while open, and portals
28
+ * an HTML overlay positioned at the top of the bounding box.
29
+ *
30
+ * Buttons:
31
+ * - Move: sets `movingNode` in useEditor. Enabled when the kind has
32
+ * `capabilities.movable`, `def.floorplanMoveTarget`, OR
33
+ * `def.affordanceTools.move` (slab / ceiling). The
34
+ * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
35
+ * Walls are excluded — their move is reached via the side-arrow
36
+ * handles emitted from `def.floorplan`, not via a menu button.
37
+ * - Add hole (slab + ceiling only): inserts a small default-square
38
+ * hole at the polygon centroid via `updateNode`. Mirrors the legacy
39
+ * `handleAddHole` in `floating-action-menu.tsx`.
40
+ * - Duplicate: deep-clones the node, marks it new, sets it as the
41
+ * movingNode (placement cursor) — same UX pattern as 3D duplicate.
42
+ * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
43
+ * `relations.cascadeDelete` if declared on the def.
44
+ *
45
+ * Hidden while in a move state (so we don't show buttons over a ghost).
46
+ */
47
+ export function FloorplanRegistryActionMenu() {
48
+ const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
49
+ const movingNode = useEditor((s) => s.movingNode)
50
+ const setMovingNode = useEditor((s) => s.setMovingNode)
51
+
52
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
53
+
54
+ // Only show for registered kinds (skip legacy kinds — they have their
55
+ // own FloorplanActionMenuLayer entries).
56
+ const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
57
+ const def = selectedKind ? nodeRegistry.get(selectedKind) : null
58
+ const isRegistryKind = !!def
59
+ const isVisible = isRegistryKind && !movingNode
60
+ const isWall = selectedKind === 'wall'
61
+
62
+ useEffect(() => {
63
+ if (!(isVisible && selectedId)) {
64
+ setPosition(null)
65
+ return
66
+ }
67
+ let raf = 0
68
+ const tick = () => {
69
+ raf = requestAnimationFrame(tick)
70
+ const sceneEl = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
71
+ const svgEl = sceneEl?.ownerSVGElement ?? null
72
+ const ctm = sceneEl?.getScreenCTM() ?? null
73
+ if (!(sceneEl && svgEl && ctm)) {
74
+ setPosition(null)
75
+ return
76
+ }
77
+
78
+ // Walls: anchor at the wall midpoint in screen space so the menu
79
+ // sits over the centre of the wall (not the top of its screen-axis
80
+ // bounding box). Menu itself stays horizontal. Read live overrides
81
+ // too so the anchor tracks the wall during side-arrow / endpoint
82
+ // drags. For curved walls `getWallMidpointHandlePoint` returns the
83
+ // apex point on the arc at t=0.5, matching what the renderer draws.
84
+ if (isWall) {
85
+ const sceneNode = useScene.getState().nodes[selectedId] as WallNode | undefined
86
+ if (!sceneNode) {
87
+ setPosition(null)
88
+ return
89
+ }
90
+ const overrides = useLiveNodeOverrides.getState().get(selectedId) as
91
+ | Partial<WallNode>
92
+ | undefined
93
+ const wall = (overrides ? { ...sceneNode, ...overrides } : sceneNode) as WallNode
94
+ const planMid = getWallMidpointHandlePoint(wall)
95
+ const midPt = svgEl.createSVGPoint()
96
+ midPt.x = planMid.x
97
+ midPt.y = planMid.y
98
+ const midScreen = midPt.matrixTransform(ctm)
99
+ setPosition({ left: midScreen.x, top: midScreen.y })
100
+ return
101
+ }
102
+
103
+ const el = sceneEl.querySelector(`[data-node-id="${selectedId}"]`) as SVGGElement | null
104
+ if (el) {
105
+ const rect = el.getBoundingClientRect()
106
+ setPosition({ left: rect.left + rect.width / 2, top: rect.top })
107
+ } else {
108
+ setPosition(null)
109
+ }
110
+ }
111
+ raf = requestAnimationFrame(tick)
112
+ return () => cancelAnimationFrame(raf)
113
+ }, [isVisible, selectedId, isWall])
114
+
115
+ if (!(isVisible && selectedId && position && def)) return null
116
+
117
+ const node = useScene.getState().nodes[selectedId]
118
+ if (!node) return null
119
+
120
+ // Move button is enabled when any of:
121
+ // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
122
+ // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
123
+ // - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling / wall)
124
+ // From the menu's perspective all three are "this kind can move from
125
+ // the floor plan." The `MoveTool` dispatcher resolves the right path —
126
+ // walls land on their bespoke `MoveWallTool` (perpendicular slide
127
+ // with linked-wall cascade) via `affordanceTools.move`.
128
+ const canMove =
129
+ !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
130
+ const canDuplicate = def.capabilities.duplicable !== false
131
+ const canDelete = def.capabilities.deletable !== false
132
+ const canAddHole = node.type === 'slab' || node.type === 'ceiling'
133
+
134
+ const handleMove = () => {
135
+ sfxEmitter.emit('sfx:item-pick')
136
+ setMovingNode(node as never)
137
+ // Match the legacy 3D `floating-action-menu`: clear selection so
138
+ // selection-gated affordances unmount during the drag. Specifically
139
+ // the slab / ceiling boundary editor (`ToolManager` shows it when
140
+ // `selectedSlabId !== undefined`) would otherwise stay visible
141
+ // and render its vertex / edge handles on top of the moving mesh
142
+ // in split-view 3D. The move overlay reads `movingNode`, not the
143
+ // selection, so clearing it doesn't disturb the move itself; the
144
+ // commit path re-selects the node when it ends.
145
+ useViewer.getState().setSelection({ selectedIds: [] })
146
+ }
147
+
148
+ const handleAddHole = () => {
149
+ if (!canAddHole) return
150
+ const surfaceNode = node as SlabNode | CeilingNode
151
+ const polygon = surfaceNode.polygon
152
+ if (!polygon || polygon.length < 3) return
153
+
154
+ let cx = 0
155
+ let cz = 0
156
+ for (const [x, z] of polygon) {
157
+ cx += x
158
+ cz += z
159
+ }
160
+ cx /= polygon.length
161
+ cz /= polygon.length
162
+
163
+ const holeSize = 0.5
164
+ const newHole: Array<[number, number]> = [
165
+ [cx - holeSize, cz - holeSize],
166
+ [cx + holeSize, cz - holeSize],
167
+ [cx + holeSize, cz + holeSize],
168
+ [cx - holeSize, cz + holeSize],
169
+ ]
170
+ const currentHoles = surfaceNode.holes ?? []
171
+ const currentMetadata = currentHoles.map(
172
+ (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
173
+ )
174
+ sfxEmitter.emit('sfx:structure-build')
175
+ useScene.getState().updateNode(
176
+ selectedId as AnyNodeId,
177
+ {
178
+ holes: [...currentHoles, newHole],
179
+ holeMetadata: [...currentMetadata, { source: 'manual' as const }],
180
+ } as Partial<AnyNode>,
181
+ )
182
+ }
183
+
184
+ const handleDuplicate = () => {
185
+ if (!node.parentId) return
186
+ sfxEmitter.emit('sfx:item-pick')
187
+ useScene.temporal.getState().pause()
188
+ const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
189
+ delete (cloned as { id?: AnyNodeId }).id
190
+ const prevMeta =
191
+ cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
192
+ ? (cloned.metadata as Record<string, unknown>)
193
+ : {}
194
+ cloned.metadata = { ...prevMeta, isNew: true }
195
+ const parsed = def.schema.parse(cloned) as AnyNode
196
+ useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
197
+ setMovingNode(parsed as never)
198
+ useScene.temporal.getState().resume()
199
+ }
200
+
201
+ const handleDelete = () => {
202
+ sfxEmitter.emit('sfx:item-delete')
203
+ useScene.getState().deleteNode(selectedId)
204
+ useViewer.getState().setSelection({ selectedIds: [] })
205
+ }
206
+
207
+ return createPortal(
208
+ <div
209
+ className="pointer-events-none fixed z-30"
210
+ style={{
211
+ left: position.left,
212
+ top: position.top,
213
+ transform: 'translate(-50%, calc(-100% - 32px))',
214
+ }}
215
+ >
216
+ <NodeActionMenu
217
+ onAddHole={canAddHole ? handleAddHole : undefined}
218
+ onDelete={canDelete ? handleDelete : undefined}
219
+ onDuplicate={canDuplicate ? handleDuplicate : undefined}
220
+ onMove={canMove ? handleMove : undefined}
221
+ onPointerDown={(event) => event.stopPropagation()}
222
+ onPointerUp={(event) => event.stopPropagation()}
223
+ />
224
+ </div>,
225
+ document.body,
226
+ )
227
+ }