@pascal-app/editor 0.9.1 → 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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  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 +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  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 +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  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 +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -5,23 +5,32 @@ import {
5
5
  type AnyNodeId,
6
6
  type CeilingNode,
7
7
  ColumnNode,
8
+ createSceneApi,
8
9
  DEFAULT_WALL_HEIGHT,
9
10
  DoorNode,
10
11
  ElevatorNode,
12
+ emitter,
11
13
  FenceNode,
12
14
  generateId,
15
+ getActiveRoofHeight,
16
+ getEffectiveNode,
13
17
  getWallCurveLength,
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
30
  StairNode,
23
31
  StairSegmentNode,
24
32
  sceneRegistry,
33
+ summarizeSystemFor,
25
34
  useLiveNodeOverrides,
26
35
  useScene,
27
36
  WallNode,
@@ -30,15 +39,43 @@ import {
30
39
  import { useViewer } from '@pascal-app/viewer'
31
40
  import { Html } from '@react-three/drei'
32
41
  import { useFrame } from '@react-three/fiber'
33
- import { useCallback, useRef } from 'react'
42
+ import { useCallback, useMemo, useRef } from 'react'
34
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'
35
51
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
36
52
  import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
37
53
  import { duplicateStairSubtree } from '../../lib/stair-duplication'
38
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'
39
61
  import { formatMeasurement, MeasurementPill } from './measurement-pill'
40
62
  import { NodeActionMenu } from './node-action-menu'
41
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
+
42
79
  const ALLOWED_TYPES = [
43
80
  'item',
44
81
  'door',
@@ -111,6 +148,127 @@ function getMenuYOffset(node: AnyNode | null): number {
111
148
  return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
112
149
  }
113
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
+
114
272
  // Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
115
273
  // pill reads sensibly before an explicit height / thickness is set.
116
274
  const FENCE_DEFAULT_HEIGHT = 1.8
@@ -142,19 +300,22 @@ export function FloatingActionMenu() {
142
300
  const updateNode = useScene((s) => s.updateNode)
143
301
  const mode = useEditor((s) => s.mode)
144
302
  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)
303
+ const canFindNode = useEditor((s) => s.canFindNode)
304
+ const endpointReshape = useEndpointReshape()
305
+ const isCurveReshape = useIsCurveReshape()
148
306
  const setMovingNode = useEditor((s) => s.setMovingNode)
149
- const setCurvingWall = useEditor((s) => s.setCurvingWall)
150
- const setCurvingFence = useEditor((s) => s.setCurvingFence)
151
307
  const setSelection = useViewer((s) => s.setSelection)
152
- const setEditingHole = useEditor((s) => s.setEditingHole)
153
308
  const unit = useViewer((s) => s.unit)
154
309
  // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
155
310
  // flips only at drag start / end, so subscribing here is cheap — the live
156
311
  // height value is written imperatively in the useFrame below.
157
- const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
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'
158
319
 
159
320
  const groupRef = useRef<THREE.Group>(null)
160
321
  const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -171,22 +332,39 @@ export function FloatingActionMenu() {
171
332
  const anchorRef = useRef(new THREE.Vector3())
172
333
  const hasAnchorRef = useRef(false)
173
334
  const lastMatrixRef = useRef(new THREE.Matrix4())
174
- const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
335
+ const lastAnchorKeyRef = useRef<{
336
+ id: string | null
337
+ node: AnyNode | null
338
+ geometryKey: string | null
339
+ }>({
175
340
  id: null,
176
341
  node: null,
342
+ geometryKey: null,
177
343
  })
178
344
 
179
345
  // Only show for single selection of specific types
180
- const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
346
+ const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
181
347
 
182
348
  // Subscribe just to the selected node so unrelated scene updates do not
183
349
  // re-render this menu.
184
350
  const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
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
+ )
185
362
  // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
186
363
  // NodeDefinition with `capabilities.selectable`) get the floating menu
187
364
  // by default too. Phase 4 collapses these into a single registry check.
188
365
  const isValidType = node
189
- ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
366
+ ? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
367
+ (ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
190
368
  : false
191
369
 
192
370
  // Height-drag pill: shown just above the menu only while the selected
@@ -218,7 +396,7 @@ export function FloatingActionMenu() {
218
396
  })
219
397
 
220
398
  useFrame((state) => {
221
- if (!(selectedId && isValidType && groupRef.current)) return
399
+ if (!(selectedId && node && isValidType && groupRef.current)) return
222
400
 
223
401
  // Scale the HTML menu with camera zoom (ortho) or inverse distance
224
402
  // (perspective) so it feels anchored to the world, clamped on both ends
@@ -253,29 +431,59 @@ export function FloatingActionMenu() {
253
431
 
254
432
  const obj = sceneRegistry.nodes.get(selectedId)
255
433
  if (obj) {
434
+ obj.updateWorldMatrix(true, false)
435
+
256
436
  // Recompute the anchor only when the object genuinely changes —
257
437
  // reselected, moved (its own world matrix changed), or resized
258
438
  // (a fresh store node on commit, or a live override / handle drag
259
439
  // mid-resize). A spinning child changes the head's matrix, not the
260
440
  // registered group's, so it never triggers a recompute → the menu
261
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.
262
448
  const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
263
449
  const dragActive = activeHandleDrag?.nodeId === selectedId
264
450
  const selectionChanged =
265
451
  lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
266
452
  const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
267
453
 
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)
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
465
+ }
466
+
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 {
275
483
  hasAnchorRef.current = true
276
484
  }
277
485
  lastMatrixRef.current.copy(obj.matrixWorld)
278
- lastAnchorKeyRef.current = { id: selectedId, node }
486
+ lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
279
487
  }
280
488
 
281
489
  if (hasAnchorRef.current) {
@@ -291,15 +499,15 @@ export function FloatingActionMenu() {
291
499
  sfxEmitter.emit('sfx:item-pick')
292
500
  if (node.type === 'wall') {
293
501
  if (!canCurveSelectedWall) return
294
- setCurvingWall(node)
502
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
295
503
  } else if (node.type === 'fence') {
296
- setCurvingFence(node)
504
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
297
505
  } else {
298
506
  return
299
507
  }
300
508
  setSelection({ selectedIds: [] })
301
509
  },
302
- [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
510
+ [canCurveSelectedWall, node, setSelection],
303
511
  )
304
512
  const handleMove = useCallback(
305
513
  (e: React.MouseEvent) => {
@@ -328,6 +536,18 @@ export function FloatingActionMenu() {
328
536
 
329
537
  useScene.temporal.getState().pause()
330
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
+
331
551
  let duplicateInfo = structuredClone(node) as any
332
552
  delete duplicateInfo.id
333
553
  duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
@@ -431,10 +651,26 @@ export function FloatingActionMenu() {
431
651
  // item without clicking" bug. (Item has its own
432
652
  // draft-committing move tool, so it must skip the generic
433
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.
434
670
  } 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.
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.
438
674
  if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
439
675
  const pos = (duplicate as { position: [number, number, number] }).position
440
676
  ;(duplicate as { position: [number, number, number] }).position = [
@@ -442,6 +678,12 @@ export function FloatingActionMenu() {
442
678
  pos[1],
443
679
  pos[2] + 1,
444
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
445
687
  }
446
688
  useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
447
689
  }
@@ -503,11 +745,13 @@ export function FloatingActionMenu() {
503
745
  holes: [...currentHoles, newHole],
504
746
  holeMetadata: [...currentMetadata, { source: 'manual' }],
505
747
  })
506
- setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
748
+ useInteractionScope
749
+ .getState()
750
+ .begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
507
751
  // Re-assert selection so the node stays selected
508
752
  setSelection({ selectedIds: [selectedId] })
509
753
  },
510
- [node, selectedId, updateNode, setEditingHole, setSelection],
754
+ [node, selectedId, updateNode, setSelection],
511
755
  )
512
756
 
513
757
  const handleDelete = useCallback(
@@ -521,11 +765,37 @@ export function FloatingActionMenu() {
521
765
  [node?.type, selectedId, setSelection],
522
766
  )
523
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
+
524
794
  if (
525
795
  !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
526
- movingWallEndpoint ||
527
- movingFenceEndpoint ||
528
- curvingFence
796
+ endpointReshape ||
797
+ isCurveReshape ||
798
+ menuStepBack
529
799
  )
530
800
  return null
531
801
 
@@ -540,11 +810,17 @@ export function FloatingActionMenu() {
540
810
  }}
541
811
  zIndexRange={[25, 0]}
542
812
  >
543
- <div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
813
+ <div
814
+ className="relative flex flex-col items-center"
815
+ ref={menuScaleRef}
816
+ style={{ transformOrigin: 'center center' }}
817
+ >
544
818
  <NodeActionMenu
819
+ onFind={node && canFindNode ? handleFind : undefined}
545
820
  onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
546
821
  onCurve={
547
- node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
822
+ (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
823
+ (node?.type === 'wall' && canCurveSelectedWall)
548
824
  ? handleCurve
549
825
  : undefined
550
826
  }
@@ -567,6 +843,30 @@ export function FloatingActionMenu() {
567
843
  onPointerDown={(e) => e.stopPropagation()}
568
844
  onPointerUp={(e) => e.stopPropagation()}
569
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}
570
870
  {/* Height-drag dimension pill. Absolutely positioned just above
571
871
  the menu (away from the height arrow below it) so it rides the
572
872
  same scale transform + anchor, never overlaps the menu, and
@@ -584,9 +884,86 @@ export function FloatingActionMenu() {
584
884
  />
585
885
  </div>
586
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}
587
911
  </div>
588
912
  </Html>
589
913
  </group>
590
914
  </group>
591
915
  )
592
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 ? (
939
+ <>
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>
965
+ </>
966
+ )}
967
+ </div>
968
+ )
969
+ }
@@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() {
38
38
  // Read lazily at click time — no need to subscribe to nodes for a
39
39
  // one-shot action.
40
40
  const node = useScene.getState().nodes[buildingId]
41
- if (!node || node.type !== 'building') return
41
+ if (node?.type !== 'building') return
42
42
  sfxEmitter.emit('sfx:item-pick')
43
43
  setMovingNode(node as BuildingNode)
44
44
  setSelection({ buildingId: null })