@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
@@ -5,16 +5,33 @@ import {
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
7
  ColumnNode,
8
+ createSceneApi,
9
+ DEFAULT_WALL_HEIGHT,
8
10
  DoorNode,
11
+ ElevatorNode,
12
+ emitter,
9
13
  FenceNode,
10
14
  generateId,
15
+ getActiveRoofHeight,
16
+ getEffectiveNode,
17
+ getWallCurveLength,
18
+ getWallThickness,
11
19
  ItemNode,
20
+ isCurvedWall,
21
+ isRegistryMovable,
22
+ isRegistrySelectable,
23
+ isSplineFence,
24
+ type NodeQuickAction,
25
+ nodeRegistry,
12
26
  RoofSegmentNode,
27
+ runAsSingleSceneHistoryStep,
13
28
  type SlabNode,
14
29
  SpawnNode,
15
30
  StairNode,
16
31
  StairSegmentNode,
17
32
  sceneRegistry,
33
+ summarizeSystemFor,
34
+ useLiveNodeOverrides,
18
35
  useScene,
19
36
  WallNode,
20
37
  WindowNode,
@@ -22,19 +39,48 @@ import {
22
39
  import { useViewer } from '@pascal-app/viewer'
23
40
  import { Html } from '@react-three/drei'
24
41
  import { useFrame } from '@react-three/fiber'
25
- import { Move } from 'lucide-react'
26
- import { useCallback, useEffect, useRef, useState } from 'react'
42
+ import { useCallback, useMemo, useRef } from 'react'
27
43
  import * as THREE from 'three'
44
+ import { useShallow } from 'zustand/react/shallow'
45
+ import {
46
+ createFreshPlacementSubtree,
47
+ duplicatesAsFreshSubtree,
48
+ } from '../../lib/fresh-planar-placement'
49
+ import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
50
+ import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
28
51
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
29
- import { sfxEmitter } from '../../lib/sfx-bus'
52
+ import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
30
53
  import { duplicateStairSubtree } from '../../lib/stair-duplication'
31
54
  import useEditor from '../../store/use-editor'
55
+ import useInteractionScope, {
56
+ useActiveHandleDrag,
57
+ useEndpointReshape,
58
+ useIsCurveReshape,
59
+ } from '../../store/use-interaction-scope'
60
+ import { IconRefGlyph } from '../ui/icon-ref'
61
+ import { formatMeasurement, MeasurementPill } from './measurement-pill'
32
62
  import { NodeActionMenu } from './node-action-menu'
33
63
 
64
+ /**
65
+ * A kind shows the system pill when it exposes typed ports — `def.ports`
66
+ * is exactly what makes a node participate in the supply/return graph the
67
+ * pill summarizes. Keeps the menu off a hand-maintained kind list.
68
+ */
69
+ const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
70
+
71
+ /**
72
+ * A kind shows the rotation-axis pill when its R/T keyboard rotation
73
+ * turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
74
+ * duct / pipe fittings with full 3D orientation.
75
+ */
76
+ const hasAxisCycling = (type: string) =>
77
+ nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
78
+
34
79
  const ALLOWED_TYPES = [
35
80
  'item',
36
81
  'door',
37
82
  'window',
83
+ 'elevator',
38
84
  'roof',
39
85
  'roof-segment',
40
86
  'stair',
@@ -49,34 +95,288 @@ const ALLOWED_TYPES = [
49
95
  const DELETE_ONLY_TYPES: string[] = []
50
96
  const HOLE_TYPES = ['slab', 'ceiling']
51
97
 
98
+ // Menu scales with camera zoom so it feels anchored to the object, but is
99
+ // clamped on both ends so it stays readable when zoomed way out and doesn't
100
+ // dominate the screen when zoomed in close. Reference values are picked so
101
+ // scale = 1 lands near the editor's default framing.
102
+ const MIN_MENU_SCALE = 0.5
103
+ // Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
104
+ // only zoom-out shrinks it (down to MIN_MENU_SCALE).
105
+ const MAX_MENU_SCALE = 1
106
+ const REF_ORTHO_ZOOM = 20
107
+ const REF_CAMERA_DISTANCE = 12
108
+
109
+ // World-space Y distance from a node's bbox top to the floating menu anchor.
110
+ // Per-type because in-world chrome above the node (height-resize arrows,
111
+ // measurement labels) varies in vertical reach.
112
+ // `EXTRA_MENU_LIFT` is a uniform global nudge — easier to tune one
113
+ // constant than to bump every per-type entry below.
114
+ const EXTRA_MENU_LIFT = 0.35
115
+ const MENU_Y_OFFSET_DEFAULT = 0.3
116
+ const MENU_Y_OFFSETS: Record<string, number> = {
117
+ wall: 0.5,
118
+ door: 0.6,
119
+ window: 0.6,
120
+ column: 0.6,
121
+ // Fence: still clears the height-resize arrow (sits at fence.height +
122
+ // 0.45) plus the chevron's visual size, but kept low so the menu sits
123
+ // close to the fence rather than floating well above it.
124
+ fence: 0.7,
125
+ // Elevator: clears the cab-height arrow which sits above the SHAFT
126
+ // top (resolved through level entries), so the menu floats above it.
127
+ elevator: 0.9,
128
+ stair: 0.2,
129
+ 'stair-stair': 1.1,
130
+ 'stair-landing': 0.9,
131
+ // Slab: clears the height arrow that sits at elevation + 0.22 plus the
132
+ // chevron's own visual reach, so the menu floats just above it.
133
+ slab: 0.7,
134
+ // Ceiling: clears the upward height arrow that sits ~0.22 above the
135
+ // ceiling plane, plus extra headroom so the menu doesn't crowd the
136
+ // chevron at any zoom level.
137
+ ceiling: 1.0,
138
+ // Shelf: clears the height arrow that sits at shelf.height + 0.22
139
+ // plus the chevron's visual reach.
140
+ shelf: 0.6,
141
+ }
142
+
143
+ function getMenuYOffset(node: AnyNode | null): number {
144
+ if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
145
+ if (node.type === 'stair-segment') {
146
+ return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
147
+ }
148
+ return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
149
+ }
150
+
151
+ function getAttributeVersion(
152
+ attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
153
+ ): number {
154
+ return attribute && 'version' in attribute && typeof attribute.version === 'number'
155
+ ? attribute.version
156
+ : 0
157
+ }
158
+
159
+ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
160
+ return (
161
+ <svg
162
+ aria-hidden="true"
163
+ className="h-3.5 w-3.5"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ strokeWidth="1.7"
169
+ viewBox="0 0 24 24"
170
+ >
171
+ <rect x="8" y="7" width="9" height="10" rx="1.75" />
172
+ <path d="M12.5 7v10" />
173
+ {direction === 'left' ? (
174
+ <>
175
+ <path d="M6.5 12H2.75" />
176
+ <path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
177
+ </>
178
+ ) : (
179
+ <>
180
+ <path d="M17.5 12h3.75" />
181
+ <path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
182
+ </>
183
+ )}
184
+ </svg>
185
+ )
186
+ }
187
+
188
+ // Builtin string tokens map to the generic glyphs above; kind-owned marks
189
+ // arrive as IconRef objects and render through the shared IconRefGlyph.
190
+ function QuickActionIcon({ action }: { action: NodeQuickAction }) {
191
+ const icon = action.icon
192
+ if (!icon) return null
193
+ if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
194
+ switch (icon) {
195
+ case 'add-left':
196
+ return <SideAddGlyph direction="left" />
197
+ case 'add-right':
198
+ return <SideAddGlyph direction="right" />
199
+ default:
200
+ return null
201
+ }
202
+ }
203
+
204
+ function collectQuickActionNodes(
205
+ nodes: Record<AnyNodeId, AnyNode>,
206
+ selectedId: string | null,
207
+ ): Record<AnyNodeId, AnyNode> | null {
208
+ if (!selectedId) return null
209
+ const selected = nodes[selectedId as AnyNodeId]
210
+ if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
211
+
212
+ const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
213
+ const add = (id: string | null | undefined) => {
214
+ if (!id) return
215
+ const node = nodes[id as AnyNodeId]
216
+ if (node) collected[node.id as AnyNodeId] = node
217
+ }
218
+ const addChildren = (node: AnyNode | undefined) => {
219
+ for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
220
+ add(childId)
221
+ }
222
+ }
223
+
224
+ add(selected.parentId ?? null)
225
+ addChildren(selected)
226
+ const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
227
+ addChildren(parent)
228
+
229
+ return collected
230
+ }
231
+
232
+ // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
233
+ // dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
234
+ const _anchorBox = new THREE.Box3()
235
+ const _anchorCenter = new THREE.Vector3()
236
+
237
+ function getObjectGeometryKey(object: THREE.Object3D): string {
238
+ const parts: string[] = []
239
+ object.traverse((child) => {
240
+ const geometry = (child as Partial<THREE.Mesh>).geometry
241
+ if (!geometry) return
242
+
243
+ parts.push(
244
+ [
245
+ geometry.id,
246
+ getAttributeVersion(geometry.getAttribute('position')),
247
+ getAttributeVersion(geometry.getIndex()),
248
+ ].join(':'),
249
+ )
250
+ })
251
+ return parts.join('|')
252
+ }
253
+
254
+ function setNodeDerivedMenuAnchor(
255
+ node: AnyNode,
256
+ object: THREE.Object3D,
257
+ target: THREE.Vector3,
258
+ ): boolean {
259
+ if (node.type !== 'roof-segment') return false
260
+
261
+ const visualTop =
262
+ node.wallHeight +
263
+ getActiveRoofHeight(node) +
264
+ Math.max(0, node.deckThickness ?? 0) +
265
+ Math.max(0, node.shingleThickness ?? 0)
266
+
267
+ target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
268
+ target.y += getMenuYOffset(node)
269
+ return true
270
+ }
271
+
272
+ // Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
273
+ // pill reads sensibly before an explicit height / thickness is set.
274
+ const FENCE_DEFAULT_HEIGHT = 1.8
275
+ const FENCE_DEFAULT_THICKNESS = 0.08
276
+
277
+ // Dimensions for the height-drag pill. Walls and fences both carry
278
+ // start/end/curveOffset, so getWallCurveLength covers length for either.
279
+ function getHeightPillDimensions(node: WallNode | FenceNode): {
280
+ height: number
281
+ length: number
282
+ thickness: number
283
+ } {
284
+ if (node.type === 'wall') {
285
+ return {
286
+ height: node.height ?? DEFAULT_WALL_HEIGHT,
287
+ length: getWallCurveLength(node),
288
+ thickness: getWallThickness(node),
289
+ }
290
+ }
291
+ return {
292
+ height: node.height ?? FENCE_DEFAULT_HEIGHT,
293
+ length: getWallCurveLength(node),
294
+ thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS,
295
+ }
296
+ }
297
+
52
298
  export function FloatingActionMenu() {
53
299
  const selectedIds = useViewer((s) => s.selection.selectedIds)
54
300
  const updateNode = useScene((s) => s.updateNode)
55
301
  const mode = useEditor((s) => s.mode)
56
302
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
57
- const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
58
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
59
- const curvingFence = useEditor((s) => s.curvingFence)
303
+ const canFindNode = useEditor((s) => s.canFindNode)
304
+ const endpointReshape = useEndpointReshape()
305
+ const isCurveReshape = useIsCurveReshape()
60
306
  const setMovingNode = useEditor((s) => s.setMovingNode)
61
- const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
62
- const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
63
- const setCurvingWall = useEditor((s) => s.setCurvingWall)
64
- const setCurvingFence = useEditor((s) => s.setCurvingFence)
65
307
  const setSelection = useViewer((s) => s.setSelection)
66
- const setEditingHole = useEditor((s) => s.setEditingHole)
308
+ const unit = useViewer((s) => s.unit)
309
+ // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
310
+ // flips only at drag start / end, so subscribing here is cheap — the live
311
+ // height value is written imperatively in the useFrame below.
312
+ const activeHandleDrag = useActiveHandleDrag()
313
+ // R/T rotation axis for kinds with full 3D orientation (duct fittings).
314
+ const rotationAxis = useEditor((s) => s.rotationAxis)
315
+ // The floating action menu is an action-conflicting control: hard-hidden
316
+ // during any active interaction so it never competes with the live action.
317
+ const scope = useInteractionScope((s) => s.scope)
318
+ const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
67
319
 
68
320
  const groupRef = useRef<THREE.Group>(null)
69
- const startEndpointGroupRef = useRef<THREE.Group>(null)
70
- const endEndpointGroupRef = useRef<THREE.Group>(null)
71
- const [altPressed, setAltPressed] = useState(false)
321
+ const menuScaleRef = useRef<HTMLDivElement>(null)
322
+ const pillHeightRef = useRef<HTMLSpanElement>(null)
323
+
324
+ // Cached world anchor. The anchor is derived from `Box3.setFromObject`,
325
+ // which traverses the selected object's children — so a node with a
326
+ // continuously-animating child (the spinning turbine-vent head) makes the
327
+ // AABB wobble a few millimetres every frame, and the menu drifts even with
328
+ // a still camera. We instead recompute the box only when the selection,
329
+ // the object's world transform, or its geometry actually changes, and
330
+ // reuse the cached anchor otherwise. (Also removes a per-frame
331
+ // `setFromObject` for every selection.)
332
+ const anchorRef = useRef(new THREE.Vector3())
333
+ const hasAnchorRef = useRef(false)
334
+ const lastMatrixRef = useRef(new THREE.Matrix4())
335
+ const lastAnchorKeyRef = useRef<{
336
+ id: string | null
337
+ node: AnyNode | null
338
+ geometryKey: string | null
339
+ }>({
340
+ id: null,
341
+ node: null,
342
+ geometryKey: null,
343
+ })
72
344
 
73
345
  // Only show for single selection of specific types
74
- const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
346
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
75
347
 
76
348
  // Subscribe just to the selected node so unrelated scene updates do not
77
349
  // re-render this menu.
78
350
  const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
79
- const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
351
+ const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId)))
352
+ const quickActions = useMemo<NodeQuickAction[]>(
353
+ () =>
354
+ node && quickActionNodes
355
+ ? (nodeRegistry.get(node.type)?.quickActions?.({
356
+ node: node as never,
357
+ nodes: quickActionNodes,
358
+ }) ?? [])
359
+ : [],
360
+ [node, quickActionNodes],
361
+ )
362
+ // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
363
+ // NodeDefinition with `capabilities.selectable`) get the floating menu
364
+ // by default too. Phase 4 collapses these into a single registry check.
365
+ const isValidType = node
366
+ ? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
367
+ (ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
368
+ : false
369
+
370
+ // Height-drag pill: shown just above the menu only while the selected
371
+ // wall/fence height arrow is being dragged. Length + thickness are fixed
372
+ // during a height drag, so they're computed here; the live height value
373
+ // is updated imperatively in the useFrame (same pattern as the scale).
374
+ const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null
375
+ const isHeightDragPill =
376
+ pillNode !== null &&
377
+ activeHandleDrag?.nodeId === selectedId &&
378
+ activeHandleDrag?.label === 'height'
379
+ const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
80
380
 
81
381
  // Boolean selector, only re-renders when curving availability actually flips.
82
382
  const canCurveSelectedWall = useScene((s) => {
@@ -95,111 +395,103 @@ export function FloatingActionMenu() {
95
395
  })
96
396
  })
97
397
 
98
- useEffect(() => {
99
- const handleKeyDown = (event: KeyboardEvent) => {
100
- if (event.key === 'Alt') {
101
- setAltPressed(true)
102
- }
103
- }
398
+ useFrame((state) => {
399
+ if (!(selectedId && node && isValidType && groupRef.current)) return
104
400
 
105
- const handleKeyUp = (event: KeyboardEvent) => {
106
- if (event.key === 'Alt') {
107
- setAltPressed(false)
108
- }
401
+ // Scale the HTML menu with camera zoom (ortho) or inverse distance
402
+ // (perspective) so it feels anchored to the world, clamped on both ends
403
+ // so it stays readable at extreme zoom-out and doesn't fill the screen
404
+ // when zoomed in close.
405
+ if (menuScaleRef.current) {
406
+ const raw =
407
+ state.camera instanceof THREE.OrthographicCamera
408
+ ? state.camera.zoom / REF_ORTHO_ZOOM
409
+ : REF_CAMERA_DISTANCE /
410
+ Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
411
+ const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
412
+ menuScaleRef.current.style.transform = `scale(${scale})`
109
413
  }
110
414
 
111
- const handleBlur = () => {
112
- setAltPressed(false)
415
+ // Live height readout for the drag pill. The dragged height lands in
416
+ // `useLiveNodeOverrides` (not the scene store) each frame, so read it
417
+ // imperatively here instead of forcing a per-frame React re-render.
418
+ if (
419
+ pillHeightRef.current &&
420
+ (node?.type === 'wall' || node?.type === 'fence') &&
421
+ activeHandleDrag?.nodeId === selectedId &&
422
+ activeHandleDrag?.label === 'height'
423
+ ) {
424
+ const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
425
+ | { height?: number }
426
+ | undefined
427
+ const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
428
+ const liveHeight = override?.height ?? node.height ?? fallbackHeight
429
+ pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
113
430
  }
114
431
 
115
- window.addEventListener('keydown', handleKeyDown)
116
- window.addEventListener('keyup', handleKeyUp)
117
- window.addEventListener('blur', handleBlur)
118
-
119
- return () => {
120
- window.removeEventListener('keydown', handleKeyDown)
121
- window.removeEventListener('keyup', handleKeyUp)
122
- window.removeEventListener('blur', handleBlur)
123
- }
124
- }, [])
125
-
126
- useFrame(() => {
127
- if (!(selectedId && isValidType && groupRef.current)) return
128
-
129
432
  const obj = sceneRegistry.nodes.get(selectedId)
130
433
  if (obj) {
131
- // Calculate bounding box in world space
132
- const box = new THREE.Box3().setFromObject(obj)
133
- if (!box.isEmpty()) {
134
- const center = box.getCenter(new THREE.Vector3())
135
- // Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
136
- const isStructural = node && [...DELETE_ONLY_TYPES, ...HOLE_TYPES].includes(node.type)
137
- const yOffset = isStructural ? 0.8 : 0.3
138
- groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
434
+ obj.updateWorldMatrix(true, false)
435
+
436
+ // Recompute the anchor only when the object genuinely changes —
437
+ // reselected, moved (its own world matrix changed), or resized
438
+ // (a fresh store node on commit, or a live override / handle drag
439
+ // mid-resize). A spinning child changes the head's matrix, not the
440
+ // registered group's, so it never triggers a recompute → the menu
441
+ // holds still.
442
+ // Cheapest guards first: a selection swap, the object's own world
443
+ // transform changing (true every frame during a drag), a live override,
444
+ // or an active handle drag all force a recompute on their own — so skip
445
+ // the geometry traversal (`getObjectGeometryKey` walks the whole subtree
446
+ // reading attribute versions) until none of them fired and a
447
+ // geometry-only change is the only thing left that could move the anchor.
448
+ const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
449
+ const dragActive = activeHandleDrag?.nodeId === selectedId
450
+ const selectionChanged =
451
+ lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
452
+ const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
453
+
454
+ let geometryKey = lastAnchorKeyRef.current.geometryKey
455
+ let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
456
+ // Only when nothing cheaper fired do we pay for the subtree traversal —
457
+ // a geometry-only change is the lone remaining trigger. When a cheaper
458
+ // guard already forced a recompute the stored key is reused; the matrix
459
+ // (or override/drag) keeps recomputing the anchor every frame, so a
460
+ // geometry edit mid-drag is absorbed, and the next idle frame refreshes
461
+ // the key against the live geometry.
462
+ if (!needsRecompute) {
463
+ geometryKey = getObjectGeometryKey(obj)
464
+ if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
139
465
  }
140
466
 
141
- if (node?.type === 'wall' || node?.type === 'fence') {
142
- const segment = node as WallNode | FenceNode
143
- const endpointYOffset = 0.35
144
- const startWorld =
145
- node.type === 'wall'
146
- ? obj.localToWorld(new THREE.Vector3(0, 0, 0))
147
- : obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
148
- const endWorld =
149
- node.type === 'wall'
150
- ? obj.localToWorld(
151
- new THREE.Vector3(
152
- Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]),
153
- 0,
154
- 0,
155
- ),
156
- )
157
- : obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
158
-
159
- if (startEndpointGroupRef.current) {
160
- startEndpointGroupRef.current.position.set(
161
- startWorld.x,
162
- startWorld.y + endpointYOffset,
163
- startWorld.z,
164
- )
165
- }
166
- if (endEndpointGroupRef.current) {
167
- endEndpointGroupRef.current.position.set(
168
- endWorld.x,
169
- endWorld.y + endpointYOffset,
170
- endWorld.z,
171
- )
467
+ if (needsRecompute) {
468
+ const effectiveNode = getEffectiveNode(node)
469
+ if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
470
+ _anchorBox.setFromObject(obj)
471
+ if (!_anchorBox.isEmpty()) {
472
+ _anchorBox.getCenter(_anchorCenter)
473
+ // Position above the object. Per-type offsets clear each kind's
474
+ // in-world chrome (height-resize arrows, measurement labels).
475
+ anchorRef.current.set(
476
+ _anchorCenter.x,
477
+ _anchorBox.max.y + getMenuYOffset(effectiveNode),
478
+ _anchorCenter.z,
479
+ )
480
+ hasAnchorRef.current = true
481
+ }
482
+ } else {
483
+ hasAnchorRef.current = true
172
484
  }
485
+ lastMatrixRef.current.copy(obj.matrixWorld)
486
+ lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
487
+ }
488
+
489
+ if (hasAnchorRef.current) {
490
+ groupRef.current.position.copy(anchorRef.current)
173
491
  }
174
492
  }
175
493
  })
176
494
 
177
- const handleMove = useCallback(
178
- (e: React.MouseEvent) => {
179
- e.stopPropagation()
180
- if (!node) return
181
- sfxEmitter.emit('sfx:item-pick')
182
- if (
183
- node.type === 'item' ||
184
- node.type === 'window' ||
185
- node.type === 'door' ||
186
- node.type === 'wall' ||
187
- node.type === 'fence' ||
188
- node.type === 'column' ||
189
- node.type === 'slab' ||
190
- node.type === 'ceiling' ||
191
- node.type === 'spawn' ||
192
- node.type === 'roof' ||
193
- node.type === 'roof-segment' ||
194
- node.type === 'stair' ||
195
- node.type === 'stair-segment'
196
- ) {
197
- setMovingNode(node as any)
198
- }
199
- setSelection({ selectedIds: [] })
200
- },
201
- [node, setMovingNode, setSelection],
202
- )
203
495
  const handleCurve = useCallback(
204
496
  (e: React.MouseEvent) => {
205
497
  e.stopPropagation()
@@ -207,33 +499,26 @@ export function FloatingActionMenu() {
207
499
  sfxEmitter.emit('sfx:item-pick')
208
500
  if (node.type === 'wall') {
209
501
  if (!canCurveSelectedWall) return
210
- setCurvingWall(node)
502
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
211
503
  } else if (node.type === 'fence') {
212
- setCurvingFence(node)
504
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
213
505
  } else {
214
506
  return
215
507
  }
216
508
  setSelection({ selectedIds: [] })
217
509
  },
218
- [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
510
+ [canCurveSelectedWall, node, setSelection],
219
511
  )
220
- const handleEndpointMove = useCallback(
221
- (endpoint: 'start' | 'end', e: React.MouseEvent) => {
512
+ const handleMove = useCallback(
513
+ (e: React.MouseEvent) => {
222
514
  e.stopPropagation()
223
515
  if (!node) return
224
516
  sfxEmitter.emit('sfx:item-pick')
225
- if (node.type === 'wall') {
226
- setMovingWallEndpoint({ wall: node, endpoint })
227
- } else if (node.type === 'fence') {
228
- setMovingFenceEndpoint({ fence: node, endpoint })
229
- } else {
230
- return
231
- }
517
+ setMovingNode(node as any)
232
518
  setSelection({ selectedIds: [] })
233
519
  },
234
- [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
520
+ [node, setMovingNode, setSelection],
235
521
  )
236
-
237
522
  const handleDuplicate = useCallback(
238
523
  (e: React.MouseEvent) => {
239
524
  e.stopPropagation()
@@ -251,6 +536,18 @@ export function FloatingActionMenu() {
251
536
 
252
537
  useScene.temporal.getState().pause()
253
538
 
539
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
540
+ const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
541
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
542
+ if (draft) {
543
+ setMovingNode(draft as any)
544
+ setSelection({ selectedIds: [] })
545
+ return
546
+ }
547
+ useScene.temporal.getState().resume()
548
+ return
549
+ }
550
+
254
551
  let duplicateInfo = structuredClone(node) as any
255
552
  delete duplicateInfo.id
256
553
  duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
@@ -263,6 +560,8 @@ export function FloatingActionMenu() {
263
560
  duplicate = WindowNode.parse(duplicateInfo)
264
561
  } else if (node.type === 'item') {
265
562
  duplicate = ItemNode.parse(duplicateInfo)
563
+ } else if (node.type === 'elevator') {
564
+ duplicate = ElevatorNode.parse(duplicateInfo)
266
565
  } else if (node.type === 'column') {
267
566
  duplicate = ColumnNode.parse(duplicateInfo)
268
567
  } else if (node.type === 'wall') {
@@ -284,6 +583,16 @@ export function FloatingActionMenu() {
284
583
  } else if (node.type === 'spawn') {
285
584
  duplicate = SpawnNode.parse(duplicateInfo)
286
585
  }
586
+
587
+ // Registry-driven fallback: any kind with a NodeDefinition can be
588
+ // duplicated through its schema's parse(). Future built-in kinds
589
+ // get duplicate for free.
590
+ if (!duplicate) {
591
+ const def = nodeRegistry.get(node.type)
592
+ if (def) {
593
+ duplicate = def.schema.parse(duplicateInfo) as AnyNode
594
+ }
595
+ }
287
596
  } catch (error) {
288
597
  console.error('Failed to parse duplicate', error)
289
598
  useScene.temporal.getState().resume()
@@ -296,7 +605,11 @@ export function FloatingActionMenu() {
296
605
  }
297
606
 
298
607
  if (duplicate) {
299
- if (duplicate.type === 'door' || duplicate.type === 'window') {
608
+ if (
609
+ duplicate.type === 'door' ||
610
+ duplicate.type === 'window' ||
611
+ duplicate.type === 'elevator'
612
+ ) {
300
613
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
301
614
  } else if (duplicate.type === 'wall') {
302
615
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
@@ -322,9 +635,61 @@ export function FloatingActionMenu() {
322
635
  }
323
636
 
324
637
  // Duplicate children for stair nodes
638
+ } else if (
639
+ duplicate.type === 'item' ||
640
+ duplicate.type === 'chimney' ||
641
+ duplicate.type === 'dormer'
642
+ ) {
643
+ // Items, chimneys & dormers use pure drag-to-place: NO node is
644
+ // inserted into the scene until the user clicks to commit. The
645
+ // `setMovingNode` call below hands the clone (with
646
+ // `metadata.isNew = true` + no id) to its move tool —
647
+ // `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which
648
+ // create a draft and call `createNode` on the commit click.
649
+ // Pre-creating here would drop a second copy into the scene
650
+ // before any click — the furnish-tab "duplicate auto-places an
651
+ // item without clicking" bug. (Item has its own
652
+ // draft-committing move tool, so it must skip the generic
653
+ // registry auto-create branch below.)
654
+ } else if (
655
+ duplicate.type === 'duct-segment' ||
656
+ duplicate.type === 'duct-fitting' ||
657
+ duplicate.type === 'pipe-segment' ||
658
+ duplicate.type === 'lineset' ||
659
+ duplicate.type === 'liquid-line'
660
+ ) {
661
+ // Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
662
+ // pure drag-to-place: NO node is inserted into the scene until the
663
+ // commit click. `setMovingNode` below hands the clone (with
664
+ // `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
665
+ // `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
666
+ // which previews a translucent copy inside a footprint bounding box
667
+ // on the cursor and calls `createNode` on the drop click.
668
+ // Pre-creating here would drop a copy before any click — the
669
+ // "auto-places it" bug.
670
+ } else if (nodeRegistry.has(duplicate.type)) {
671
+ // Registry-driven kinds: offset slightly so the duplicate doesn't
672
+ // overlap exactly, then create + hand to the move tool. Mirrors the
673
+ // roof-segment / stair-segment behavior.
674
+ if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
675
+ const pos = (duplicate as { position: [number, number, number] }).position
676
+ ;(duplicate as { position: [number, number, number] }).position = [
677
+ pos[0] + 1,
678
+ pos[1],
679
+ pos[2] + 1,
680
+ ]
681
+ } else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
682
+ // Other polyline kinds (pipe / lineset) carry a `path`, not a
683
+ // `position`. Create the copy HIDDEN so nothing is auto-placed:
684
+ // their shared path mover reveals it as a cursor-following
685
+ // preview on the first mouse move and commits on the next click.
686
+ ;(duplicate as { visible?: boolean }).visible = false
687
+ }
688
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
325
689
  }
326
690
  if (
327
691
  duplicate.type === 'item' ||
692
+ duplicate.type === 'elevator' ||
328
693
  duplicate.type === 'column' ||
329
694
  duplicate.type === 'wall' ||
330
695
  duplicate.type === 'fence' ||
@@ -332,7 +697,10 @@ export function FloatingActionMenu() {
332
697
  duplicate.type === 'door' ||
333
698
  duplicate.type === 'roof-segment' ||
334
699
  duplicate.type === 'spawn' ||
335
- duplicate.type === 'stair-segment'
700
+ duplicate.type === 'stair-segment' ||
701
+ // Registry-driven kinds get picked up by MoveTool's generic
702
+ // fallback (MoveRegistryNodeTool) so the user can reposition.
703
+ nodeRegistry.has(duplicate.type)
336
704
  ) {
337
705
  setMovingNode(duplicate as any)
338
706
  } else if (duplicate.type === 'stair') {
@@ -377,33 +745,57 @@ export function FloatingActionMenu() {
377
745
  holes: [...currentHoles, newHole],
378
746
  holeMetadata: [...currentMetadata, { source: 'manual' }],
379
747
  })
380
- setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
748
+ useInteractionScope
749
+ .getState()
750
+ .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
381
751
  // Re-assert selection so the node stays selected
382
752
  setSelection({ selectedIds: [selectedId] })
383
753
  },
384
- [node, selectedId, updateNode, setEditingHole, setSelection],
754
+ [node, selectedId, updateNode, setSelection],
385
755
  )
386
756
 
387
757
  const handleDelete = useCallback(
388
758
  (e: React.MouseEvent) => {
389
759
  e.stopPropagation()
390
760
  if (!selectedId) return
391
- if (node?.type === 'item') {
392
- sfxEmitter.emit('sfx:item-delete')
393
- } else {
394
- sfxEmitter.emit('sfx:structure-delete')
395
- }
761
+ emitDeleteSFX(node?.type)
396
762
  setSelection({ selectedIds: [] })
397
763
  useScene.getState().deleteNode(selectedId as AnyNodeId)
398
764
  },
399
765
  [node?.type, selectedId, setSelection],
400
766
  )
401
767
 
768
+ // "Find in catalog": the editor only signals intent — the host (community)
769
+ // listens for `selection:find-node` and reveals the node in its browser.
770
+ const handleFind = useCallback(
771
+ (e: React.MouseEvent) => {
772
+ e.stopPropagation()
773
+ if (node) emitter.emit('selection:find-node', node)
774
+ },
775
+ [node],
776
+ )
777
+
778
+ const handleQuickAction = useCallback(
779
+ (action: NodeQuickAction) => (e: React.MouseEvent) => {
780
+ e.stopPropagation()
781
+ if (!node || action.disabled) return
782
+ const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
783
+ const result =
784
+ action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
785
+ if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds })
786
+ if (result?.selectedIds) {
787
+ const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
788
+ sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
789
+ }
790
+ },
791
+ [node, setSelection],
792
+ )
793
+
402
794
  if (
403
795
  !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
404
- movingWallEndpoint ||
405
- movingFenceEndpoint ||
406
- curvingFence
796
+ endpointReshape ||
797
+ isCurveReshape ||
798
+ menuStepBack
407
799
  )
408
800
  return null
409
801
 
@@ -416,86 +808,162 @@ export function FloatingActionMenu() {
416
808
  pointerEvents: 'auto',
417
809
  touchAction: 'none',
418
810
  }}
419
- zIndexRange={[100, 0]}
811
+ zIndexRange={[25, 0]}
420
812
  >
421
- <NodeActionMenu
422
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
423
- onCurve={
424
- node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
425
- ? handleCurve
426
- : undefined
427
- }
428
- onDelete={handleDelete}
429
- onDuplicate={
430
- node &&
431
- node.type !== 'spawn' &&
432
- !DELETE_ONLY_TYPES.includes(node.type) &&
433
- !HOLE_TYPES.includes(node.type)
434
- ? handleDuplicate
435
- : undefined
436
- }
437
- onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
438
- onPointerDown={(e) => e.stopPropagation()}
439
- onPointerUp={(e) => e.stopPropagation()}
440
- />
813
+ <div
814
+ className="relative flex flex-col items-center"
815
+ ref={menuScaleRef}
816
+ style={{ transformOrigin: 'center center' }}
817
+ >
818
+ <NodeActionMenu
819
+ onFind={node && canFindNode ? handleFind : undefined}
820
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
821
+ onCurve={
822
+ (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
823
+ (node?.type === 'wall' && canCurveSelectedWall)
824
+ ? handleCurve
825
+ : undefined
826
+ }
827
+ onMove={
828
+ // Fully registry-driven: any kind that declares
829
+ // `capabilities.movable`, a `floorplanMoveTarget`, or a
830
+ // 3D `affordanceTools.move` mover gets the Move button.
831
+ // Adding a new movable kind never touches this file.
832
+ node && isRegistryMovable(node.type) ? handleMove : undefined
833
+ }
834
+ onDelete={handleDelete}
835
+ onDuplicate={
836
+ node &&
837
+ node.type !== 'spawn' &&
838
+ !DELETE_ONLY_TYPES.includes(node.type) &&
839
+ !HOLE_TYPES.includes(node.type)
840
+ ? handleDuplicate
841
+ : undefined
842
+ }
843
+ onPointerDown={(e) => e.stopPropagation()}
844
+ onPointerUp={(e) => e.stopPropagation()}
845
+ />
846
+ {quickActions.length > 0 ? (
847
+ <div
848
+ 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"
849
+ onPointerDown={(e) => e.stopPropagation()}
850
+ onPointerUp={(e) => e.stopPropagation()}
851
+ >
852
+ {quickActions.map((action) => (
853
+ <button
854
+ aria-label={action.title ?? action.label}
855
+ 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"
856
+ disabled={action.disabled}
857
+ key={action.id}
858
+ onClick={handleQuickAction(action)}
859
+ title={action.title ?? action.label}
860
+ type="button"
861
+ >
862
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
863
+ <QuickActionIcon action={action} />
864
+ </span>
865
+ <span className="whitespace-nowrap leading-none">{action.label}</span>
866
+ </button>
867
+ ))}
868
+ </div>
869
+ ) : null}
870
+ {/* Height-drag dimension pill. Absolutely positioned just above
871
+ the menu (away from the height arrow below it) so it rides the
872
+ same scale transform + anchor, never overlaps the menu, and
873
+ needs no menu lift — which is what caused the click flicker.
874
+ Non-interactive. */}
875
+ {isHeightDragPill && pillDims ? (
876
+ <div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2">
877
+ <MeasurementPill
878
+ height={pillDims.height}
879
+ length={pillDims.length}
880
+ primary="height"
881
+ ref={pillHeightRef}
882
+ thickness={pillDims.thickness}
883
+ unit={unit}
884
+ />
885
+ </div>
886
+ ) : null}
887
+ {/* HVAC chrome above the menu — same slot as the wall height
888
+ pill. System pill (which tree, run length, equipment reach)
889
+ for every distribution kind; the rotation-axis pill stacks
890
+ under it for duct fittings. */}
891
+ {node && hasPorts(node.type) ? (
892
+ <div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
893
+ <SystemSummaryPill nodeId={node.id} unit={unit} />
894
+ {hasAxisCycling(node.type) ? (
895
+ <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
896
+ <span className="font-medium text-foreground">
897
+ Axis {rotationAxis.toUpperCase()}
898
+ </span>
899
+ <span aria-hidden className="text-muted-foreground">
900
+ ·
901
+ </span>
902
+ <span className="text-muted-foreground">R/T rotate</span>
903
+ <span aria-hidden className="text-muted-foreground">
904
+ ·
905
+ </span>
906
+ <span className="text-muted-foreground">⌥ axis</span>
907
+ </div>
908
+ ) : null}
909
+ </div>
910
+ ) : null}
911
+ </div>
441
912
  </Html>
442
913
  </group>
443
- {(node?.type === 'wall' || node?.type === 'fence') && (
914
+ </group>
915
+ )
916
+ }
917
+
918
+ /**
919
+ * System summary pill for a selected distribution kind (HVAC duct / DWV
920
+ * pipe / refrigerant lineset): which supply/return tree it belongs to, its
921
+ * run length, and whether it actually reaches a piece of equipment.
922
+ *
923
+ * Mounted only while an HVAC node is selected, so the full-`nodes`
924
+ * subscription it needs (connectivity changes when ANY joint moves) doesn't
925
+ * re-render the always-mounted parent menu on every unrelated scene tick.
926
+ */
927
+ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
928
+ const allNodes = useScene((s) => s.nodes)
929
+ const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
930
+ if (!summary) return null
931
+ return (
932
+ <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
933
+ <span className="font-medium text-foreground">
934
+ {summary.systems.length > 0
935
+ ? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
936
+ : 'System'}
937
+ </span>
938
+ {summary.runCount > 0 ? (
444
939
  <>
445
- <group ref={startEndpointGroupRef}>
446
- <Html
447
- center
448
- style={{ pointerEvents: 'auto', touchAction: 'none' }}
449
- zIndexRange={[100, 0]}
450
- >
451
- <button
452
- aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
453
- className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
454
- altPressed
455
- ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
456
- : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
457
- }`}
458
- onClick={(e) => handleEndpointMove('start', e)}
459
- onPointerDown={(e) => e.stopPropagation()}
460
- title={
461
- node.type === 'wall'
462
- ? 'Move wall start (Alt to detach)'
463
- : 'Move fence start (Alt to detach)'
464
- }
465
- type="button"
466
- >
467
- <Move className="h-4 w-4" />
468
- </button>
469
- </Html>
470
- </group>
471
- <group ref={endEndpointGroupRef}>
472
- <Html
473
- center
474
- style={{ pointerEvents: 'auto', touchAction: 'none' }}
475
- zIndexRange={[100, 0]}
476
- >
477
- <button
478
- aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
479
- className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
480
- altPressed
481
- ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
482
- : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
483
- }`}
484
- onClick={(e) => handleEndpointMove('end', e)}
485
- onPointerDown={(e) => e.stopPropagation()}
486
- title={
487
- node.type === 'wall'
488
- ? 'Move wall end (Alt to detach)'
489
- : 'Move fence end (Alt to detach)'
490
- }
491
- type="button"
492
- >
493
- <Move className="h-4 w-4" />
494
- </button>
495
- </Html>
496
- </group>
940
+ <span aria-hidden className="text-muted-foreground">
941
+ ·
942
+ </span>
943
+ <span className="text-muted-foreground">
944
+ {formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
945
+ {summary.runCount === 1 ? 'run' : 'runs'}
946
+ </span>
947
+ </>
948
+ ) : null}
949
+ {summary.terminalCount > 0 ? (
950
+ <>
951
+ <span aria-hidden className="text-muted-foreground">
952
+ ·
953
+ </span>
954
+ <span className="text-muted-foreground">
955
+ {summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
956
+ </span>
957
+ </>
958
+ ) : null}
959
+ {summary.connectedToEquipment ? null : (
960
+ <>
961
+ <span aria-hidden className="text-muted-foreground">
962
+ ·
963
+ </span>
964
+ <span className="font-medium text-amber-500">⚠ no equipment</span>
497
965
  </>
498
966
  )}
499
- </group>
967
+ </div>
500
968
  )
501
969
  }