@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -40,6 +40,7 @@ import {
40
40
  STAND_CAPSULE,
41
41
  STAND_CLEARANCE,
42
42
  STAND_FLOAT_HEIGHT,
43
+ setSurfaceRaycastLayers,
43
44
  useViewer,
44
45
  WALKTHROUGH_FOV,
45
46
  } from '@pascal-app/viewer'
@@ -89,6 +90,12 @@ import {
89
90
 
90
91
  const CAMERA_EYE_OFFSET = 0.45
91
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
92
99
  const CONTROLLER_CENTER_FROM_EYE = 0.85
93
100
  const DOOR_INTERACTION_DISTANCE = 2.5
94
101
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
@@ -143,10 +150,15 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
143
150
 
144
151
  const cameraOffset = new Vector3()
145
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()
146
157
  const standClearanceRaycaster = new Raycaster()
147
158
  const standClearanceUp = new Vector3(0, 1, 0)
148
159
  const centerScreenPoint = new Vector2(0, 0)
149
160
  const doorInteractionRaycaster = new Raycaster()
161
+ setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
150
162
  const doorLeafBox = new Box3()
151
163
  const doorLeafInverseMatrix = new Matrix4()
152
164
  const doorLeafLocalHit = new Vector3()
@@ -169,6 +181,7 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
169
181
  const spawnWorldPosition = new Vector3()
170
182
  const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
171
183
  const windowInteractionRaycaster = new Raycaster()
184
+ setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
172
185
  const hudBuildingLocalEyePosition = new Vector3()
173
186
  const hudWorldEyePosition = new Vector3()
174
187
  const hudLevelBounds = new Box3()
@@ -653,6 +666,7 @@ export const FirstPersonControls = () => {
653
666
  const { camera, gl } = useThree()
654
667
  const selectedLevelId = useViewer((state) => state.selection.levelId)
655
668
  const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
669
+ const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
656
670
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
657
671
  const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
658
672
  const hadPointerLockRef = useRef(false)
@@ -661,9 +675,14 @@ export const FirstPersonControls = () => {
661
675
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
662
676
  const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
663
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())
664
682
  const suspendRef = useRef(false)
665
683
  const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
666
684
  const [crouched, setCrouched] = useState(false)
685
+ const captureShutterHold = useEditor((state) => state.captureShutterHold)
667
686
  const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
668
687
  const ridingElevatorRef = useRef<{
669
688
  elevatorId: AnyNodeId
@@ -692,13 +711,19 @@ export const FirstPersonControls = () => {
692
711
  }
693
712
  }, [])
694
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.
695
718
  useEffect(() => {
696
719
  const perspectiveCamera = camera as PerspectiveCamera
697
720
  if (!perspectiveCamera.isPerspectiveCamera) return
721
+ if (useEditor.getState().isCaptureMode) return
698
722
  const previousFov = perspectiveCamera.fov
699
723
  perspectiveCamera.fov = WALKTHROUGH_FOV
700
724
  perspectiveCamera.updateProjectionMatrix()
701
725
  return () => {
726
+ if (useEditor.getState().isCaptureMode) return
702
727
  perspectiveCamera.fov = previousFov
703
728
  perspectiveCamera.updateProjectionMatrix()
704
729
  }
@@ -931,6 +956,13 @@ export const FirstPersonControls = () => {
931
956
  }, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
932
957
 
933
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
+
934
966
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
935
967
  if (!target) return
936
968
 
@@ -984,9 +1016,11 @@ export const FirstPersonControls = () => {
984
1016
  if (node?.type !== 'door' || node.openingKind === 'opening') return
985
1017
 
986
1018
  toggleDoorOpenState(doorId, { persist: false })
987
- }, [resolveInteractableTarget])
1019
+ }, [isDroneMode, resolveInteractableTarget])
988
1020
 
989
1021
  const closeInteractableTarget = useCallback(() => {
1022
+ if (isDroneMode) return
1023
+
990
1024
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
991
1025
  if (!target) return
992
1026
 
@@ -1010,7 +1044,7 @@ export const FirstPersonControls = () => {
1010
1044
  if (node?.type !== 'door' || node.openingKind === 'opening') return
1011
1045
 
1012
1046
  closeDoorOpenState(target.id, { persist: false })
1013
- }, [resolveInteractableTarget])
1047
+ }, [isDroneMode, resolveInteractableTarget])
1014
1048
 
1015
1049
  const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
1016
1050
  if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -1042,7 +1076,10 @@ export const FirstPersonControls = () => {
1042
1076
  }, [placedSpawnNode])
1043
1077
 
1044
1078
  useEffect(() => {
1045
- 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()
1046
1083
 
1047
1084
  return () => {
1048
1085
  worldRef.current?.dispose()
@@ -1052,16 +1089,39 @@ export const FirstPersonControls = () => {
1052
1089
  setElevatorColliderMeshes([])
1053
1090
  setWorld(null)
1054
1091
  }
1055
- }, [rebuildColliderWorld])
1092
+ }, [isDroneMode, rebuildColliderWorld])
1056
1093
 
1057
1094
  useEffect(() => {
1095
+ if (isDroneMode) return
1058
1096
  emitter.on('door:animation-completed', rebuildColliderWorld)
1059
1097
  emitter.on('window:animation-completed', rebuildColliderWorld)
1060
1098
  return () => {
1061
1099
  emitter.off('door:animation-completed', rebuildColliderWorld)
1062
1100
  emitter.off('window:animation-completed', rebuildColliderWorld)
1063
1101
  }
1064
- }, [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])
1065
1125
 
1066
1126
  useEffect(() => {
1067
1127
  if (!world) return
@@ -1103,11 +1163,18 @@ export const FirstPersonControls = () => {
1103
1163
  const canvas = gl.domElement
1104
1164
  const handleMouseMove = (e: MouseEvent) => {
1105
1165
  if (document.pointerLockElement !== canvas) return
1106
-
1107
- 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
1108
1175
  pitchRef.current = Math.max(
1109
1176
  -(Math.PI / 2 - 0.05),
1110
- 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),
1111
1178
  )
1112
1179
  }
1113
1180
 
@@ -1124,6 +1191,10 @@ export const FirstPersonControls = () => {
1124
1191
  if (document.pointerLockElement !== canvas) return
1125
1192
  if (event.button !== 0) return
1126
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
+
1127
1198
  event.preventDefault()
1128
1199
  event.stopPropagation()
1129
1200
  toggleInteractableTargetRef.current()
@@ -1142,6 +1213,19 @@ export const FirstPersonControls = () => {
1142
1213
  // clicking the canvas re-locks.
1143
1214
  if (suspendRef.current) return
1144
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
+
1145
1229
  if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
1146
1230
  useEditor.getState().setFirstPersonMode(false)
1147
1231
  }
@@ -1194,9 +1278,36 @@ export const FirstPersonControls = () => {
1194
1278
  // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
1195
1279
  // screenshot) must not toggle it under the user.
1196
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
1197
1294
  } else if (event.code === 'Escape') {
1198
1295
  event.preventDefault()
1199
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
+ }
1200
1311
  if (document.pointerLockElement === canvas) {
1201
1312
  document.exitPointerLock()
1202
1313
  }
@@ -1230,11 +1341,25 @@ export const FirstPersonControls = () => {
1230
1341
  if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
1231
1342
  crouchKeyRef.current = false
1232
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
+ }
1233
1353
  applyMovementKey(event, false)
1234
1354
  }
1235
1355
 
1236
1356
  const handleBlur = () => {
1237
- 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
+ }
1238
1363
  }
1239
1364
 
1240
1365
  document.addEventListener('keydown', handleKeyDown, true)
@@ -1245,7 +1370,7 @@ export const FirstPersonControls = () => {
1245
1370
  document.removeEventListener('keyup', handleKeyUp, true)
1246
1371
  window.removeEventListener('blur', handleBlur)
1247
1372
  }
1248
- }, [closeInteractableTarget, gl, toggleInteractableTarget])
1373
+ }, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget])
1249
1374
 
1250
1375
  const syncElevatorColliderMeshes = useCallback(() => {
1251
1376
  const nodes = useScene.getState().nodes
@@ -1321,6 +1446,7 @@ export const FirstPersonControls = () => {
1321
1446
  }, [])
1322
1447
 
1323
1448
  useFrame(() => {
1449
+ if (isDroneMode) return
1324
1450
  syncElevatorColliderMeshes()
1325
1451
  }, -1)
1326
1452
 
@@ -1480,7 +1606,49 @@ export const FirstPersonControls = () => {
1480
1606
  return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1481
1607
  }, [])
1482
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
+
1483
1650
  useFrame((_, delta) => {
1651
+ if (isDroneMode) return
1484
1652
  if (!controllerRef.current?.group) return
1485
1653
 
1486
1654
  const group = controllerRef.current.group
@@ -1562,7 +1730,7 @@ export const FirstPersonControls = () => {
1562
1730
  [world, elevatorColliderMeshes],
1563
1731
  )
1564
1732
 
1565
- if (!world) {
1733
+ if (isDroneMode || !world) {
1566
1734
  return null
1567
1735
  }
1568
1736
 
@@ -1594,7 +1762,7 @@ export const FirstPersonControls = () => {
1594
1762
  maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
1595
1763
  maxSlope={1.2}
1596
1764
  maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
1597
- paused={isElevatorRideLocked}
1765
+ paused={isElevatorRideLocked || captureShutterHold}
1598
1766
  position={controllerStart.position}
1599
1767
  ref={setControllerApi}
1600
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.
@@ -301,10 +291,7 @@ export function FloatingActionMenu() {
301
291
  const activeHandleDrag = useActiveHandleDrag()
302
292
  // R/T rotation axis for kinds with full 3D orientation (duct fittings).
303
293
  const rotationAxis = useEditor((s) => s.rotationAxis)
304
- // The floating action menu is an action-conflicting control: hard-hidden
305
- // during any active interaction so it never competes with the live action.
306
294
  const scope = useInteractionScope((s) => s.scope)
307
- const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
308
295
 
309
296
  const groupRef = useRef<THREE.Group>(null)
310
297
  const menuScaleRef = useRef<HTMLDivElement>(null)
@@ -366,6 +353,7 @@ export function FloatingActionMenu() {
366
353
  activeHandleDrag?.nodeId === selectedId &&
367
354
  activeHandleDrag?.label === 'height'
368
355
  const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
356
+ const menuVisibility = resolveFloatingActionMenuVisibility(scope, isHeightDragPill)
369
357
 
370
358
  // Boolean selector, only re-renders when curving availability actually flips.
371
359
  const canCurveSelectedWall = useScene((s) => {
@@ -392,12 +380,7 @@ export function FloatingActionMenu() {
392
380
  // so it stays readable at extreme zoom-out and doesn't fill the screen
393
381
  // when zoomed in close.
394
382
  if (menuScaleRef.current) {
395
- const raw =
396
- state.camera instanceof THREE.OrthographicCamera
397
- ? state.camera.zoom / REF_ORTHO_ZOOM
398
- : REF_CAMERA_DISTANCE /
399
- Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
400
- const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
383
+ const scale = getFloatingMenuScale(state.camera, groupRef.current.position)
401
384
  menuScaleRef.current.style.transform = `scale(${scale})`
402
385
  }
403
386
 
@@ -781,7 +764,7 @@ export function FloatingActionMenu() {
781
764
  !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
782
765
  endpointReshape ||
783
766
  isCurveReshape ||
784
- menuStepBack
767
+ !menuVisibility.root
785
768
  )
786
769
  return null
787
770
 
@@ -801,35 +784,43 @@ export function FloatingActionMenu() {
801
784
  ref={menuScaleRef}
802
785
  style={{ transformOrigin: 'center center' }}
803
786
  >
804
- <NodeActionMenu
805
- onFind={node && canFindNode ? handleFind : undefined}
806
- onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
807
- onCurve={
808
- (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
809
- (node?.type === 'wall' && canCurveSelectedWall)
810
- ? handleCurve
811
- : undefined
812
- }
813
- onMove={
814
- // Fully registry-driven: any kind that declares
815
- // `capabilities.movable`, a `floorplanMoveTarget`, or a
816
- // 3D `affordanceTools.move` mover gets the Move button.
817
- // Adding a new movable kind never touches this file.
818
- node && isRegistryMovable(node.type) ? handleMove : undefined
819
- }
820
- onDelete={handleDelete}
821
- onDuplicate={
822
- node &&
823
- node.type !== 'spawn' &&
824
- !DELETE_ONLY_TYPES.includes(node.type) &&
825
- !HOLE_TYPES.includes(node.type)
826
- ? handleDuplicate
827
- : undefined
828
- }
829
- onPointerDown={(e) => e.stopPropagation()}
830
- onPointerUp={(e) => e.stopPropagation()}
831
- />
832
- {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 ? (
833
824
  <div
834
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"
835
826
  onPointerDown={(e) => e.stopPropagation()}