@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
6
+ import {
7
+ createQuickMeasurementPointerScheduler,
8
+ resolveQuickMeasurementReport,
9
+ } from '../../lib/quick-measurement'
10
+ import {
11
+ activateQuickMeasurementHudSource,
12
+ clearQuickMeasurementHudSource,
13
+ publishQuickMeasurementHudSource,
14
+ } from '../../store/use-quick-measurement-hud'
15
+ import { useFloorplanRender } from './floorplan-render-context'
16
+
17
+ type FloorplanQuickMeasureHit = {
18
+ nodeId: string
19
+ point: { x: number; y: number }
20
+ }
21
+
22
+ function nodeIdFromElement(element: Element | null): string | null {
23
+ const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
24
+ return entry?.dataset.nodeId ?? null
25
+ }
26
+
27
+ function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
28
+ const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
29
+ if (direct) return direct
30
+ for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
31
+ if (!svg.contains(element)) continue
32
+ const nodeId = nodeIdFromElement(element)
33
+ if (nodeId) return nodeId
34
+ }
35
+ return null
36
+ }
37
+
38
+ function pointAtPointer(
39
+ group: SVGGElement,
40
+ event: PointerEvent,
41
+ ): FloorplanQuickMeasureHit['point'] | null {
42
+ const matrix = group.getScreenCTM()
43
+ if (!matrix) return null
44
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
45
+ return { x: point.x, y: point.y }
46
+ }
47
+
48
+ function FloorplanSmartMarker({
49
+ hit,
50
+ pinned,
51
+ unitsPerPixel,
52
+ markerRef,
53
+ }: {
54
+ hit?: FloorplanQuickMeasureHit
55
+ pinned: boolean
56
+ unitsPerPixel: number
57
+ markerRef?: RefObject<SVGGElement | null>
58
+ }) {
59
+ const localRef = useRef<SVGGElement>(null)
60
+ const ref = markerRef ?? localRef
61
+
62
+ useLayoutEffect(() => {
63
+ if (!hit) ref.current?.setAttribute('display', 'none')
64
+ }, [hit, ref])
65
+
66
+ return (
67
+ <g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
68
+ <circle
69
+ cx={0}
70
+ cy={0}
71
+ fill="none"
72
+ r={(pinned ? 11 : 8) * unitsPerPixel}
73
+ stroke={pinned ? '#0e7490' : '#0891b2'}
74
+ strokeWidth={pinned ? 2.5 : 2}
75
+ vectorEffect="non-scaling-stroke"
76
+ />
77
+ {pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
78
+ </g>
79
+ )
80
+ }
81
+
82
+ function showFloorplanSmartMarker(
83
+ marker: SVGGElement | null,
84
+ point: FloorplanQuickMeasureHit['point'],
85
+ ) {
86
+ if (!marker) return
87
+ marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
88
+ marker.removeAttribute('display')
89
+ }
90
+
91
+ function hideFloorplanSmartMarker(marker: SVGGElement | null) {
92
+ marker?.setAttribute('display', 'none')
93
+ }
94
+
95
+ export function FloorplanQuickMeasureLayer() {
96
+ const groupRef = useRef<SVGGElement>(null)
97
+ const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
98
+ const hoverNodeIdRef = useRef<string | null>(null)
99
+ const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
100
+ const candidateHasReportRef = useRef(false)
101
+ const hoverMarkerRef = useRef<SVGGElement>(null)
102
+ const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
103
+ const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
104
+ const nodes = useScene((state) => state.nodes)
105
+ const candidateNodesRef = useRef(nodes)
106
+ const levelId = useViewer((state) => state.selection.levelId)
107
+ const levelRef = useRef(levelId)
108
+ const renderContext = useFloorplanRender()
109
+ const hoverReport = useMemo(
110
+ () => resolveQuickMeasurementReport(hoverNodeId, nodes),
111
+ [hoverNodeId, nodes],
112
+ )
113
+ const pinnedReport = useMemo(
114
+ () => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
115
+ [pinned?.nodeId, nodes],
116
+ )
117
+
118
+ useEffect(() => {
119
+ if (levelRef.current === levelId) return
120
+ levelRef.current = levelId
121
+ hoverRef.current = null
122
+ hoverNodeIdRef.current = null
123
+ candidateNodeIdRef.current = undefined
124
+ candidateHasReportRef.current = false
125
+ hideFloorplanSmartMarker(hoverMarkerRef.current)
126
+ setHoverNodeId(null)
127
+ setPinned(null)
128
+ }, [levelId])
129
+
130
+ useEffect(() => {
131
+ const group = groupRef.current
132
+ const svg = groupRef.current?.ownerSVGElement
133
+ if (!(group && svg)) return
134
+ const updateHover = (next: FloorplanQuickMeasureHit | null) => {
135
+ hoverRef.current = next
136
+ if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
137
+ else hideFloorplanSmartMarker(hoverMarkerRef.current)
138
+ const nextNodeId = next?.nodeId ?? null
139
+ if (nextNodeId === hoverNodeIdRef.current) return
140
+ hoverNodeIdRef.current = nextNodeId
141
+ setHoverNodeId(nextNodeId)
142
+ }
143
+ const processPointerMove = (event: PointerEvent) => {
144
+ activateQuickMeasurementHudSource('2d')
145
+ const candidateNodeId = nodeIdAtPointer(svg, event)
146
+ const sceneNodes = useScene.getState().nodes
147
+ if (candidateNodesRef.current !== sceneNodes) {
148
+ candidateNodesRef.current = sceneNodes
149
+ candidateNodeIdRef.current = undefined
150
+ }
151
+ if (candidateNodeId !== candidateNodeIdRef.current) {
152
+ candidateNodeIdRef.current = candidateNodeId
153
+ candidateHasReportRef.current = Boolean(
154
+ resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
155
+ )
156
+ }
157
+ const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
158
+ updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
159
+ }
160
+ const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
161
+ const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
162
+ const clear = () => {
163
+ pointerScheduler.clear()
164
+ updateHover(null)
165
+ }
166
+ const onClick = (event: MouseEvent) => {
167
+ const next = hoverRef.current
168
+ if (!(next && event.button === 0)) return
169
+ event.preventDefault()
170
+ event.stopImmediatePropagation()
171
+ activateQuickMeasurementHudSource('2d')
172
+ setPinned(next)
173
+ }
174
+
175
+ svg.addEventListener('pointermove', onPointerMove, true)
176
+ svg.addEventListener('pointerleave', clear)
177
+ svg.addEventListener('click', onClick, true)
178
+ return () => {
179
+ svg.removeEventListener('pointermove', onPointerMove, true)
180
+ svg.removeEventListener('pointerleave', clear)
181
+ svg.removeEventListener('click', onClick, true)
182
+ pointerScheduler.clear()
183
+ }
184
+ }, [])
185
+
186
+ const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
187
+ const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
188
+ const report = hoverReport ?? pinnedReport
189
+ const lensState =
190
+ pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
191
+
192
+ useEffect(() => {
193
+ publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
194
+ }, [lensState, report])
195
+
196
+ useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
197
+
198
+ return (
199
+ <g pointerEvents="none" ref={groupRef}>
200
+ {pinned && pinnedReport ? (
201
+ <FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
202
+ ) : null}
203
+ <FloorplanSmartMarker
204
+ markerRef={hoverMarkerRef}
205
+ pinned={false}
206
+ unitsPerPixel={unitsPerPixel}
207
+ />
208
+ </g>
209
+ )
210
+ }
211
+
212
+ export default FloorplanQuickMeasureLayer
@@ -0,0 +1,392 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type CeilingNode,
7
+ createSceneApi,
8
+ getWallMidpointHandlePoint,
9
+ type NodeQuickAction,
10
+ nodeRegistry,
11
+ runAsSingleSceneHistoryStep,
12
+ type SlabNode,
13
+ useLiveNodeOverrides,
14
+ useScene,
15
+ type WallNode,
16
+ } from '@pascal-app/core'
17
+ import { useViewer } from '@pascal-app/viewer'
18
+ import { useEffect, useState } from 'react'
19
+ import { createPortal } from 'react-dom'
20
+ import { useShallow } from 'zustand/react/shallow'
21
+ import {
22
+ createFreshPlacementSubtree,
23
+ duplicatesAsFreshSubtree,
24
+ } from '../../lib/fresh-planar-placement'
25
+ import { sfxEmitter } from '../../lib/sfx-bus'
26
+ import useEditor from '../../store/use-editor'
27
+ import { useMovingNode } from '../../store/use-interaction-scope'
28
+ import { NodeActionMenu } from '../editor/node-action-menu'
29
+ import { IconRefGlyph } from '../ui/icon-ref'
30
+
31
+ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
32
+ return (
33
+ <svg
34
+ aria-hidden="true"
35
+ className="h-3.5 w-3.5"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ strokeWidth="1.7"
41
+ viewBox="0 0 24 24"
42
+ >
43
+ <rect x="8" y="7" width="9" height="10" rx="1.75" />
44
+ <path d="M12.5 7v10" />
45
+ {direction === 'left' ? (
46
+ <>
47
+ <path d="M6.5 12H2.75" />
48
+ <path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
49
+ </>
50
+ ) : (
51
+ <>
52
+ <path d="M17.5 12h3.75" />
53
+ <path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
54
+ </>
55
+ )}
56
+ </svg>
57
+ )
58
+ }
59
+
60
+ // Builtin string tokens map to the generic glyphs above; kind-owned marks
61
+ // arrive as IconRef objects and render through the shared IconRefGlyph.
62
+ // Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
63
+ function QuickActionIcon({ action }: { action: NodeQuickAction }) {
64
+ const icon = action.icon
65
+ if (!icon) return null
66
+ if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
67
+ switch (icon) {
68
+ case 'add-left':
69
+ return <SideAddGlyph direction="left" />
70
+ case 'add-right':
71
+ return <SideAddGlyph direction="right" />
72
+ default:
73
+ return null
74
+ }
75
+ }
76
+
77
+ function collectQuickActionNodes(
78
+ nodes: Record<AnyNodeId, AnyNode>,
79
+ selectedId: string | null,
80
+ ): Record<AnyNodeId, AnyNode> | null {
81
+ if (!selectedId) return null
82
+ const selected = nodes[selectedId as AnyNodeId]
83
+ if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
84
+
85
+ const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
86
+ const add = (id: string | null | undefined) => {
87
+ if (!id) return
88
+ const node = nodes[id as AnyNodeId]
89
+ if (node) collected[node.id as AnyNodeId] = node
90
+ }
91
+ const addChildren = (node: AnyNode | undefined) => {
92
+ for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
93
+ add(childId)
94
+ }
95
+ }
96
+
97
+ add(selected.parentId ?? null)
98
+ addChildren(selected)
99
+ const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
100
+ addChildren(parent)
101
+
102
+ return collected
103
+ }
104
+
105
+ /**
106
+ * Floating Move / Duplicate / Delete buttons that appear above the
107
+ * selected registered kind in the floor plan view.
108
+ *
109
+ * Lives outside the floorplan-panel.tsx monolith. Reads selection from
110
+ * `useViewer`, finds the rendered `[data-node-id]` <g> inside the floor
111
+ * plan scene, polls its bounding rect via rAF while open, and portals
112
+ * an HTML overlay positioned at the top of the bounding box.
113
+ *
114
+ * Buttons:
115
+ * - Move: sets `movingNode` in useEditor. Enabled when the kind has
116
+ * `capabilities.movable`, `def.floorplanMoveTarget`, OR
117
+ * `def.affordanceTools.move` (slab / ceiling). The
118
+ * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
119
+ * Walls are excluded — their move is reached via the side-arrow
120
+ * handles emitted from `def.floorplan`, not via a menu button.
121
+ * - Add hole (slab + ceiling only): inserts a small default-square
122
+ * hole at the polygon centroid via `updateNode`. Mirrors the legacy
123
+ * `handleAddHole` in `floating-action-menu.tsx`.
124
+ * - Duplicate: deep-clones the node, marks it new, sets it as the
125
+ * movingNode (placement cursor) — same UX pattern as 3D duplicate.
126
+ * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
127
+ * `relations.cascadeDelete` if declared on the def.
128
+ *
129
+ * Hidden while in a move state (so we don't show buttons over a ghost).
130
+ */
131
+ export function FloorplanRegistryActionMenu() {
132
+ // Sole selection only — a multi-selection gets the group menu
133
+ // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
134
+ const selectedId = useViewer((s) =>
135
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
136
+ ) as AnyNodeId | undefined
137
+ const movingNode = useMovingNode()
138
+ const setMovingNode = useEditor((s) => s.setMovingNode)
139
+ const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
140
+ // Gate on floorplan hover so this 2D menu never coexists with the 3D
141
+ // FloatingActionMenu in split view — that menu hides while the floorplan
142
+ // is hovered, so this one must only show then. Mirrors the legacy
143
+ // FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
144
+ // duct) shows two Duplicate buttons whenever the pointer is outside the
145
+ // 2D panel.
146
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
147
+
148
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
149
+
150
+ // Only show for registered kinds (skip legacy kinds — they have their
151
+ // own FloorplanActionMenuLayer entries).
152
+ const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
153
+ const def = selectedKind ? nodeRegistry.get(selectedKind) : null
154
+ const isRegistryKind = !!def
155
+ const isVisible =
156
+ isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
157
+ const isWall = selectedKind === 'wall'
158
+ const quickActionNodes = useScene(
159
+ useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
160
+ )
161
+
162
+ useEffect(() => {
163
+ if (!(isVisible && selectedId)) {
164
+ setPosition(null)
165
+ return
166
+ }
167
+ let raf = 0
168
+ const tick = () => {
169
+ raf = requestAnimationFrame(tick)
170
+ const sceneEl = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
171
+ const svgEl = sceneEl?.ownerSVGElement ?? null
172
+ const ctm = sceneEl?.getScreenCTM() ?? null
173
+ if (!(sceneEl && svgEl && ctm)) {
174
+ setPosition(null)
175
+ return
176
+ }
177
+
178
+ // Walls: anchor at the wall midpoint in screen space so the menu
179
+ // sits over the centre of the wall (not the top of its screen-axis
180
+ // bounding box). Menu itself stays horizontal. Read live overrides
181
+ // too so the anchor tracks the wall during side-arrow / endpoint
182
+ // drags. For curved walls `getWallMidpointHandlePoint` returns the
183
+ // apex point on the arc at t=0.5, matching what the renderer draws.
184
+ if (isWall) {
185
+ const sceneNode = useScene.getState().nodes[selectedId] as WallNode | undefined
186
+ if (!sceneNode) {
187
+ setPosition(null)
188
+ return
189
+ }
190
+ const overrides = useLiveNodeOverrides.getState().get(selectedId) as
191
+ | Partial<WallNode>
192
+ | undefined
193
+ const wall = (overrides ? { ...sceneNode, ...overrides } : sceneNode) as WallNode
194
+ const planMid = getWallMidpointHandlePoint(wall)
195
+ const midPt = svgEl.createSVGPoint()
196
+ midPt.x = planMid.x
197
+ midPt.y = planMid.y
198
+ const midScreen = midPt.matrixTransform(ctm)
199
+ setPosition({ left: midScreen.x, top: midScreen.y })
200
+ return
201
+ }
202
+
203
+ const el = sceneEl.querySelector(`[data-node-id="${selectedId}"]`) as SVGGElement | null
204
+ if (el) {
205
+ const rect = el.getBoundingClientRect()
206
+ setPosition({ left: rect.left + rect.width / 2, top: rect.top })
207
+ } else {
208
+ setPosition(null)
209
+ }
210
+ }
211
+ raf = requestAnimationFrame(tick)
212
+ return () => cancelAnimationFrame(raf)
213
+ }, [isVisible, selectedId, isWall])
214
+
215
+ if (!(isVisible && selectedId && position && def)) return null
216
+
217
+ const node = useScene.getState().nodes[selectedId]
218
+ if (!node) return null
219
+
220
+ const quickActions =
221
+ quickActionNodes && nodeRegistry.get(node.type)?.quickActions
222
+ ? (nodeRegistry.get(node.type)?.quickActions?.({
223
+ node: node as never,
224
+ nodes: quickActionNodes,
225
+ }) ?? [])
226
+ : []
227
+
228
+ // Move button is enabled when any of:
229
+ // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
230
+ // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
231
+ // - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling / wall)
232
+ // From the menu's perspective all three are "this kind can move from
233
+ // the floor plan." The `MoveTool` dispatcher resolves the right path —
234
+ // walls land on their bespoke `MoveWallTool` (perpendicular slide
235
+ // with linked-wall cascade) via `affordanceTools.move`.
236
+ const canMove =
237
+ !!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
238
+ const canDuplicate = def.capabilities.duplicable !== false
239
+ const canDelete = def.capabilities.deletable !== false
240
+ const canAddHole = node.type === 'slab' || node.type === 'ceiling'
241
+
242
+ const handleMove = () => {
243
+ sfxEmitter.emit('sfx:item-pick')
244
+ setMovingNode(node as never)
245
+ // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
246
+ // Mark the origin (after `setMovingNode`, which resets it to null) so
247
+ // `ToolManager` keeps the 3D affordance mover from also adopting the node
248
+ // and reverting it on unmount. Mirrors the orange move-dot path.
249
+ setMovingNodeOrigin('2d')
250
+ // Match the legacy 3D `floating-action-menu`: clear selection so
251
+ // selection-gated affordances unmount during the drag. Specifically
252
+ // the slab / ceiling boundary editor (`ToolManager` shows it when
253
+ // `selectedSlabId !== undefined`) would otherwise stay visible
254
+ // and render its vertex / edge handles on top of the moving mesh
255
+ // in split-view 3D. The move overlay reads `movingNode`, not the
256
+ // selection, so clearing it doesn't disturb the move itself; the
257
+ // commit path re-selects the node when it ends.
258
+ useViewer.getState().setSelection({ selectedIds: [] })
259
+ }
260
+
261
+ const handleAddHole = () => {
262
+ if (!canAddHole) return
263
+ const surfaceNode = node as SlabNode | CeilingNode
264
+ const polygon = surfaceNode.polygon
265
+ if (!polygon || polygon.length < 3) return
266
+
267
+ let cx = 0
268
+ let cz = 0
269
+ for (const [x, z] of polygon) {
270
+ cx += x
271
+ cz += z
272
+ }
273
+ cx /= polygon.length
274
+ cz /= polygon.length
275
+
276
+ const holeSize = 0.5
277
+ const newHole: Array<[number, number]> = [
278
+ [cx - holeSize, cz - holeSize],
279
+ [cx + holeSize, cz - holeSize],
280
+ [cx + holeSize, cz + holeSize],
281
+ [cx - holeSize, cz + holeSize],
282
+ ]
283
+ const currentHoles = surfaceNode.holes ?? []
284
+ const currentMetadata = currentHoles.map(
285
+ (_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
286
+ )
287
+ sfxEmitter.emit('sfx:structure-build')
288
+ useScene.getState().updateNode(
289
+ selectedId as AnyNodeId,
290
+ {
291
+ holes: [...currentHoles, newHole],
292
+ holeMetadata: [...currentMetadata, { source: 'manual' as const }],
293
+ } as Partial<AnyNode>,
294
+ )
295
+ }
296
+
297
+ const handleDuplicate = () => {
298
+ if (!node.parentId) return
299
+ sfxEmitter.emit('sfx:item-pick')
300
+ useScene.temporal.getState().pause()
301
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
302
+ const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
303
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
304
+ if (draft) {
305
+ setMovingNode(draft as never)
306
+ setMovingNodeOrigin('2d')
307
+ useScene.temporal.getState().resume()
308
+ return
309
+ }
310
+ useScene.temporal.getState().resume()
311
+ return
312
+ }
313
+ const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
314
+ delete (cloned as { id?: AnyNodeId }).id
315
+ const prevMeta =
316
+ cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
317
+ ? (cloned.metadata as Record<string, unknown>)
318
+ : {}
319
+ // Mark fresh + hand to the placement cursor so the copy follows the
320
+ // pointer and only lands on the next click — same gesture for every
321
+ // kind. Polyline runs (duct / pipe / lineset) ride the same path:
322
+ // `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
323
+ // no longer need the old "offset + drop already-placed" special case.
324
+ cloned.metadata = { ...prevMeta, isNew: true }
325
+ const parsed = def.schema.parse(cloned) as AnyNode
326
+ useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
327
+ setMovingNode(parsed as never)
328
+ useScene.temporal.getState().resume()
329
+ }
330
+
331
+ const handleDelete = () => {
332
+ sfxEmitter.emit('sfx:item-delete')
333
+ useScene.getState().deleteNode(selectedId)
334
+ useViewer.getState().setSelection({ selectedIds: [] })
335
+ }
336
+
337
+ const handleQuickAction = (action: NodeQuickAction) => {
338
+ if (action.disabled) return
339
+ const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
340
+ const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
341
+ if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
342
+ if (result?.selectedIds) {
343
+ const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
344
+ sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
345
+ }
346
+ }
347
+
348
+ return createPortal(
349
+ <div
350
+ className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
351
+ style={{
352
+ left: position.left,
353
+ top: position.top,
354
+ transform: 'translate(-50%, calc(-100% - 32px))',
355
+ }}
356
+ >
357
+ <NodeActionMenu
358
+ onAddHole={canAddHole ? handleAddHole : undefined}
359
+ onDelete={canDelete ? handleDelete : undefined}
360
+ onDuplicate={canDuplicate ? handleDuplicate : undefined}
361
+ onMove={canMove ? handleMove : undefined}
362
+ onPointerDown={(event) => event.stopPropagation()}
363
+ onPointerUp={(event) => event.stopPropagation()}
364
+ />
365
+ {quickActions.length > 0 ? (
366
+ <div
367
+ className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
368
+ onPointerDown={(event) => event.stopPropagation()}
369
+ onPointerUp={(event) => event.stopPropagation()}
370
+ >
371
+ {quickActions.map((action) => (
372
+ <button
373
+ aria-label={action.title ?? action.label}
374
+ className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
375
+ disabled={action.disabled}
376
+ key={action.id}
377
+ onClick={() => handleQuickAction(action)}
378
+ title={action.title ?? action.label}
379
+ type="button"
380
+ >
381
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
382
+ <QuickActionIcon action={action} />
383
+ </span>
384
+ <span className="whitespace-nowrap leading-none">{action.label}</span>
385
+ </button>
386
+ ))}
387
+ </div>
388
+ ) : null}
389
+ </div>,
390
+ document.body,
391
+ )
392
+ }