@pascal-app/editor 0.9.1 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (366) hide show
  1. package/package.json +14 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +49 -0
  4. package/src/components/editor/bake-thumbnail.tsx +95 -0
  5. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  6. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  7. package/src/components/editor/custom-camera-controls.tsx +332 -199
  8. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  9. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  10. package/src/components/editor/editor-layout-v2.tsx +18 -0
  11. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  12. package/src/components/editor/export-manager.tsx +71 -63
  13. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  14. package/src/components/editor/first-person/build-collider-world.test.ts +102 -5
  15. package/src/components/editor/first-person/build-collider-world.ts +52 -5
  16. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  17. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  18. package/src/components/editor/first-person-controls.tsx +410 -136
  19. package/src/components/editor/floating-action-menu.tsx +435 -64
  20. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  21. package/src/components/editor/floorplan-background-selection.test.ts +72 -0
  22. package/src/components/editor/floorplan-background-selection.ts +7 -20
  23. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  24. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  25. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  26. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  27. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  28. package/src/components/editor/floorplan-panel.tsx +2517 -1492
  29. package/src/components/editor/grid.tsx +166 -39
  30. package/src/components/editor/group-actions.ts +597 -0
  31. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  32. package/src/components/editor/group-move-3d.ts +397 -0
  33. package/src/components/editor/group-rotate-handle.tsx +102 -64
  34. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  35. package/src/components/editor/group-transform-shared.test.ts +192 -1
  36. package/src/components/editor/group-transform-shared.ts +240 -7
  37. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  38. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  39. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  40. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  41. package/src/components/editor/handles/preview-overrides.ts +13 -0
  42. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  43. package/src/components/editor/handles/resize-snap.ts +23 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +49 -6
  45. package/src/components/editor/index.tsx +289 -35
  46. package/src/components/editor/measurement-pill.tsx +68 -23
  47. package/src/components/editor/node-action-menu.tsx +14 -1
  48. package/src/components/editor/node-arrow-handles.tsx +409 -180
  49. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  50. package/src/components/editor/quick-measurement-card.tsx +76 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +34 -0
  52. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  53. package/src/components/editor/selection-manager.tsx +814 -466
  54. package/src/components/editor/site-edge-labels.tsx +28 -6
  55. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  56. package/src/components/editor/snapshot-capture-overlay.tsx +258 -115
  57. package/src/components/editor/three-context-bridge.ts +22 -0
  58. package/src/components/editor/thumbnail-generator.tsx +118 -253
  59. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  60. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  61. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  62. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  63. package/src/components/editor/use-mesh-settle-epoch.ts +27 -0
  64. package/src/components/editor/wall-measurement-label.tsx +10 -28
  65. package/src/components/editor/wall-move-side-handles.tsx +229 -39
  66. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  67. package/src/components/editor/wall-snap-beacon-layer.tsx +162 -6
  68. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  69. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +40 -9
  71. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  72. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  73. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  74. package/src/components/editor-2d/floorplan-group-move.tsx +744 -0
  75. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  76. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1672 -0
  77. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  78. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  79. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +219 -20
  80. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +327 -59
  81. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  82. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  83. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  84. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  85. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  86. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  88. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  89. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  90. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +89 -0
  91. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +106 -0
  92. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +94 -26
  93. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +587 -0
  94. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +2280 -578
  95. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  96. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  97. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +425 -61
  98. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  99. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  100. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  101. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  102. package/src/components/systems/zone/zone-system.tsx +42 -13
  103. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  104. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  105. package/src/components/tools/fence/fence-drafting.ts +107 -8
  106. package/src/components/tools/item/move-tool.tsx +17 -13
  107. package/src/components/tools/item/placement-math.test.ts +13 -1
  108. package/src/components/tools/item/placement-math.ts +28 -2
  109. package/src/components/tools/item/placement-strategies.ts +246 -5
  110. package/src/components/tools/item/placement-types.ts +13 -1
  111. package/src/components/tools/item/use-draft-node.ts +146 -57
  112. package/src/components/tools/item/use-placement-coordinator.tsx +813 -125
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +636 -103
  114. package/src/components/tools/roof/roof-tool.tsx +336 -94
  115. package/src/components/tools/select/box-select-state.ts +18 -7
  116. package/src/components/tools/select/box-select-tool.tsx +176 -40
  117. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  118. package/src/components/tools/select/marquee-geometry.ts +155 -0
  119. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  120. package/src/components/tools/select/select-candidates.ts +1 -1
  121. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  122. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  123. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  124. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  125. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  126. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  127. package/src/components/tools/shared/placement-box.tsx +183 -1
  128. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  129. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  130. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  131. package/src/components/tools/site/site-boundary-editor.tsx +101 -42
  132. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  133. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  134. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  135. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  136. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  137. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  138. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  139. package/src/components/tools/stair/stair-defaults.ts +1 -0
  140. package/src/components/tools/stair/stair-tool.tsx +164 -42
  141. package/src/components/tools/tool-manager.tsx +149 -30
  142. package/src/components/tools/wall/wall-drafting.test.ts +447 -0
  143. package/src/components/tools/wall/wall-drafting.ts +304 -108
  144. package/src/components/tools/wall/wall-snap-geometry.test.ts +52 -0
  145. package/src/components/tools/wall/wall-snap-geometry.ts +137 -9
  146. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  147. package/src/components/tools/zone/zone-tool.tsx +98 -88
  148. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  149. package/src/components/ui/action-menu/control-modes.tsx +44 -94
  150. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  151. package/src/components/ui/action-menu/index.tsx +2 -4
  152. package/src/components/ui/action-menu/measurement-control.tsx +267 -0
  153. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  154. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  155. package/src/components/ui/command-palette/editor-commands.tsx +16 -3
  156. package/src/components/ui/command-palette/index.tsx +4 -3
  157. package/src/components/ui/controls/material-paint-panel.tsx +93 -18
  158. package/src/components/ui/controls/material-picker.tsx +162 -152
  159. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  160. package/src/components/ui/controls/metric-control.tsx +118 -44
  161. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  162. package/src/components/ui/controls/slider-control.tsx +88 -38
  163. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  164. package/src/components/ui/floating-level-selector.tsx +74 -20
  165. package/src/components/ui/helpers/building-helper.tsx +10 -23
  166. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  167. package/src/components/ui/helpers/helper-manager.tsx +273 -14
  168. package/src/components/ui/helpers/item-helper.tsx +28 -32
  169. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  170. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  171. package/src/components/ui/icon-ref.tsx +47 -0
  172. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  173. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  174. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  175. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  176. package/src/components/ui/panels/node-display.ts +17 -17
  177. package/src/components/ui/panels/panel-manager.tsx +36 -14
  178. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  179. package/src/components/ui/panels/parametric-inspector.tsx +19 -4
  180. package/src/components/ui/panels/reference-panel.tsx +54 -19
  181. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  182. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  183. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  184. package/src/components/ui/primitives/sidebar.tsx +1 -0
  185. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  186. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  187. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  188. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +82 -41
  189. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +76 -6
  190. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  191. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  192. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  193. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  194. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  195. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  196. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  197. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  198. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  199. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  200. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -17
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  211. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  215. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  216. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  217. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  218. package/src/components/ui/snap-target-badge.tsx +88 -0
  219. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  220. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  221. package/src/components/viewer-overlay.tsx +36 -596
  222. package/src/components/viewer-zone-system.tsx +6 -1
  223. package/src/components/walkthrough-hud.tsx +111 -0
  224. package/src/hooks/use-auto-save.test.ts +14 -0
  225. package/src/hooks/use-auto-save.ts +54 -7
  226. package/src/hooks/use-ceiling-events.ts +3 -2
  227. package/src/hooks/use-drag-action.ts +4 -1
  228. package/src/hooks/use-grid-events.ts +24 -0
  229. package/src/hooks/use-keyboard.ts +438 -70
  230. package/src/index.tsx +355 -9
  231. package/src/lib/active-placement-surface.test.ts +24 -0
  232. package/src/lib/active-placement-surface.ts +42 -0
  233. package/src/lib/camera-pose.test.ts +237 -0
  234. package/src/lib/camera-pose.ts +172 -0
  235. package/src/lib/ceiling-plan-snap.ts +24 -0
  236. package/src/lib/contextual-help.test.ts +112 -0
  237. package/src/lib/contextual-help.ts +144 -0
  238. package/src/lib/continuation.ts +64 -0
  239. package/src/lib/direct-manipulation.test.ts +275 -0
  240. package/src/lib/direct-manipulation.ts +118 -0
  241. package/src/lib/door-interaction.ts +1 -1
  242. package/src/lib/editor-api.ts +23 -35
  243. package/src/lib/elevation-guides.test.ts +107 -0
  244. package/src/lib/elevation-guides.ts +236 -0
  245. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  246. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  247. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  248. package/src/lib/floorplan/apply-alignment.ts +18 -12
  249. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  250. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  251. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  252. package/src/lib/floorplan/floorplan-export.tsx +978 -0
  253. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  254. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  255. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  256. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  257. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  258. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  259. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  260. package/src/lib/floorplan/geometry.ts +52 -0
  261. package/src/lib/floorplan/index.ts +3 -0
  262. package/src/lib/floorplan/items.ts +5 -2
  263. package/src/lib/floorplan/plan-coords.ts +21 -0
  264. package/src/lib/fresh-planar-placement.test.ts +283 -3
  265. package/src/lib/fresh-planar-placement.ts +85 -1
  266. package/src/lib/glb-export.test.ts +556 -0
  267. package/src/lib/glb-export.ts +1071 -0
  268. package/src/lib/ground-surface.ts +95 -0
  269. package/src/lib/history.test.ts +96 -0
  270. package/src/lib/history.ts +31 -0
  271. package/src/lib/interaction/hot-set.test.ts +133 -0
  272. package/src/lib/interaction/hot-set.ts +67 -0
  273. package/src/lib/interaction/overlay-policy.test.ts +52 -0
  274. package/src/lib/interaction/overlay-policy.ts +59 -0
  275. package/src/lib/interaction/scope.ts +243 -0
  276. package/src/lib/level-duplication.test.ts +2 -1
  277. package/src/lib/level-selection.ts +2 -2
  278. package/src/lib/material-paint.ts +36 -49
  279. package/src/lib/measurement-kind.test.ts +30 -0
  280. package/src/lib/measurement-kind.ts +19 -0
  281. package/src/lib/measurement-label.test.ts +47 -0
  282. package/src/lib/measurement-label.ts +68 -0
  283. package/src/lib/measurement-parser.ts +116 -0
  284. package/src/lib/measurements.test.ts +188 -0
  285. package/src/lib/measurements.ts +210 -0
  286. package/src/lib/paint-scope.test.ts +454 -0
  287. package/src/lib/paint-scope.ts +461 -0
  288. package/src/lib/placement-drag-release.ts +23 -0
  289. package/src/lib/planar-cursor-placement.test.ts +57 -0
  290. package/src/lib/plugin-panels.test.ts +19 -0
  291. package/src/lib/plugin-panels.ts +91 -0
  292. package/src/lib/quick-action-feedback.ts +33 -0
  293. package/src/lib/quick-action-nodes.test.ts +94 -0
  294. package/src/lib/quick-action-nodes.ts +47 -0
  295. package/src/lib/quick-measurement.test.ts +77 -0
  296. package/src/lib/quick-measurement.ts +109 -0
  297. package/src/lib/roof-duplication.ts +6 -6
  298. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  299. package/src/lib/roof-wall-hit.ts +164 -0
  300. package/src/lib/scene-clipboard.test.ts +325 -0
  301. package/src/lib/scene-clipboard.ts +310 -21
  302. package/src/lib/scene.ts +28 -5
  303. package/src/lib/selection-routing.test.ts +313 -0
  304. package/src/lib/selection-routing.ts +180 -0
  305. package/src/lib/sfx/index.ts +1 -0
  306. package/src/lib/sfx/movement-tick.test.ts +65 -0
  307. package/src/lib/sfx/movement-tick.ts +18 -0
  308. package/src/lib/sfx-bus.ts +82 -14
  309. package/src/lib/sfx-player.test.ts +266 -0
  310. package/src/lib/sfx-player.ts +262 -69
  311. package/src/lib/site-boundary.test.ts +53 -0
  312. package/src/lib/site-boundary.ts +27 -0
  313. package/src/lib/slab-plan-snap.test.ts +93 -0
  314. package/src/lib/slab-plan-snap.ts +108 -0
  315. package/src/lib/snapping-mode.test.ts +165 -0
  316. package/src/lib/snapping-mode.ts +191 -0
  317. package/src/lib/stair-duplication.test.ts +103 -0
  318. package/src/lib/stair-duplication.ts +58 -86
  319. package/src/lib/stair-levels.ts +2 -0
  320. package/src/lib/surface-plan-snap.test.ts +71 -0
  321. package/src/lib/surface-plan-snap.ts +256 -0
  322. package/src/lib/terrain-sculpt.test.ts +429 -0
  323. package/src/lib/terrain-sculpt.ts +301 -0
  324. package/src/lib/terrain-verb-color.test.ts +51 -0
  325. package/src/lib/terrain-verb-color.ts +58 -0
  326. package/src/lib/touch-gesture-priority.test.ts +45 -0
  327. package/src/lib/touch-gesture-priority.ts +30 -0
  328. package/src/lib/use-linear-display.ts +40 -0
  329. package/src/lib/window-interaction.ts +1 -1
  330. package/src/lib/world-grid-snap.ts +37 -5
  331. package/src/store/camera-pose-store.test.ts +29 -0
  332. package/src/store/camera-pose-store.ts +27 -0
  333. package/src/store/live-draft-preview-stores.test.ts +136 -0
  334. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  335. package/src/store/navigation-sync-pose-store.ts +27 -0
  336. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  337. package/src/store/use-delete-confirmation.ts +27 -0
  338. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  339. package/src/store/use-drawing-view.test.ts +23 -0
  340. package/src/store/use-drawing-view.ts +84 -0
  341. package/src/store/use-editor.tsx +665 -126
  342. package/src/store/use-elevation-guides.ts +40 -0
  343. package/src/store/use-facing-pose.ts +44 -0
  344. package/src/store/use-fence-curve-draft.ts +51 -0
  345. package/src/store/use-first-person-hud.ts +40 -0
  346. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  347. package/src/store/use-floorplan-draft-preview.ts +151 -0
  348. package/src/store/use-floorplan-marquee.ts +50 -0
  349. package/src/store/use-floorplan-mode.ts +110 -0
  350. package/src/store/use-interaction-scope.test.ts +206 -0
  351. package/src/store/use-interaction-scope.ts +140 -0
  352. package/src/store/use-measurement-draft.test.ts +559 -0
  353. package/src/store/use-measurement-draft.ts +570 -0
  354. package/src/store/use-opening-guides.ts +41 -0
  355. package/src/store/use-path-draft-preview.ts +90 -0
  356. package/src/store/use-placement-preview.ts +11 -3
  357. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  358. package/src/store/use-quick-measurement-hud.ts +77 -0
  359. package/src/store/use-segment-draft-chain.ts +28 -0
  360. package/src/store/use-stair-build-preview.ts +53 -0
  361. package/src/store/use-wall-snap-indicator.ts +2 -0
  362. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  363. package/src/components/editor/group-move-handle.tsx +0 -316
  364. package/src/components/ui/panels/paint-panel.tsx +0 -163
  365. package/src/lib/floorplan/selection-tool.ts +0 -271
  366. package/src/lib/level-name.ts +0 -11
@@ -5,23 +5,31 @@ import {
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
7
  ColumnNode,
8
- DEFAULT_WALL_HEIGHT,
8
+ createSceneApi,
9
9
  DoorNode,
10
10
  ElevatorNode,
11
+ emitter,
11
12
  FenceNode,
12
13
  generateId,
14
+ getActiveRoofHeight,
15
+ getEffectiveNode,
13
16
  getWallCurveLength,
17
+ getWallEffectiveHeightForNodes,
14
18
  getWallThickness,
15
19
  ItemNode,
20
+ isCurvedWall,
16
21
  isRegistryMovable,
17
22
  isRegistrySelectable,
23
+ isSplineFence,
24
+ type NodeQuickAction,
18
25
  nodeRegistry,
19
26
  RoofSegmentNode,
27
+ runAsSingleSceneHistoryStep,
20
28
  type SlabNode,
21
29
  SpawnNode,
22
- StairNode,
23
30
  StairSegmentNode,
24
31
  sceneRegistry,
32
+ summarizeSystemFor,
25
33
  useLiveNodeOverrides,
26
34
  useScene,
27
35
  WallNode,
@@ -30,15 +38,48 @@ import {
30
38
  import { useViewer } from '@pascal-app/viewer'
31
39
  import { Html } from '@react-three/drei'
32
40
  import { useFrame } from '@react-three/fiber'
33
- import { useCallback, useRef } from 'react'
41
+ import { useCallback, useMemo, useRef } from 'react'
34
42
  import * as THREE from 'three'
43
+ import { useShallow } from 'zustand/react/shallow'
44
+ import { useReducedMotion } from '../../hooks/use-reduced-motion'
45
+ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
46
+ import {
47
+ createFreshPlacementSubtree,
48
+ duplicatesAsFreshSubtree,
49
+ prepareFreshPlacementRootDuplicate,
50
+ } from '../../lib/fresh-planar-placement'
51
+ import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
52
+ import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
53
+ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
54
+ import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
35
55
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
36
56
  import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
37
- import { duplicateStairSubtree } from '../../lib/stair-duplication'
57
+ import { cn } from '../../lib/utils'
38
58
  import useEditor from '../../store/use-editor'
59
+ import useInteractionScope, {
60
+ useActiveHandleDrag,
61
+ useEndpointReshape,
62
+ useIsCurveReshape,
63
+ } from '../../store/use-interaction-scope'
64
+ import { IconRefGlyph } from '../ui/icon-ref'
39
65
  import { formatMeasurement, MeasurementPill } from './measurement-pill'
40
66
  import { NodeActionMenu } from './node-action-menu'
41
67
 
68
+ /**
69
+ * A kind shows the system pill when it exposes typed ports — `def.ports`
70
+ * is exactly what makes a node participate in the supply/return graph the
71
+ * pill summarizes. Keeps the menu off a hand-maintained kind list.
72
+ */
73
+ const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
74
+
75
+ /**
76
+ * A kind shows the rotation-axis pill when its R/T keyboard rotation
77
+ * turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
78
+ * duct / pipe fittings with full 3D orientation.
79
+ */
80
+ const hasAxisCycling = (type: string) =>
81
+ nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
82
+
42
83
  const ALLOWED_TYPES = [
43
84
  'item',
44
85
  'door',
@@ -111,6 +152,110 @@ function getMenuYOffset(node: AnyNode | null): number {
111
152
  return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
112
153
  }
113
154
 
155
+ function getAttributeVersion(
156
+ attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
157
+ ): number {
158
+ return attribute && 'version' in attribute && typeof attribute.version === 'number'
159
+ ? attribute.version
160
+ : 0
161
+ }
162
+
163
+ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
164
+ return (
165
+ <svg
166
+ aria-hidden="true"
167
+ className="h-3.5 w-3.5"
168
+ fill="none"
169
+ stroke="currentColor"
170
+ strokeLinecap="round"
171
+ strokeLinejoin="round"
172
+ strokeWidth="1.7"
173
+ viewBox="0 0 24 24"
174
+ >
175
+ <rect x="8" y="7" width="9" height="10" rx="1.75" />
176
+ <path d="M12.5 7v10" />
177
+ {direction === 'left' ? (
178
+ <>
179
+ <path d="M6.5 12H2.75" />
180
+ <path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
181
+ </>
182
+ ) : (
183
+ <>
184
+ <path d="M17.5 12h3.75" />
185
+ <path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
186
+ </>
187
+ )}
188
+ </svg>
189
+ )
190
+ }
191
+
192
+ // Builtin string tokens map to the generic glyphs above; kind-owned marks
193
+ // arrive as IconRef objects and render through the shared IconRefGlyph.
194
+ function QuickActionIcon({ action }: { action: NodeQuickAction }) {
195
+ const icon = action.icon
196
+ if (!icon) return null
197
+ if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
198
+ switch (icon) {
199
+ case 'add-left':
200
+ return <SideAddGlyph direction="left" />
201
+ case 'add-right':
202
+ return <SideAddGlyph direction="right" />
203
+ default:
204
+ return null
205
+ }
206
+ }
207
+
208
+ function collectQuickActionNodes(
209
+ nodes: Record<AnyNodeId, AnyNode>,
210
+ selectedId: string | null,
211
+ ): Record<AnyNodeId, AnyNode> | null {
212
+ if (!selectedId) return null
213
+ const selected = nodes[selectedId as AnyNodeId]
214
+ const def = selected ? nodeRegistry.get(selected.type) : undefined
215
+ if (!def?.quickActions) return null
216
+ return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
217
+ }
218
+
219
+ // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
220
+ // dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
221
+ const _anchorBox = new THREE.Box3()
222
+ const _anchorCenter = new THREE.Vector3()
223
+
224
+ function getObjectGeometryKey(object: THREE.Object3D): string {
225
+ const parts: string[] = []
226
+ object.traverse((child) => {
227
+ const geometry = (child as Partial<THREE.Mesh>).geometry
228
+ if (!geometry) return
229
+
230
+ parts.push(
231
+ [
232
+ geometry.id,
233
+ getAttributeVersion(geometry.getAttribute('position')),
234
+ getAttributeVersion(geometry.getIndex()),
235
+ ].join(':'),
236
+ )
237
+ })
238
+ return parts.join('|')
239
+ }
240
+
241
+ function setNodeDerivedMenuAnchor(
242
+ node: AnyNode,
243
+ object: THREE.Object3D,
244
+ target: THREE.Vector3,
245
+ ): boolean {
246
+ if (node.type !== 'roof-segment') return false
247
+
248
+ const visualTop =
249
+ node.wallHeight +
250
+ getActiveRoofHeight(node) +
251
+ Math.max(0, node.deckThickness ?? 0) +
252
+ Math.max(0, node.shingleThickness ?? 0)
253
+
254
+ target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
255
+ target.y += getMenuYOffset(node)
256
+ return true
257
+ }
258
+
114
259
  // Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
115
260
  // pill reads sensibly before an explicit height / thickness is set.
116
261
  const FENCE_DEFAULT_HEIGHT = 1.8
@@ -125,7 +270,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
125
270
  } {
126
271
  if (node.type === 'wall') {
127
272
  return {
128
- height: node.height ?? DEFAULT_WALL_HEIGHT,
273
+ height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
129
274
  length: getWallCurveLength(node),
130
275
  thickness: getWallThickness(node),
131
276
  }
@@ -138,23 +283,27 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
138
283
  }
139
284
 
140
285
  export function FloatingActionMenu() {
286
+ const reducedMotion = useReducedMotion()
141
287
  const selectedIds = useViewer((s) => s.selection.selectedIds)
142
288
  const updateNode = useScene((s) => s.updateNode)
143
289
  const mode = useEditor((s) => s.mode)
144
290
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
145
- const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
146
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
147
- const curvingFence = useEditor((s) => s.curvingFence)
291
+ const canFindNode = useEditor((s) => s.canFindNode)
292
+ const endpointReshape = useEndpointReshape()
293
+ const isCurveReshape = useIsCurveReshape()
148
294
  const setMovingNode = useEditor((s) => s.setMovingNode)
149
- const setCurvingWall = useEditor((s) => s.setCurvingWall)
150
- const setCurvingFence = useEditor((s) => s.setCurvingFence)
151
295
  const setSelection = useViewer((s) => s.setSelection)
152
- const setEditingHole = useEditor((s) => s.setEditingHole)
153
296
  const unit = useViewer((s) => s.unit)
154
297
  // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
155
298
  // flips only at drag start / end, so subscribing here is cheap — the live
156
299
  // height value is written imperatively in the useFrame below.
157
- const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
300
+ const activeHandleDrag = useActiveHandleDrag()
301
+ // R/T rotation axis for kinds with full 3D orientation (duct fittings).
302
+ const rotationAxis = useEditor((s) => s.rotationAxis)
303
+ // The floating action menu is an action-conflicting control: hard-hidden
304
+ // during any active interaction so it never competes with the live action.
305
+ const scope = useInteractionScope((s) => s.scope)
306
+ const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
158
307
 
159
308
  const groupRef = useRef<THREE.Group>(null)
160
309
  const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -171,22 +320,39 @@ export function FloatingActionMenu() {
171
320
  const anchorRef = useRef(new THREE.Vector3())
172
321
  const hasAnchorRef = useRef(false)
173
322
  const lastMatrixRef = useRef(new THREE.Matrix4())
174
- const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
323
+ const lastAnchorKeyRef = useRef<{
324
+ id: string | null
325
+ node: AnyNode | null
326
+ geometryKey: string | null
327
+ }>({
175
328
  id: null,
176
329
  node: null,
330
+ geometryKey: null,
177
331
  })
178
332
 
179
333
  // Only show for single selection of specific types
180
- const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
334
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
181
335
 
182
336
  // Subscribe just to the selected node so unrelated scene updates do not
183
337
  // re-render this menu.
184
338
  const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
339
+ const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId)))
340
+ const quickActions = useMemo<NodeQuickAction[]>(
341
+ () =>
342
+ node && quickActionNodes
343
+ ? (nodeRegistry.get(node.type)?.quickActions?.({
344
+ node: node as never,
345
+ nodes: quickActionNodes,
346
+ }) ?? [])
347
+ : [],
348
+ [node, quickActionNodes],
349
+ )
185
350
  // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
186
351
  // NodeDefinition with `capabilities.selectable`) get the floating menu
187
352
  // by default too. Phase 4 collapses these into a single registry check.
188
353
  const isValidType = node
189
- ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
354
+ ? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
355
+ (ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
190
356
  : false
191
357
 
192
358
  // Height-drag pill: shown just above the menu only while the selected
@@ -218,7 +384,7 @@ export function FloatingActionMenu() {
218
384
  })
219
385
 
220
386
  useFrame((state) => {
221
- if (!(selectedId && isValidType && groupRef.current)) return
387
+ if (!(selectedId && node && isValidType && groupRef.current)) return
222
388
 
223
389
  // Scale the HTML menu with camera zoom (ortho) or inverse distance
224
390
  // (perspective) so it feels anchored to the world, clamped on both ends
@@ -246,36 +412,69 @@ export function FloatingActionMenu() {
246
412
  const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
247
413
  | { height?: number }
248
414
  | undefined
249
- const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
415
+ const fallbackHeight =
416
+ node.type === 'wall'
417
+ ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
418
+ : FENCE_DEFAULT_HEIGHT
250
419
  const liveHeight = override?.height ?? node.height ?? fallbackHeight
251
420
  pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
252
421
  }
253
422
 
254
423
  const obj = sceneRegistry.nodes.get(selectedId)
255
424
  if (obj) {
425
+ obj.updateWorldMatrix(true, false)
426
+
256
427
  // Recompute the anchor only when the object genuinely changes —
257
428
  // reselected, moved (its own world matrix changed), or resized
258
429
  // (a fresh store node on commit, or a live override / handle drag
259
430
  // mid-resize). A spinning child changes the head's matrix, not the
260
431
  // registered group's, so it never triggers a recompute → the menu
261
432
  // holds still.
433
+ // Cheapest guards first: a selection swap, the object's own world
434
+ // transform changing (true every frame during a drag), a live override,
435
+ // or an active handle drag all force a recompute on their own — so skip
436
+ // the geometry traversal (`getObjectGeometryKey` walks the whole subtree
437
+ // reading attribute versions) until none of them fired and a
438
+ // geometry-only change is the only thing left that could move the anchor.
262
439
  const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
263
440
  const dragActive = activeHandleDrag?.nodeId === selectedId
264
441
  const selectionChanged =
265
442
  lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
266
443
  const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
267
444
 
268
- if (selectionChanged || matrixChanged || overrideActive || dragActive) {
269
- const box = new THREE.Box3().setFromObject(obj)
270
- if (!box.isEmpty()) {
271
- const center = box.getCenter(new THREE.Vector3())
272
- // Position above the object. Per-type offsets clear each kind's
273
- // in-world chrome (height-resize arrows, measurement labels).
274
- anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z)
445
+ let geometryKey = lastAnchorKeyRef.current.geometryKey
446
+ let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
447
+ // Only when nothing cheaper fired do we pay for the subtree traversal —
448
+ // a geometry-only change is the lone remaining trigger. When a cheaper
449
+ // guard already forced a recompute the stored key is reused; the matrix
450
+ // (or override/drag) keeps recomputing the anchor every frame, so a
451
+ // geometry edit mid-drag is absorbed, and the next idle frame refreshes
452
+ // the key against the live geometry.
453
+ if (!needsRecompute) {
454
+ geometryKey = getObjectGeometryKey(obj)
455
+ if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
456
+ }
457
+
458
+ if (needsRecompute) {
459
+ const effectiveNode = getEffectiveNode(node)
460
+ if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
461
+ _anchorBox.setFromObject(obj)
462
+ if (!_anchorBox.isEmpty()) {
463
+ _anchorBox.getCenter(_anchorCenter)
464
+ // Position above the object. Per-type offsets clear each kind's
465
+ // in-world chrome (height-resize arrows, measurement labels).
466
+ anchorRef.current.set(
467
+ _anchorCenter.x,
468
+ _anchorBox.max.y + getMenuYOffset(effectiveNode),
469
+ _anchorCenter.z,
470
+ )
471
+ hasAnchorRef.current = true
472
+ }
473
+ } else {
275
474
  hasAnchorRef.current = true
276
475
  }
277
476
  lastMatrixRef.current.copy(obj.matrixWorld)
278
- lastAnchorKeyRef.current = { id: selectedId, node }
477
+ lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
279
478
  }
280
479
 
281
480
  if (hasAnchorRef.current) {
@@ -291,22 +490,23 @@ export function FloatingActionMenu() {
291
490
  sfxEmitter.emit('sfx:item-pick')
292
491
  if (node.type === 'wall') {
293
492
  if (!canCurveSelectedWall) return
294
- setCurvingWall(node)
493
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
295
494
  } else if (node.type === 'fence') {
296
- setCurvingFence(node)
495
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
297
496
  } else {
298
497
  return
299
498
  }
300
499
  setSelection({ selectedIds: [] })
301
500
  },
302
- [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
501
+ [canCurveSelectedWall, node, setSelection],
303
502
  )
304
503
  const handleMove = useCallback(
305
504
  (e: React.MouseEvent) => {
306
505
  e.stopPropagation()
307
506
  if (!node) return
308
507
  sfxEmitter.emit('sfx:item-pick')
309
- setMovingNode(node as any)
508
+ const sceneNodes = useScene.getState().nodes
509
+ setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
310
510
  setSelection({ selectedIds: [] })
311
511
  },
312
512
  [node, setMovingNode, setSelection],
@@ -328,9 +528,27 @@ export function FloatingActionMenu() {
328
528
 
329
529
  useScene.temporal.getState().pause()
330
530
 
331
- let duplicateInfo = structuredClone(node) as any
332
- delete duplicateInfo.id
333
- duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
531
+ if (duplicatesAsFreshSubtree(node as AnyNode)) {
532
+ let draftId: AnyNodeId | null = null
533
+ try {
534
+ draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
535
+ const draft = draftId ? useScene.getState().nodes[draftId] : null
536
+ if (draft) {
537
+ setMovingNode(draft as any)
538
+ setSelection({ selectedIds: [] })
539
+ return
540
+ }
541
+ } catch (error) {
542
+ if (draftId && useScene.getState().nodes[draftId]) {
543
+ useScene.getState().deleteNode(draftId)
544
+ }
545
+ console.error('Failed to duplicate node subtree', error)
546
+ }
547
+ useScene.temporal.getState().resume()
548
+ return
549
+ }
550
+
551
+ const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
334
552
 
335
553
  let duplicate: AnyNode | null = null
336
554
  try {
@@ -353,11 +571,6 @@ export function FloatingActionMenu() {
353
571
  } else if (node.type === 'roof-segment') {
354
572
  duplicateInfo.id = generateId('rseg')
355
573
  duplicate = RoofSegmentNode.parse(duplicateInfo)
356
- } else if (node.type === 'stair') {
357
- duplicateInfo.children = []
358
- duplicateInfo.metadata = { ...duplicateInfo.metadata }
359
- delete duplicateInfo.metadata?.isNew
360
- duplicate = StairNode.parse(duplicateInfo)
361
574
  } else if (node.type === 'stair-segment') {
362
575
  duplicate = StairSegmentNode.parse(duplicateInfo)
363
576
  } else if (node.type === 'spawn') {
@@ -395,11 +608,7 @@ export function FloatingActionMenu() {
395
608
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
396
609
  } else if (duplicate.type === 'fence') {
397
610
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
398
- } else if (
399
- duplicate.type === 'roof-segment' ||
400
- duplicate.type === 'stair' ||
401
- duplicate.type === 'stair-segment'
402
- ) {
611
+ } else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
403
612
  // Add small offset to make it visible
404
613
  if ('position' in duplicate) {
405
614
  duplicate.position = [
@@ -408,13 +617,7 @@ export function FloatingActionMenu() {
408
617
  duplicate.position[2] + 1,
409
618
  ]
410
619
  }
411
- if (node.type === 'stair' && duplicate.type === 'stair') {
412
- duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
413
- } else {
414
- useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
415
- }
416
-
417
- // Duplicate children for stair nodes
620
+ useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
418
621
  } else if (
419
622
  duplicate.type === 'item' ||
420
623
  duplicate.type === 'chimney' ||
@@ -431,10 +634,26 @@ export function FloatingActionMenu() {
431
634
  // item without clicking" bug. (Item has its own
432
635
  // draft-committing move tool, so it must skip the generic
433
636
  // registry auto-create branch below.)
637
+ } else if (
638
+ duplicate.type === 'duct-segment' ||
639
+ duplicate.type === 'duct-fitting' ||
640
+ duplicate.type === 'pipe-segment' ||
641
+ duplicate.type === 'lineset' ||
642
+ duplicate.type === 'liquid-line'
643
+ ) {
644
+ // Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
645
+ // pure drag-to-place: NO node is inserted into the scene until the
646
+ // commit click. `setMovingNode` below hands the clone (with
647
+ // `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
648
+ // `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
649
+ // which previews a translucent copy inside a footprint bounding box
650
+ // on the cursor and calls `createNode` on the drop click.
651
+ // Pre-creating here would drop a copy before any click — the
652
+ // "auto-places it" bug.
434
653
  } else if (nodeRegistry.has(duplicate.type)) {
435
- // Registry-driven kinds: offset the position slightly so the
436
- // duplicate doesn't overlap exactly, then create + hand to the
437
- // move tool. Mirrors the roof-segment / stair-segment behavior.
654
+ // Registry-driven kinds: offset slightly so the duplicate doesn't
655
+ // overlap exactly, then create + hand to the move tool. Mirrors the
656
+ // roof-segment / stair-segment behavior.
438
657
  if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
439
658
  const pos = (duplicate as { position: [number, number, number] }).position
440
659
  ;(duplicate as { position: [number, number, number] }).position = [
@@ -442,6 +661,12 @@ export function FloatingActionMenu() {
442
661
  pos[1],
443
662
  pos[2] + 1,
444
663
  ]
664
+ } else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
665
+ // Other polyline kinds (pipe / lineset) carry a `path`, not a
666
+ // `position`. Create the copy HIDDEN so nothing is auto-placed:
667
+ // their shared path mover reveals it as a cursor-following
668
+ // preview on the first mouse move and commits on the next click.
669
+ ;(duplicate as { visible?: boolean }).visible = false
445
670
  }
446
671
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
447
672
  }
@@ -461,12 +686,8 @@ export function FloatingActionMenu() {
461
686
  nodeRegistry.has(duplicate.type)
462
687
  ) {
463
688
  setMovingNode(duplicate as any)
464
- } else if (duplicate.type === 'stair') {
465
- setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
466
- }
467
- if (duplicate.type !== 'stair') {
468
- setSelection({ selectedIds: [] })
469
689
  }
690
+ setSelection({ selectedIds: [] })
470
691
  }
471
692
  },
472
693
  [node, setMovingNode, setSelection],
@@ -503,11 +724,13 @@ export function FloatingActionMenu() {
503
724
  holes: [...currentHoles, newHole],
504
725
  holeMetadata: [...currentMetadata, { source: 'manual' }],
505
726
  })
506
- setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
727
+ useInteractionScope
728
+ .getState()
729
+ .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
507
730
  // Re-assert selection so the node stays selected
508
731
  setSelection({ selectedIds: [selectedId] })
509
732
  },
510
- [node, selectedId, updateNode, setEditingHole, setSelection],
733
+ [node, selectedId, updateNode, setSelection],
511
734
  )
512
735
 
513
736
  const handleDelete = useCallback(
@@ -521,11 +744,43 @@ export function FloatingActionMenu() {
521
744
  [node?.type, selectedId, setSelection],
522
745
  )
523
746
 
747
+ // "Find in catalog": the editor only signals intent — the host (community)
748
+ // listens for `selection:find-node` and reveals the node in its browser.
749
+ const handleFind = useCallback(
750
+ (e: React.MouseEvent) => {
751
+ e.stopPropagation()
752
+ if (node) emitter.emit('selection:find-node', node)
753
+ },
754
+ [node],
755
+ )
756
+
757
+ const handleQuickAction = useCallback(
758
+ (action: NodeQuickAction) => (e: React.MouseEvent<HTMLButtonElement>) => {
759
+ e.stopPropagation()
760
+ if (!node) return
761
+ if (action.disabled) {
762
+ if (action.blockedFeedback) {
763
+ playBlockedQuickActionFeedback(e.currentTarget, reducedMotion)
764
+ }
765
+ return
766
+ }
767
+ const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
768
+ const result =
769
+ action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
770
+ if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds })
771
+ if (result?.selectedIds) {
772
+ const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
773
+ sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
774
+ }
775
+ },
776
+ [node, reducedMotion, setSelection],
777
+ )
778
+
524
779
  if (
525
780
  !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
526
- movingWallEndpoint ||
527
- movingFenceEndpoint ||
528
- curvingFence
781
+ endpointReshape ||
782
+ isCurveReshape ||
783
+ menuStepBack
529
784
  )
530
785
  return null
531
786
 
@@ -540,11 +795,17 @@ export function FloatingActionMenu() {
540
795
  }}
541
796
  zIndexRange={[25, 0]}
542
797
  >
543
- <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
798
+ <div
799
+ className="relative flex flex-col items-center"
800
+ ref={menuScaleRef}
801
+ style={{ transformOrigin: 'center center' }}
802
+ >
544
803
  <NodeActionMenu
804
+ onFind={node && canFindNode ? handleFind : undefined}
545
805
  onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
546
806
  onCurve={
547
- node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
807
+ (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
808
+ (node?.type === 'wall' && canCurveSelectedWall)
548
809
  ? handleCurve
549
810
  : undefined
550
811
  }
@@ -567,6 +828,39 @@ export function FloatingActionMenu() {
567
828
  onPointerDown={(e) => e.stopPropagation()}
568
829
  onPointerUp={(e) => e.stopPropagation()}
569
830
  />
831
+ {quickActions.length > 0 ? (
832
+ <div
833
+ 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"
834
+ onPointerDown={(e) => e.stopPropagation()}
835
+ onPointerUp={(e) => e.stopPropagation()}
836
+ >
837
+ {quickActions.map((action) => (
838
+ <button
839
+ aria-disabled={action.disabled || undefined}
840
+ aria-label={action.title ?? action.label}
841
+ className={cn(
842
+ 'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
843
+ action.disabled &&
844
+ 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
845
+ )}
846
+ disabled={action.disabled && !action.blockedFeedback}
847
+ key={action.id}
848
+ onClick={handleQuickAction(action)}
849
+ title={action.title ?? action.label}
850
+ type="button"
851
+ >
852
+ <span className="flex items-center gap-1.5" data-quick-action-feedback>
853
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
854
+ <QuickActionIcon action={action} />
855
+ </span>
856
+ <span className="whitespace-nowrap leading-none" data-quick-action-label>
857
+ {action.label}
858
+ </span>
859
+ </span>
860
+ </button>
861
+ ))}
862
+ </div>
863
+ ) : null}
570
864
  {/* Height-drag dimension pill. Absolutely positioned just above
571
865
  the menu (away from the height arrow below it) so it rides the
572
866
  same scale transform + anchor, never overlaps the menu, and
@@ -584,9 +878,86 @@ export function FloatingActionMenu() {
584
878
  />
585
879
  </div>
586
880
  ) : null}
881
+ {/* HVAC chrome above the menu — same slot as the wall height
882
+ pill. System pill (which tree, run length, equipment reach)
883
+ for every distribution kind; the rotation-axis pill stacks
884
+ under it for duct fittings. */}
885
+ {node && hasPorts(node.type) ? (
886
+ <div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
887
+ <SystemSummaryPill nodeId={node.id} unit={unit} />
888
+ {hasAxisCycling(node.type) ? (
889
+ <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">
890
+ <span className="font-medium text-foreground">
891
+ Axis {rotationAxis.toUpperCase()}
892
+ </span>
893
+ <span aria-hidden className="text-muted-foreground">
894
+ ·
895
+ </span>
896
+ <span className="text-muted-foreground">R/T rotate</span>
897
+ <span aria-hidden className="text-muted-foreground">
898
+ ·
899
+ </span>
900
+ <span className="text-muted-foreground">⌥ axis</span>
901
+ </div>
902
+ ) : null}
903
+ </div>
904
+ ) : null}
587
905
  </div>
588
906
  </Html>
589
907
  </group>
590
908
  </group>
591
909
  )
592
910
  }
911
+
912
+ /**
913
+ * System summary pill for a selected distribution kind (HVAC duct / DWV
914
+ * pipe / refrigerant lineset): which supply/return tree it belongs to, its
915
+ * run length, and whether it actually reaches a piece of equipment.
916
+ *
917
+ * Mounted only while an HVAC node is selected, so the full-`nodes`
918
+ * subscription it needs (connectivity changes when ANY joint moves) doesn't
919
+ * re-render the always-mounted parent menu on every unrelated scene tick.
920
+ */
921
+ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
922
+ const allNodes = useScene((s) => s.nodes)
923
+ const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
924
+ if (!summary) return null
925
+ return (
926
+ <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">
927
+ <span className="font-medium text-foreground">
928
+ {summary.systems.length > 0
929
+ ? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
930
+ : 'System'}
931
+ </span>
932
+ {summary.runCount > 0 ? (
933
+ <>
934
+ <span aria-hidden className="text-muted-foreground">
935
+ ·
936
+ </span>
937
+ <span className="text-muted-foreground">
938
+ {formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
939
+ {summary.runCount === 1 ? 'run' : 'runs'}
940
+ </span>
941
+ </>
942
+ ) : null}
943
+ {summary.terminalCount > 0 ? (
944
+ <>
945
+ <span aria-hidden className="text-muted-foreground">
946
+ ·
947
+ </span>
948
+ <span className="text-muted-foreground">
949
+ {summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
950
+ </span>
951
+ </>
952
+ ) : null}
953
+ {summary.connectedToEquipment ? null : (
954
+ <>
955
+ <span aria-hidden className="text-muted-foreground">
956
+ ·
957
+ </span>
958
+ <span className="font-medium text-amber-500">⚠ no equipment</span>
959
+ </>
960
+ )}
961
+ </div>
962
+ )
963
+ }