@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -21,9 +21,8 @@ import {
21
21
  openElevatorDoor,
22
22
  pointInPolygon2D,
23
23
  requestElevatorLevel,
24
- resolveElevatorBuildingLevels,
25
24
  resolveElevatorDispatchTarget,
26
- resolveElevatorServiceLevels,
25
+ resolveElevatorLevels,
27
26
  sceneRegistry,
28
27
  useInteractive,
29
28
  useScene,
@@ -41,6 +40,7 @@ import {
41
40
  STAND_CAPSULE,
42
41
  STAND_CLEARANCE,
43
42
  STAND_FLOAT_HEIGHT,
43
+ setSurfaceRaycastLayers,
44
44
  useViewer,
45
45
  WALKTHROUGH_FOV,
46
46
  } from '@pascal-app/viewer'
@@ -90,6 +90,12 @@ import {
90
90
 
91
91
  const CAMERA_EYE_OFFSET = 0.45
92
92
  const LOOK_SENSITIVITY = 0.002
93
+ // Drone mode: metres per second, and how hard Shift boosts it. The smoothing
94
+ // constant is an exponential approach rate, not a linear acceleration.
95
+ const DRONE_SPEED = 7
96
+ const DRONE_RUN_MULTIPLIER = 3
97
+ const DRONE_SLOW_MULTIPLIER = 0.2
98
+ const DRONE_SMOOTHING = 12
93
99
  const CONTROLLER_CENTER_FROM_EYE = 0.85
94
100
  const DOOR_INTERACTION_DISTANCE = 2.5
95
101
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
@@ -144,10 +150,15 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
144
150
 
145
151
  const cameraOffset = new Vector3()
146
152
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
153
+ const droneEuler = new Euler(0, 0, 0, 'YXZ')
154
+ const droneForward = new Vector3()
155
+ const droneRight = new Vector3()
156
+ const droneDesiredVelocity = new Vector3()
147
157
  const standClearanceRaycaster = new Raycaster()
148
158
  const standClearanceUp = new Vector3(0, 1, 0)
149
159
  const centerScreenPoint = new Vector2(0, 0)
150
160
  const doorInteractionRaycaster = new Raycaster()
161
+ setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
151
162
  const doorLeafBox = new Box3()
152
163
  const doorLeafInverseMatrix = new Matrix4()
153
164
  const doorLeafLocalHit = new Vector3()
@@ -170,6 +181,7 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
170
181
  const spawnWorldPosition = new Vector3()
171
182
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
172
183
  const windowInteractionRaycaster = new Raycaster()
184
+ setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
173
185
  const hudBuildingLocalEyePosition = new Vector3()
174
186
  const hudWorldEyePosition = new Vector3()
175
187
  const hudLevelBounds = new Box3()
@@ -430,45 +442,6 @@ function isInsideElevatorCab(
430
442
  )
431
443
  }
432
444
 
433
- function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
434
- const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
435
- const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
436
-
437
- const baseYByLevelId = new Map<string, number>()
438
- let cumulativeY = 0
439
- for (const level of allLevels) {
440
- const elevation = levelElevations.get(level.id)
441
- const baseY = elevation?.baseY ?? 0
442
- baseYByLevelId.set(level.id, baseY)
443
- cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
444
- }
445
-
446
- const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
447
- const entries = serviceLevels.map((level) => ({
448
- baseY: baseYByLevelId.get(level.id) ?? 0,
449
- id: level.id as AnyNodeId,
450
- }))
451
- const firstServedLevel = serviceLevels[0] ?? null
452
- const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
453
- const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
454
- const lastServedIndex = lastServedLevel
455
- ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
456
- : -1
457
- const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
458
- const shaftTopY = nextLevel
459
- ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
460
- : lastServedLevel
461
- ? cumulativeY
462
- : elevator.cabHeight + 0.3
463
-
464
- return {
465
- entries,
466
- shaftBaseY,
467
- shaftTopY,
468
- totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
469
- }
470
- }
471
-
472
445
  function createElevatorColliderMesh(
473
446
  elevatorId: AnyNodeId,
474
447
  kind: ElevatorColliderKind,
@@ -519,10 +492,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
519
492
  const node = nodes[typedElevatorId]
520
493
  if (node?.type !== 'elevator' || node.visible === false) continue
521
494
 
522
- const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
523
- node,
524
- nodes,
525
- )
495
+ const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
526
496
  const cabWidth = getElevatorCabWidth(node)
527
497
  const cabDepth = getElevatorCabDepth(node)
528
498
  const shaftWidth = getElevatorShaftWidth(node, cabWidth)
@@ -696,6 +666,7 @@ export const FirstPersonControls = () => {
696
666
  const { camera, gl } = useThree()
697
667
  const selectedLevelId = useViewer((state) => state.selection.levelId)
698
668
  const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
669
+ const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
699
670
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
700
671
  const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
701
672
  const hadPointerLockRef = useRef(false)
@@ -704,9 +675,14 @@ export const FirstPersonControls = () => {
704
675
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
705
676
  const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
706
677
  const crouchKeyRef = useRef(false)
678
+ const droneAscendKeyRef = useRef(false)
679
+ const droneSlowKeyRef = useRef(false)
680
+ const droneDescendKeyRef = useRef(false)
681
+ const droneVelocityRef = useRef(new Vector3())
707
682
  const suspendRef = useRef(false)
708
683
  const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
709
684
  const [crouched, setCrouched] = useState(false)
685
+ const captureShutterHold = useEditor((state) => state.captureShutterHold)
710
686
  const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
711
687
  const ridingElevatorRef = useRef<{
712
688
  elevatorId: AnyNodeId
@@ -735,13 +711,19 @@ export const FirstPersonControls = () => {
735
711
  }
736
712
  }, [])
737
713
 
714
+ // While a snapshot is being framed the capture rig owns the fov (the user is
715
+ // driving it from the overlay's slider), so walkthrough neither applies its
716
+ // own nor restores one underneath it. Read imperatively: this must not re-run
717
+ // — and therefore restore — when capture mode toggles mid-walkthrough.
738
718
  useEffect(() => {
739
719
  const perspectiveCamera = camera as PerspectiveCamera
740
720
  if (!perspectiveCamera.isPerspectiveCamera) return
721
+ if (useEditor.getState().isCaptureMode) return
741
722
  const previousFov = perspectiveCamera.fov
742
723
  perspectiveCamera.fov = WALKTHROUGH_FOV
743
724
  perspectiveCamera.updateProjectionMatrix()
744
725
  return () => {
726
+ if (useEditor.getState().isCaptureMode) return
745
727
  perspectiveCamera.fov = previousFov
746
728
  perspectiveCamera.updateProjectionMatrix()
747
729
  }
@@ -974,6 +956,13 @@ export const FirstPersonControls = () => {
974
956
  }, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
975
957
 
976
958
  const toggleInteractableTarget = useCallback(() => {
959
+ // Drone is a camera, not an avatar: the click that re-acquires pointer lock
960
+ // must not swing a door open under the shot being framed. (In capture
961
+ // mode's walk camera the CLICK path is gated at handleMouseDown — there a
962
+ // locked-pointer click is the shutter — but E/R still open doors, so the
963
+ // photographer can stage the shot.)
964
+ if (isDroneMode) return
965
+
977
966
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
978
967
  if (!target) return
979
968
 
@@ -1027,9 +1016,11 @@ export const FirstPersonControls = () => {
1027
1016
  if (node?.type !== 'door' || node.openingKind === 'opening') return
1028
1017
 
1029
1018
  toggleDoorOpenState(doorId, { persist: false })
1030
- }, [resolveInteractableTarget])
1019
+ }, [isDroneMode, resolveInteractableTarget])
1031
1020
 
1032
1021
  const closeInteractableTarget = useCallback(() => {
1022
+ if (isDroneMode) return
1023
+
1033
1024
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
1034
1025
  if (!target) return
1035
1026
 
@@ -1053,7 +1044,7 @@ export const FirstPersonControls = () => {
1053
1044
  if (node?.type !== 'door' || node.openingKind === 'opening') return
1054
1045
 
1055
1046
  closeDoorOpenState(target.id, { persist: false })
1056
- }, [resolveInteractableTarget])
1047
+ }, [isDroneMode, resolveInteractableTarget])
1057
1048
 
1058
1049
  const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
1059
1050
  if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -1085,7 +1076,10 @@ export const FirstPersonControls = () => {
1085
1076
  }, [placedSpawnNode])
1086
1077
 
1087
1078
  useEffect(() => {
1088
- rebuildColliderWorld()
1079
+ // Drone has no gravity, no floor and no collision, so the BVH collider world
1080
+ // (a full-scene traversal, rebuilt on every door/window animation) is pure
1081
+ // cost there. Switching modes disposes it and rebuilds on the way back.
1082
+ if (!isDroneMode) rebuildColliderWorld()
1089
1083
 
1090
1084
  return () => {
1091
1085
  worldRef.current?.dispose()
@@ -1095,16 +1089,39 @@ export const FirstPersonControls = () => {
1095
1089
  setElevatorColliderMeshes([])
1096
1090
  setWorld(null)
1097
1091
  }
1098
- }, [rebuildColliderWorld])
1092
+ }, [isDroneMode, rebuildColliderWorld])
1099
1093
 
1100
1094
  useEffect(() => {
1095
+ if (isDroneMode) return
1101
1096
  emitter.on('door:animation-completed', rebuildColliderWorld)
1102
1097
  emitter.on('window:animation-completed', rebuildColliderWorld)
1103
1098
  return () => {
1104
1099
  emitter.off('door:animation-completed', rebuildColliderWorld)
1105
1100
  emitter.off('window:animation-completed', rebuildColliderWorld)
1106
1101
  }
1107
- }, [rebuildColliderWorld])
1102
+ }, [isDroneMode, rebuildColliderWorld])
1103
+
1104
+ // A walk session started before a drone detour would otherwise resume at its
1105
+ // original spawn; drop it so the next walk re-derives one.
1106
+ useEffect(() => {
1107
+ if (isDroneMode) setControllerStart(null)
1108
+ }, [isDroneMode])
1109
+
1110
+ // Drone picks up wherever the camera currently is — orbit pose or walk eye.
1111
+ useEffect(() => {
1112
+ if (!isDroneMode) return
1113
+ droneEuler.setFromQuaternion(camera.quaternion)
1114
+ yawRef.current = droneEuler.y
1115
+ pitchRef.current = droneEuler.x
1116
+ droneVelocityRef.current.set(0, 0, 0)
1117
+
1118
+ // Interaction prompts belong to walk; drop whatever its frame left behind.
1119
+ if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
1120
+ useViewer.getState().setHoveredId(null)
1121
+ }
1122
+ interactableTargetRef.current = null
1123
+ useFirstPersonHud.getState().reset()
1124
+ }, [camera, isDroneMode])
1108
1125
 
1109
1126
  useEffect(() => {
1110
1127
  if (!world) return
@@ -1146,11 +1163,18 @@ export const FirstPersonControls = () => {
1146
1163
  const canvas = gl.domElement
1147
1164
  const handleMouseMove = (e: MouseEvent) => {
1148
1165
  if (document.pointerLockElement !== canvas) return
1149
-
1150
- yawRef.current -= e.movementX * LOOK_SENSITIVITY
1166
+ // Shutter hold: the shot is rendering — a mouse twitch must not pan it.
1167
+ if (useEditor.getState().captureShutterHold) return
1168
+
1169
+ const lookSensitivity =
1170
+ LOOK_SENSITIVITY *
1171
+ (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1172
+ ? DRONE_SLOW_MULTIPLIER
1173
+ : 1)
1174
+ yawRef.current -= e.movementX * lookSensitivity
1151
1175
  pitchRef.current = Math.max(
1152
1176
  -(Math.PI / 2 - 0.05),
1153
- Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
1177
+ Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity),
1154
1178
  )
1155
1179
  }
1156
1180
 
@@ -1167,6 +1191,10 @@ export const FirstPersonControls = () => {
1167
1191
  if (document.pointerLockElement !== canvas) return
1168
1192
  if (event.button !== 0) return
1169
1193
 
1194
+ // Capture mode: the locked-pointer click is the SHUTTER (the snapshot
1195
+ // overlay's window-capture listener already fired); doors stay on E/R.
1196
+ if (useEditor.getState().isCaptureMode) return
1197
+
1170
1198
  event.preventDefault()
1171
1199
  event.stopPropagation()
1172
1200
  toggleInteractableTargetRef.current()
@@ -1185,6 +1213,19 @@ export const FirstPersonControls = () => {
1185
1213
  // clicking the canvas re-locks.
1186
1214
  if (suspendRef.current) return
1187
1215
 
1216
+ // Capture mode: Esc (the browser's own unlock — no keydown reaches us)
1217
+ // acts like P. Dropping back to orbit would throw away the framed pose,
1218
+ // which reads as a crash to anyone who never noticed P.
1219
+ if (
1220
+ hadPointerLockRef.current &&
1221
+ useEditor.getState().isCaptureMode &&
1222
+ useEditor.getState().isFirstPersonMode
1223
+ ) {
1224
+ suspendRef.current = true
1225
+ useViewer.getState().setWalkthroughSuspended(true)
1226
+ return
1227
+ }
1228
+
1188
1229
  if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1189
1230
  useEditor.getState().setFirstPersonMode(false)
1190
1231
  }
@@ -1237,9 +1278,36 @@ export const FirstPersonControls = () => {
1237
1278
  // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
1238
1279
  // screenshot) must not toggle it under the user.
1239
1280
  if (!suspendRef.current) crouchKeyRef.current = true
1281
+ } else if (event.code === 'KeyQ') {
1282
+ // Drone descend. Space (already bound to jump) and E are the matching ascend.
1283
+ event.preventDefault()
1284
+ event.stopPropagation()
1285
+ if (!suspendRef.current) droneDescendKeyRef.current = true
1286
+ } else if (event.code === 'KeyE' && isDroneMode) {
1287
+ event.preventDefault()
1288
+ event.stopPropagation()
1289
+ if (!suspendRef.current) droneAscendKeyRef.current = true
1290
+ } else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) {
1291
+ event.preventDefault()
1292
+ event.stopPropagation()
1293
+ if (!suspendRef.current) droneSlowKeyRef.current = true
1240
1294
  } else if (event.code === 'Escape') {
1241
1295
  event.preventDefault()
1242
1296
  event.stopPropagation()
1297
+ // Capture mode, first Esc frees the cursor (see handlePointerLockChange
1298
+ // — while locked the browser usually unlocks without delivering the
1299
+ // keydown); with the cursor already free, Esc cancels the snapshot
1300
+ // (setCaptureMode(false) also lands the camera back on orbit).
1301
+ if (useEditor.getState().isCaptureMode) {
1302
+ if (document.pointerLockElement === canvas) {
1303
+ suspendRef.current = true
1304
+ useViewer.getState().setWalkthroughSuspended(true)
1305
+ document.exitPointerLock()
1306
+ } else {
1307
+ useEditor.getState().setCaptureMode(false)
1308
+ }
1309
+ return
1310
+ }
1243
1311
  if (document.pointerLockElement === canvas) {
1244
1312
  document.exitPointerLock()
1245
1313
  }
@@ -1273,11 +1341,25 @@ export const FirstPersonControls = () => {
1273
1341
  if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
1274
1342
  crouchKeyRef.current = false
1275
1343
  }
1344
+ if (event.code === 'KeyQ' && !suspendRef.current) {
1345
+ droneDescendKeyRef.current = false
1346
+ }
1347
+ if (event.code === 'KeyE' && !suspendRef.current) {
1348
+ droneAscendKeyRef.current = false
1349
+ }
1350
+ if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) {
1351
+ droneSlowKeyRef.current = false
1352
+ }
1276
1353
  applyMovementKey(event, false)
1277
1354
  }
1278
1355
 
1279
1356
  const handleBlur = () => {
1280
- if (!suspendRef.current) crouchKeyRef.current = false
1357
+ if (!suspendRef.current) {
1358
+ crouchKeyRef.current = false
1359
+ droneAscendKeyRef.current = false
1360
+ droneDescendKeyRef.current = false
1361
+ droneSlowKeyRef.current = false
1362
+ }
1281
1363
  }
1282
1364
 
1283
1365
  document.addEventListener('keydown', handleKeyDown, true)
@@ -1288,7 +1370,7 @@ export const FirstPersonControls = () => {
1288
1370
  document.removeEventListener('keyup', handleKeyUp, true)
1289
1371
  window.removeEventListener('blur', handleBlur)
1290
1372
  }
1291
- }, [closeInteractableTarget, gl, toggleInteractableTarget])
1373
+ }, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget])
1292
1374
 
1293
1375
  const syncElevatorColliderMeshes = useCallback(() => {
1294
1376
  const nodes = useScene.getState().nodes
@@ -1364,6 +1446,7 @@ export const FirstPersonControls = () => {
1364
1446
  }, [])
1365
1447
 
1366
1448
  useFrame(() => {
1449
+ if (isDroneMode) return
1367
1450
  syncElevatorColliderMeshes()
1368
1451
  }, -1)
1369
1452
 
@@ -1523,7 +1606,49 @@ export const FirstPersonControls = () => {
1523
1606
  return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1524
1607
  }, [])
1525
1608
 
1609
+ // Drone: a free camera driven straight from the look angles — no controller,
1610
+ // no gravity or collision clamping. WASD move along the view axes, Space or E
1611
+ // rises, Q (or Ctrl) sinks, and Shift boosts.
1612
+ useFrame((_, delta) => {
1613
+ if (!isDroneMode) return
1614
+ // Shutter hold: freeze the drone mid-air while the shot renders.
1615
+ if (useEditor.getState().captureShutterHold) return
1616
+
1617
+ const step = Math.min(delta, 0.1)
1618
+ const movement = movementInputRef.current
1619
+
1620
+ droneEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
1621
+ camera.quaternion.setFromEuler(droneEuler)
1622
+ droneForward.set(0, 0, -1).applyEuler(droneEuler)
1623
+ droneRight.set(1, 0, 0).applyEuler(droneEuler)
1624
+
1625
+ droneDesiredVelocity.set(0, 0, 0)
1626
+ if (movement.forward) droneDesiredVelocity.add(droneForward)
1627
+ if (movement.backward) droneDesiredVelocity.sub(droneForward)
1628
+ if (movement.rightward) droneDesiredVelocity.add(droneRight)
1629
+ if (movement.leftward) droneDesiredVelocity.sub(droneRight)
1630
+ if (movement.jump || droneAscendKeyRef.current) droneDesiredVelocity.y += 1
1631
+ if (droneDescendKeyRef.current || crouchKeyRef.current) droneDesiredVelocity.y -= 1
1632
+ if (droneDesiredVelocity.lengthSq() > 0) {
1633
+ droneDesiredVelocity
1634
+ .normalize()
1635
+ .multiplyScalar(
1636
+ DRONE_SPEED *
1637
+ (droneSlowKeyRef.current
1638
+ ? DRONE_SLOW_MULTIPLIER
1639
+ : movement.run
1640
+ ? DRONE_RUN_MULTIPLIER
1641
+ : 1),
1642
+ )
1643
+ }
1644
+
1645
+ droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING))
1646
+ camera.position.addScaledVector(droneVelocityRef.current, step)
1647
+ camera.updateMatrixWorld(true)
1648
+ }, 2.5)
1649
+
1526
1650
  useFrame((_, delta) => {
1651
+ if (isDroneMode) return
1527
1652
  if (!controllerRef.current?.group) return
1528
1653
 
1529
1654
  const group = controllerRef.current.group
@@ -1605,7 +1730,7 @@ export const FirstPersonControls = () => {
1605
1730
  [world, elevatorColliderMeshes],
1606
1731
  )
1607
1732
 
1608
- if (!world) {
1733
+ if (isDroneMode || !world) {
1609
1734
  return null
1610
1735
  }
1611
1736
 
@@ -1637,7 +1762,7 @@ export const FirstPersonControls = () => {
1637
1762
  maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
1638
1763
  maxSlope={1.2}
1639
1764
  maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
1640
- paused={isElevatorRideLocked}
1765
+ paused={isElevatorRideLocked || captureShutterHold}
1641
1766
  position={controllerStart.position}
1642
1767
  ref={setControllerApi}
1643
1768
  />
@@ -43,12 +43,13 @@ import * as THREE from 'three'
43
43
  import { useShallow } from 'zustand/react/shallow'
44
44
  import { useReducedMotion } from '../../hooks/use-reduced-motion'
45
45
  import { resolveMoveActionNode } from '../../lib/direct-manipulation'
46
+ import { getFloatingMenuScale } from '../../lib/floating-menu-scale'
46
47
  import {
47
48
  createFreshPlacementSubtree,
48
49
  duplicatesAsFreshSubtree,
49
50
  prepareFreshPlacementRootDuplicate,
50
51
  } from '../../lib/fresh-planar-placement'
51
- import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
52
+ import { resolveFloatingActionMenuVisibility } from '../../lib/interaction/overlay-policy'
52
53
  import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
53
54
  import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
54
55
  import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
@@ -99,17 +100,6 @@ const ALLOWED_TYPES = [
99
100
  const DELETE_ONLY_TYPES: string[] = []
100
101
  const HOLE_TYPES = ['slab', 'ceiling']
101
102
 
102
- // Menu scales with camera zoom so it feels anchored to the object, but is
103
- // clamped on both ends so it stays readable when zoomed way out and doesn't
104
- // dominate the screen when zoomed in close. Reference values are picked so
105
- // scale = 1 lands near the editor's default framing.
106
- const MIN_MENU_SCALE = 0.5
107
- // Cap at 1 so zooming in doesn't grow the menu past its default pixel size —
108
- // only zoom-out shrinks it (down to MIN_MENU_SCALE).
109
- const MAX_MENU_SCALE = 1
110
- const REF_ORTHO_ZOOM = 20
111
- const REF_CAMERA_DISTANCE = 12
112
-
113
103
  // World-space Y distance from a node's bbox top to the floating menu anchor.
114
104
  // Per-type because in-world chrome above the node (height-resize arrows,
115
105
  // measurement labels) varies in vertical reach.
@@ -294,16 +284,14 @@ export function FloatingActionMenu() {
294
284
  const setMovingNode = useEditor((s) => s.setMovingNode)
295
285
  const setSelection = useViewer((s) => s.setSelection)
296
286
  const unit = useViewer((s) => s.unit)
287
+ const metricNotation = useViewer((s) => s.metricNotation)
297
288
  // Drives the height-drag dimension pill below the menu. `activeHandleDrag`
298
289
  // flips only at drag start / end, so subscribing here is cheap — the live
299
290
  // height value is written imperatively in the useFrame below.
300
291
  const activeHandleDrag = useActiveHandleDrag()
301
292
  // R/T rotation axis for kinds with full 3D orientation (duct fittings).
302
293
  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
294
  const scope = useInteractionScope((s) => s.scope)
306
- const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
307
295
 
308
296
  const groupRef = useRef<THREE.Group>(null)
309
297
  const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -365,6 +353,7 @@ export function FloatingActionMenu() {
365
353
  activeHandleDrag?.nodeId === selectedId &&
366
354
  activeHandleDrag?.label === 'height'
367
355
  const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
356
+ const menuVisibility = resolveFloatingActionMenuVisibility(scope, isHeightDragPill)
368
357
 
369
358
  // Boolean selector, only re-renders when curving availability actually flips.
370
359
  const canCurveSelectedWall = useScene((s) => {
@@ -391,12 +380,7 @@ export function FloatingActionMenu() {
391
380
  // so it stays readable at extreme zoom-out and doesn't fill the screen
392
381
  // when zoomed in close.
393
382
  if (menuScaleRef.current) {
394
- const raw =
395
- state.camera instanceof THREE.OrthographicCamera
396
- ? state.camera.zoom / REF_ORTHO_ZOOM
397
- : REF_CAMERA_DISTANCE /
398
- Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
399
- const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
383
+ const scale = getFloatingMenuScale(state.camera, groupRef.current.position)
400
384
  menuScaleRef.current.style.transform = `scale(${scale})`
401
385
  }
402
386
 
@@ -417,7 +401,7 @@ export function FloatingActionMenu() {
417
401
  ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
418
402
  : FENCE_DEFAULT_HEIGHT
419
403
  const liveHeight = override?.height ?? node.height ?? fallbackHeight
420
- pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
404
+ pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}`
421
405
  }
422
406
 
423
407
  const obj = sceneRegistry.nodes.get(selectedId)
@@ -780,7 +764,7 @@ export function FloatingActionMenu() {
780
764
  !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
781
765
  endpointReshape ||
782
766
  isCurveReshape ||
783
- menuStepBack
767
+ !menuVisibility.root
784
768
  )
785
769
  return null
786
770
 
@@ -800,35 +784,43 @@ export function FloatingActionMenu() {
800
784
  ref={menuScaleRef}
801
785
  style={{ transformOrigin: 'center center' }}
802
786
  >
803
- <NodeActionMenu
804
- onFind={node && canFindNode ? handleFind : undefined}
805
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
806
- onCurve={
807
- (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
808
- (node?.type === 'wall' && canCurveSelectedWall)
809
- ? handleCurve
810
- : undefined
811
- }
812
- onMove={
813
- // Fully registry-driven: any kind that declares
814
- // `capabilities.movable`, a `floorplanMoveTarget`, or a
815
- // 3D `affordanceTools.move` mover gets the Move button.
816
- // Adding a new movable kind never touches this file.
817
- node && isRegistryMovable(node.type) ? handleMove : undefined
818
- }
819
- onDelete={handleDelete}
820
- onDuplicate={
821
- node &&
822
- node.type !== 'spawn' &&
823
- !DELETE_ONLY_TYPES.includes(node.type) &&
824
- !HOLE_TYPES.includes(node.type)
825
- ? handleDuplicate
826
- : undefined
827
- }
828
- onPointerDown={(e) => e.stopPropagation()}
829
- onPointerUp={(e) => e.stopPropagation()}
830
- />
831
- {quickActions.length > 0 ? (
787
+ {menuVisibility.actions ? (
788
+ <NodeActionMenu
789
+ onFind={
790
+ node &&
791
+ canFindNode &&
792
+ nodeRegistry.get(node.type)?.presentation?.findInCatalog !== false
793
+ ? handleFind
794
+ : undefined
795
+ }
796
+ onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
797
+ onCurve={
798
+ (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
799
+ (node?.type === 'wall' && canCurveSelectedWall)
800
+ ? handleCurve
801
+ : undefined
802
+ }
803
+ onMove={
804
+ // Fully registry-driven: any kind that declares
805
+ // `capabilities.movable`, a `floorplanMoveTarget`, or a
806
+ // 3D `affordanceTools.move` mover gets the Move button.
807
+ // Adding a new movable kind never touches this file.
808
+ node && isRegistryMovable(node.type) ? handleMove : undefined
809
+ }
810
+ onDelete={handleDelete}
811
+ onDuplicate={
812
+ node &&
813
+ node.type !== 'spawn' &&
814
+ !DELETE_ONLY_TYPES.includes(node.type) &&
815
+ !HOLE_TYPES.includes(node.type)
816
+ ? handleDuplicate
817
+ : undefined
818
+ }
819
+ onPointerDown={(e) => e.stopPropagation()}
820
+ onPointerUp={(e) => e.stopPropagation()}
821
+ />
822
+ ) : null}
823
+ {menuVisibility.actions && quickActions.length > 0 ? (
832
824
  <div
833
825
  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
826
  onPointerDown={(e) => e.stopPropagation()}
@@ -884,7 +876,7 @@ export function FloatingActionMenu() {
884
876
  under it for duct fittings. */}
885
877
  {node && hasPorts(node.type) ? (
886
878
  <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} />
879
+ <SystemSummaryPill metricNotation={metricNotation} nodeId={node.id} unit={unit} />
888
880
  {hasAxisCycling(node.type) ? (
889
881
  <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
882
  <span className="font-medium text-foreground">
@@ -918,7 +910,15 @@ export function FloatingActionMenu() {
918
910
  * subscription it needs (connectivity changes when ANY joint moves) doesn't
919
911
  * re-render the always-mounted parent menu on every unrelated scene tick.
920
912
  */
921
- function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
913
+ function SystemSummaryPill({
914
+ metricNotation,
915
+ nodeId,
916
+ unit,
917
+ }: {
918
+ metricNotation: 'meters' | 'millimeters'
919
+ nodeId: AnyNodeId
920
+ unit: 'metric' | 'imperial'
921
+ }) {
922
922
  const allNodes = useScene((s) => s.nodes)
923
923
  const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
924
924
  if (!summary) return null
@@ -935,7 +935,7 @@ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric'
935
935
  ·
936
936
  </span>
937
937
  <span className="text-muted-foreground">
938
- {formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
938
+ {formatMeasurement(summary.runLengthM, unit, metricNotation)} · {summary.runCount}{' '}
939
939
  {summary.runCount === 1 ? 'run' : 'runs'}
940
940
  </span>
941
941
  </>