@pascal-app/editor 1.0.6 → 1.0.7

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 (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Pascal building editor component",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.tsx",
9
9
  "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx",
10
+ "./locales": "./src/i18n/locales.ts",
11
+ "./locales/pseudo": "./src/i18n/pseudo-locale.ts",
10
12
  "./procedural-items": "./src/components/procedural-items/inspector.tsx",
11
13
  "./distribution": "./src/components/tools/distribution/distribution-public.ts"
12
14
  },
@@ -16,23 +18,24 @@
16
18
  "test": "bun test src"
17
19
  },
18
20
  "peerDependencies": {
19
- "@pascal-app/core": "^1.0.6",
20
- "@pascal-app/viewer": "^1.0.6",
21
+ "@pascal-app/core": "^1.0.7",
22
+ "@pascal-app/viewer": "^1.0.7",
21
23
  "@react-three/drei": "^10",
22
24
  "@react-three/fiber": "^9",
23
25
  "next": ">=15",
24
26
  "react": "^18 || ^19",
25
27
  "react-dom": "^18 || ^19",
26
- "three": "^0.186"
28
+ "three": "^0.187"
27
29
  },
28
30
  "dependencies": {
29
31
  "@dnd-kit/core": "^6.3.1",
30
32
  "@dnd-kit/sortable": "^10.0.0",
31
33
  "@dnd-kit/utilities": "^3.2.2",
32
- "@iconify/react": "^6.0.3",
34
+ "@iconify/react": "^6.0.4",
35
+ "@lingui/core": "6.12.0",
33
36
  "@number-flow/react": "^0.6.2",
34
- "@pascal-app/geometry-script": "^1.0.6",
35
- "@pascal-app/ifc-converter": "^1.0.6",
37
+ "@pascal-app/geometry-script": "^1.0.7",
38
+ "@pascal-app/ifc-converter": "^1.0.7",
36
39
  "@pascal-app/lingo": "^0.5.0",
37
40
  "@radix-ui/react-alert-dialog": "^1.2.1",
38
41
  "@radix-ui/react-context-menu": "^2.4.1",
@@ -53,31 +56,31 @@
53
56
  "cmdk": "^1.1.1",
54
57
  "fflate": "^0.8.3",
55
58
  "howler": "^2.2.4",
56
- "lucide-react": "^1.61.0",
59
+ "lucide-react": "^1.69.0",
57
60
  "manifold-3d": "3.5.4",
58
61
  "mitt": "^3.0.1",
59
- "motion": "^14.6.0",
62
+ "motion": "^14.9.1",
60
63
  "nanoid": "^6.0.2",
61
64
  "pdfkit": "^0.20.2",
62
65
  "tailwind-merge": "^3.7.0",
63
66
  "three-bvh-csg": "^0.0.18",
64
- "three-mesh-bvh": "~0.9.16",
67
+ "three-mesh-bvh": "~0.9.18",
65
68
  "zod": ">=4.6.7 <4.7",
66
69
  "zustand": "^5.0.16"
67
70
  },
68
71
  "devDependencies": {
69
- "@pascal-app/core": "^1.0.6",
70
- "@pascal-app/viewer": "^1.0.6",
72
+ "@pascal-app/core": "^1.0.7",
73
+ "@pascal-app/viewer": "^1.0.7",
71
74
  "@pascal/typescript-config": "*",
72
75
  "@react-three/test-renderer": "^9.1.1",
73
76
  "@types/blob-stream": "^0.1.33",
74
- "@types/bun": "^1.4.3",
77
+ "@types/bun": "^1.4.4",
75
78
  "@types/howler": "^2.2.13",
76
79
  "@types/pdfkit": "^0.17.7",
77
80
  "@types/react": "19.3.0",
78
81
  "@types/react-dom": "19.3.0",
79
- "@types/three": "^0.186.0",
80
- "fast-xml-parser": "^5.11.2",
82
+ "@types/three": "^0.187",
83
+ "fast-xml-parser": "^5.12.0",
81
84
  "typescript": "7.0.2"
82
85
  },
83
86
  "repository": {
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
+ import { usePascalI18n } from '@pascal-app/core'
3
4
  import type { MouseEventHandler, ReactNode } from 'react'
5
+ import { toolHintKey } from '../../i18n/node-messages'
4
6
  import { cn } from '../../lib/utils'
5
7
  import { shortcutDisplayValue } from '../ui/primitives/shortcut-token'
6
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
@@ -37,6 +39,7 @@ export function ActionMenuButton({
37
39
  pressed?: boolean
38
40
  children: ReactNode
39
41
  }) {
42
+ const i18n = usePascalI18n()
40
43
  return (
41
44
  <Tooltip>
42
45
  <TooltipTrigger asChild>
@@ -72,7 +75,7 @@ export function ActionMenuButton({
72
75
  className="rounded border border-background/30 px-1 font-mono text-[10px] leading-4 opacity-80"
73
76
  key={key}
74
77
  >
75
- {shortcutDisplayValue(key)}
78
+ {toolHintKey(shortcutDisplayValue(key), i18n)}
76
79
  </kbd>
77
80
  ))}
78
81
  </span>
@@ -2,13 +2,16 @@
2
2
 
3
3
  import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Html } from '@react-three/drei'
6
5
  import { useFrame } from '@react-three/fiber'
7
6
  import { memo, useMemo, useRef } from 'react'
8
- import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
7
+ import { BoxGeometry, CircleGeometry, type Group, RingGeometry, Vector3 } from 'three'
9
8
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
9
  import { EDITOR_LAYER } from '../../lib/constants'
11
- import useAlignmentGuides from '../../store/use-alignment-guides'
10
+ import useAlignmentGuides, {
11
+ type DraftGuideLine,
12
+ type DraftGuides,
13
+ } from '../../store/use-alignment-guides'
14
+ import { Html } from './localized-html'
12
15
  import { formatMeasurement } from './measurement-pill'
13
16
 
14
17
  /**
@@ -22,13 +25,18 @@ import { formatMeasurement } from './measurement-pill'
22
25
  * the design reference — so they're real 3D geometry, not screen billboards.
23
26
  * Only the distance pill is screen-space (`<Html>`).
24
27
  *
25
- * Guide coordinates are XZ meters in the WORLD frame — alignment now resolves
26
- * on the world axes (via `resolveAlignmentForActiveBuilding`) so the guides
27
- * stay parallel to the visible world grid even when the active building is
28
- * rotated. This layer is mounted OUTSIDE the building-local ToolManager group
29
- * for the same reason. The whole ribbon is lifted to the active level's WORLD
30
- * Y each frame so it lies on the floor being edited — not the building base —
31
- * when floors are stacked.
28
+ * Guide coordinates are XZ meters in the active building's LOCAL frame —
29
+ * alignment resolves on the building's own axes, the axes its 3D grid is laid
30
+ * on — and the layer is mounted inside the building-local ToolManager group.
31
+ * The whole ribbon is lifted to the active level's WORLD Y each frame so it
32
+ * lies on the floor being edited — not the building base — when floors are
33
+ * stacked.
34
+ *
35
+ * A wall pulled from a wall end (and the run it continues) publishes its
36
+ * projection guides beside them (`useAlignmentGuides.draft`): the extension of
37
+ * the wall it comes from in amber, lines through other walls' ends in indigo,
38
+ * lines through the run's start in emerald, all dashed, with a ringed marker
39
+ * where two of them cross.
32
40
  */
33
41
 
34
42
  const LINE_COLOR = 0x81_8c_f8 // indigo-400 — matches the editor's selection accent (box-select / wall highlights)
@@ -59,11 +67,33 @@ const dotMaterial = new MeshBasicNodeMaterial({
59
67
  })
60
68
  const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
61
69
  const DOT_GEOMETRY = new CircleGeometry(1, 24)
70
+ const RING_GEOMETRY = new RingGeometry(0.7, 1, 28)
71
+
72
+ // Draft projection guides: one material per kind, shared like the above.
73
+ const draftGuideMaterial = (color: number) =>
74
+ new MeshBasicNodeMaterial({
75
+ color,
76
+ depthTest: false,
77
+ depthWrite: false,
78
+ toneMapped: false,
79
+ transparent: true,
80
+ opacity: 0.9,
81
+ })
82
+ const DRAFT_GUIDE_MATERIALS: Record<DraftGuideLine['kind'], MeshBasicNodeMaterial> = {
83
+ extension: draftGuideMaterial(0xf5_9e_0b), // amber-500
84
+ alignment: guideMaterial,
85
+ 'run-start': draftGuideMaterial(0x10_b9_81), // emerald-500 — the pull grip's colour
86
+ }
87
+ const DRAFT_DASH_LEN = 0.14
88
+ const DRAFT_DASH_GAP = 0.12
89
+ const DRAFT_LINE_WIDTH = 0.035
90
+ const INTERSECTION_RADIUS = 0.13
62
91
 
63
92
  type Vec3 = [number, number, number]
64
93
 
65
94
  export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
66
95
  const guides = useAlignmentGuides((s) => s.guides)
96
+ const draft = useAlignmentGuides((s) => s.draft)
67
97
  const levelId = useViewer((s) => s.selection.levelId)
68
98
  const unit = useViewer((s) => s.unit)
69
99
  const metricNotation = useViewer((s) => s.metricNotation)
@@ -82,16 +112,77 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
82
112
  group.position.y = levelMesh ? levelMesh.getWorldPosition(worldYWork).y : 0
83
113
  })
84
114
 
85
- if (guides.length === 0) return null
115
+ if (guides.length === 0 && !draft) return null
86
116
  return (
87
117
  <group ref={groupRef}>
88
118
  {guides.map((guide, i) => (
89
119
  <GuideLine guide={guide} key={i} metricNotation={metricNotation} unit={unit} />
90
120
  ))}
121
+ {draft ? <DraftGuidesLayer draft={draft} /> : null}
91
122
  </group>
92
123
  )
93
124
  })
94
125
 
126
+ /** A wall draft's dashed projection guides and the markers where they cross. */
127
+ function DraftGuidesLayer({ draft }: { draft: DraftGuides }) {
128
+ return (
129
+ <>
130
+ {draft.lines.map((line, i) => (
131
+ <DraftLine key={`l${i}`} line={line} />
132
+ ))}
133
+ {draft.intersections.map((point, i) => (
134
+ <mesh
135
+ geometry={RING_GEOMETRY}
136
+ key={`x${i}`}
137
+ layers={EDITOR_LAYER}
138
+ material={guideMaterial}
139
+ position={[point[0], GUIDE_Y + 0.002, point[1]]}
140
+ renderOrder={1001}
141
+ rotation={[-Math.PI / 2, 0, 0]}
142
+ scale={[INTERSECTION_RADIUS, INTERSECTION_RADIUS, INTERSECTION_RADIUS]}
143
+ />
144
+ ))}
145
+ </>
146
+ )
147
+ }
148
+
149
+ function DraftLine({ line }: { line: DraftGuideLine }) {
150
+ const [fx, fz] = line.from
151
+ const [tx, tz] = line.to
152
+ const { dashes, angleY } = useMemo(() => {
153
+ const dx = tx - fx
154
+ const dz = tz - fz
155
+ const length = Math.hypot(dx, dz)
156
+ const angle = -Math.atan2(dz, dx)
157
+ if (length < 1e-4) return { dashes: [] as Vec3[], angleY: angle }
158
+ const ux = dx / length
159
+ const uz = dz / length
160
+ const period = Math.max(DRAFT_DASH_LEN + DRAFT_DASH_GAP, length / MAX_DASHES)
161
+ const centres: Vec3[] = []
162
+ for (let d = period / 2; d - DRAFT_DASH_LEN / 2 <= length; d += period) {
163
+ centres.push([fx + ux * d, GUIDE_Y, fz + uz * d])
164
+ }
165
+ return { dashes: centres, angleY: angle }
166
+ }, [fx, fz, tx, tz])
167
+ const material = DRAFT_GUIDE_MATERIALS[line.kind]
168
+ return (
169
+ <>
170
+ {dashes.map((centre, i) => (
171
+ <mesh
172
+ geometry={DASH_GEOMETRY}
173
+ key={i}
174
+ layers={EDITOR_LAYER}
175
+ material={material}
176
+ position={centre}
177
+ renderOrder={1000}
178
+ rotation={[0, angleY, 0]}
179
+ scale={[DRAFT_DASH_LEN, 0.002, DRAFT_LINE_WIDTH]}
180
+ />
181
+ ))}
182
+ </>
183
+ )
184
+ }
185
+
95
186
  function GuideLine({
96
187
  guide,
97
188
  metricNotation,
@@ -47,8 +47,9 @@ const THUMBNAIL_BACKDROP = { inner: '#FAFAFA', outer: '#E4E4E4' }
47
47
  const THUMBNAIL_SHADOW_CATCHER_OPACITY = 0.7
48
48
  /** Soft shade hugging the ground-floor walls so the building never floats. */
49
49
  const THUMBNAIL_WALL_CONTACT = { opacity: 0.5, spreadM: 0.9 }
50
- /** Unpainted walls render as smooth plaster rather than the screw-dotted
51
- * prepared-drywall default, which reads as unfinished at card size. */
50
+ /** A roof's unpainted trim and gable bands render as smooth plaster rather
51
+ * than the screw-dotted prepared-drywall default, which reads as unfinished
52
+ * at card size. Unpainted walls already wear the interior paint. */
52
53
  const THUMBNAIL_SLOT_DEFAULT_OVERRIDES = {
53
54
  'library:concrete-drywall': 'library:concrete-plaster',
54
55
  } as const
@@ -62,6 +62,7 @@ import {
62
62
  orbitFloorMinY,
63
63
  overviewEyeY,
64
64
  } from '../../lib/orbit-floor-clearance'
65
+ import { orbitNearPlane } from '../../lib/orbit-near-plane'
65
66
  import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
66
67
  import { frameViewerCamera } from '../../lib/viewer-selection'
67
68
  import { publishCameraPose } from '../../store/camera-pose-store'
@@ -766,8 +767,16 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
766
767
  }, [camera, isFirstPersonMode, viewportSize])
767
768
 
768
769
  const handleCameraUpdate = useCallback(() => {
770
+ const control = controls.current
771
+ if (control && isPerspectiveCamera(camera)) {
772
+ const near = orbitNearPlane(control.distance)
773
+ if (Math.abs(camera.near - near) > 1e-4) {
774
+ camera.near = near
775
+ camera.updateProjectionMatrix()
776
+ }
777
+ }
769
778
  publishCurrentPose()
770
- }, [publishCurrentPose])
779
+ }, [camera, publishCurrentPose])
771
780
 
772
781
  useEffect(() => {
773
782
  publishInitialCameraPose(publishCurrentPose)
@@ -1054,7 +1063,10 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1054
1063
  updateNavigationCursor()
1055
1064
  }
1056
1065
 
1057
- const onWheel = () => {
1066
+ const onWheel = (event: WheelEvent) => {
1067
+ // Zooming in carries on past the closest orbit, the target with it; zooming out stops at
1068
+ // the farthest, or the eye would drift off the site. Seen before the controls' own listener.
1069
+ if (controls.current) controls.current.infinityDolly = event.deltaY < 0
1058
1070
  beginLocalCameraInteraction()
1059
1071
  cameraDraggingLifecycle.scheduleEnd()
1060
1072
  }
@@ -1452,13 +1464,12 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1452
1464
  }
1453
1465
  }, [cameraDraggingLifecycle])
1454
1466
 
1455
- // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1456
- // so the default 2m minDistance prevents the user from getting close
1457
- // enough to compose a good thumbnail. Relax the clamp to 0.5m while
1458
- // capturing presets; reset on exit so general editing keeps the looser
1459
- // navigation guardrails.
1467
+ // The wheel dollies toward the cursor and, at the closest orbit, carries the
1468
+ // target on (infinity dolly), so the eye goes right up to a window's trim
1469
+ // or through a doorway rather than stopping short of the orbit's pivot.
1470
+ // Preset capture frames a single small subtree and orbits it closer still.
1460
1471
  const isPresetCapture = captureMode.mode === 'preset'
1461
- const minDistance = isPresetCapture ? 0.5 : 2
1472
+ const minDistance = isPresetCapture ? 0.25 : 0.5
1462
1473
  // Over modeled or mapped surroundings (a neighbourhood instead of the
1463
1474
  // horizon disc) the camera may pull back far enough to take it all in.
1464
1475
  const groundReplaced = useSceneGroundReplacement()
@@ -1471,6 +1482,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1471
1482
  return (
1472
1483
  <CameraControls
1473
1484
  azimuthRotateSpeed={ORBIT_ROTATE_SPEED}
1485
+ dollyToCursor
1474
1486
  makeDefault
1475
1487
  maxDistance={maxDistance}
1476
1488
  impl={FloorAwareCameraControls}
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { sceneRegistry } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Html } from '@react-three/drei'
6
5
  import { useFrame } from '@react-three/fiber'
7
6
  import { memo, useMemo, useRef } from 'react'
8
7
  import { BoxGeometry, CircleGeometry, type Group } from 'three'
9
8
  import { MeshBasicNodeMaterial } from 'three/webgpu'
10
9
  import { EDITOR_LAYER } from '../../lib/constants'
11
10
  import useElevationGuides from '../../store/use-elevation-guides'
11
+ import { Html } from './localized-html'
12
12
  import { formatMeasurement } from './measurement-pill'
13
13
 
14
14
  const LINE_COLOR = 0x81_8c_f8
@@ -18,6 +18,7 @@ import {
18
18
  getLevelDisplayName,
19
19
  getLevelElevations,
20
20
  getResolvedElevatorDoorStyle,
21
+ type InteractionSound,
21
22
  itemPrompt,
22
23
  nodeMechanism,
23
24
  openElevatorDoor,
@@ -45,12 +46,20 @@ import {
45
46
  CROUCH_FLOAT_HEIGHT,
46
47
  CROUCH_RUN_SPEED,
47
48
  CROUCH_WALK_SPEED,
49
+ createDroneInput,
50
+ DRONE_SLOW_MULTIPLIER,
51
+ type DroneLook,
48
52
  EYE_LERP_SPEED,
49
53
  type MovementInput,
54
+ openingSettled,
55
+ proceduralSettled,
50
56
  STAND_CAPSULE,
51
57
  STAND_CLEARANCE,
52
58
  STAND_FLOAT_HEIGHT,
59
+ seedDroneLook,
53
60
  setSurfaceRaycastLayers,
61
+ stepDroneFlight,
62
+ turnFirstPersonLook,
54
63
  useViewer,
55
64
  WALKTHROUGH_FOV,
56
65
  } from '@pascal-app/viewer'
@@ -90,7 +99,7 @@ import {
90
99
  } from '../../lib/window-interaction'
91
100
  import useEditor from '../../store/use-editor'
92
101
  import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
93
- import { WalkthroughHud } from '../walkthrough-hud'
102
+ import { DroneHud, WalkthroughHud } from '../walkthrough-hud'
94
103
  import {
95
104
  buildFirstPersonColliderWorldFromRegistry,
96
105
  deriveFirstPersonSpawn,
@@ -107,13 +116,6 @@ import {
107
116
  } from './first-person/mechanism-targets'
108
117
 
109
118
  const CAMERA_EYE_OFFSET = 0.45
110
- const LOOK_SENSITIVITY = 0.002
111
- // Drone mode: metres per second, and how hard Shift boosts it. The smoothing
112
- // constant is an exponential approach rate, not a linear acceleration.
113
- const DRONE_SPEED = 7
114
- const DRONE_RUN_MULTIPLIER = 3
115
- const DRONE_SLOW_MULTIPLIER = 0.2
116
- const DRONE_SMOOTHING = 12
117
119
  const CONTROLLER_CENTER_FROM_EYE = 0.85
118
120
  const DOOR_INTERACTION_DISTANCE = 2.5
119
121
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
@@ -168,10 +170,7 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
168
170
 
169
171
  const cameraOffset = new Vector3()
170
172
  const cameraEuler = new Euler(0, 0, 0, 'YXZ')
171
- const droneEuler = new Euler(0, 0, 0, 'YXZ')
172
- const droneForward = new Vector3()
173
- const droneRight = new Vector3()
174
- const droneDesiredVelocity = new Vector3()
173
+ const droneInput = createDroneInput()
175
174
  const standClearanceRaycaster = new Raycaster()
176
175
  const standClearanceUp = new Vector3(0, 1, 0)
177
176
  const centerScreenPoint = new Vector2(0, 0)
@@ -425,6 +424,40 @@ function resolveHudInteract(target: FirstPersonInteractableTarget | null): Walkt
425
424
  return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
426
425
  }
427
426
 
427
+ /** E plays what the viewer's callout plays for the same door, window or switch. */
428
+ function playTargetSound(target: FirstPersonInteractableTarget) {
429
+ const verb = resolveHudInteract(target)?.verb
430
+ if (!verb || verb === 'press') return
431
+ const node = useScene.getState().nodes[target.id]
432
+ // A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end.
433
+ if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) {
434
+ if (verb === 'open') {
435
+ emitter.emit('interaction:sound', 'panel-open')
436
+ return
437
+ }
438
+ if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing')
439
+ void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close'))
440
+ return
441
+ }
442
+ let sound: InteractionSound
443
+ if (verb !== 'open' && verb !== 'close')
444
+ sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off'
445
+ else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage'
446
+ else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close'
447
+ else sound = verb === 'open' ? 'door-open' : 'door-close'
448
+ // A built-in leaf or sash swings shut, then latches on the frame it lands.
449
+ if (
450
+ (sound === 'door-close' || sound === 'window-close') &&
451
+ (target.type === 'door' || target.type === 'window') &&
452
+ node
453
+ ) {
454
+ emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing')
455
+ void openingSettled(node).then(() => emitter.emit('interaction:sound', sound))
456
+ return
457
+ }
458
+ emitter.emit('interaction:sound', sound)
459
+ }
460
+
428
461
  function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
429
462
  let current: Object3D | null = object
430
463
 
@@ -734,8 +767,7 @@ export const FirstPersonControls = () => {
734
767
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
735
768
  const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
736
769
  const hadPointerLockRef = useRef(false)
737
- const yawRef = useRef(0)
738
- const pitchRef = useRef(0)
770
+ const lookRef = useRef<DroneLook>({ yaw: 0, pitch: 0 })
739
771
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
740
772
  const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
741
773
  const crouchKeyRef = useRef(false)
@@ -1131,6 +1163,7 @@ export const FirstPersonControls = () => {
1131
1163
 
1132
1164
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
1133
1165
  if (!target) return
1166
+ playTargetSound(target)
1134
1167
  if (target.type === 'mechanism') {
1135
1168
  const node = useScene.getState().nodes[target.id]
1136
1169
  if (node) toggleNodeMechanism(node)
@@ -1325,9 +1358,7 @@ export const FirstPersonControls = () => {
1325
1358
  // Drone picks up wherever the camera currently is — orbit pose or walk eye.
1326
1359
  useEffect(() => {
1327
1360
  if (!isDroneMode) return
1328
- droneEuler.setFromQuaternion(camera.quaternion)
1329
- yawRef.current = droneEuler.y
1330
- pitchRef.current = droneEuler.x
1361
+ seedDroneLook(lookRef.current, camera.quaternion)
1331
1362
  droneVelocityRef.current.set(0, 0, 0)
1332
1363
 
1333
1364
  // Interaction prompts belong to walk; drop whatever its frame left behind.
@@ -1344,8 +1375,8 @@ export const FirstPersonControls = () => {
1344
1375
 
1345
1376
  const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
1346
1377
  const [x, y, z] = spawn.position
1347
- yawRef.current = spawn.yaw
1348
- pitchRef.current = 0
1378
+ lookRef.current.yaw = spawn.yaw
1379
+ lookRef.current.pitch = 0
1349
1380
  setControllerStart({
1350
1381
  position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
1351
1382
  yaw: spawn.yaw,
@@ -1381,15 +1412,13 @@ export const FirstPersonControls = () => {
1381
1412
  // Shutter hold: the shot is rendering — a mouse twitch must not pan it.
1382
1413
  if (useEditor.getState().captureShutterHold) return
1383
1414
 
1384
- const lookSensitivity =
1385
- LOOK_SENSITIVITY *
1386
- (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1415
+ turnFirstPersonLook(
1416
+ lookRef.current,
1417
+ e.movementX,
1418
+ e.movementY,
1419
+ useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1387
1420
  ? DRONE_SLOW_MULTIPLIER
1388
- : 1)
1389
- yawRef.current -= e.movementX * lookSensitivity
1390
- pitchRef.current = Math.max(
1391
- -(Math.PI / 2 - 0.05),
1392
- Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * lookSensitivity),
1421
+ : 1,
1393
1422
  )
1394
1423
  }
1395
1424
 
@@ -1817,45 +1846,24 @@ export const FirstPersonControls = () => {
1817
1846
  return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1818
1847
  }, [])
1819
1848
 
1820
- // Drone: a free camera driven straight from the look angles — no controller,
1821
- // no gravity or collision clamping. WASD move along the view axes, Space or E
1822
- // rises, Q (or Ctrl) sinks, and Shift boosts.
1849
+ // Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's
1850
+ // `DroneController`) fed from this controller's keys — WASD along the view
1851
+ // axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows.
1823
1852
  useFrame((_, delta) => {
1824
1853
  if (!isDroneMode) return
1825
1854
  // Shutter hold: freeze the drone mid-air while the shot renders.
1826
1855
  if (useEditor.getState().captureShutterHold) return
1827
1856
 
1828
- const step = Math.min(delta, 0.1)
1829
1857
  const movement = movementInputRef.current
1830
-
1831
- droneEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
1832
- camera.quaternion.setFromEuler(droneEuler)
1833
- droneForward.set(0, 0, -1).applyEuler(droneEuler)
1834
- droneRight.set(1, 0, 0).applyEuler(droneEuler)
1835
-
1836
- droneDesiredVelocity.set(0, 0, 0)
1837
- if (movement.forward) droneDesiredVelocity.add(droneForward)
1838
- if (movement.backward) droneDesiredVelocity.sub(droneForward)
1839
- if (movement.rightward) droneDesiredVelocity.add(droneRight)
1840
- if (movement.leftward) droneDesiredVelocity.sub(droneRight)
1841
- if (movement.jump || droneAscendKeyRef.current) droneDesiredVelocity.y += 1
1842
- if (droneDescendKeyRef.current || crouchKeyRef.current) droneDesiredVelocity.y -= 1
1843
- if (droneDesiredVelocity.lengthSq() > 0) {
1844
- droneDesiredVelocity
1845
- .normalize()
1846
- .multiplyScalar(
1847
- DRONE_SPEED *
1848
- (droneSlowKeyRef.current
1849
- ? DRONE_SLOW_MULTIPLIER
1850
- : movement.run
1851
- ? DRONE_RUN_MULTIPLIER
1852
- : 1),
1853
- )
1854
- }
1855
-
1856
- droneVelocityRef.current.lerp(droneDesiredVelocity, 1 - Math.exp(-step * DRONE_SMOOTHING))
1857
- camera.position.addScaledVector(droneVelocityRef.current, step)
1858
- camera.updateMatrixWorld(true)
1858
+ droneInput.forward = movement.forward === true
1859
+ droneInput.backward = movement.backward === true
1860
+ droneInput.leftward = movement.leftward === true
1861
+ droneInput.rightward = movement.rightward === true
1862
+ droneInput.up = movement.jump === true || droneAscendKeyRef.current
1863
+ droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current
1864
+ droneInput.boost = movement.run === true
1865
+ droneInput.slow = droneSlowKeyRef.current
1866
+ stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta)
1859
1867
  }, 2.5)
1860
1868
 
1861
1869
  useFrame((_, delta) => {
@@ -1899,7 +1907,7 @@ export const FirstPersonControls = () => {
1899
1907
 
1900
1908
  group.rotation.y = 0
1901
1909
  camera.position.copy(group.position).add(cameraOffset)
1902
- cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
1910
+ cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ')
1903
1911
  camera.quaternion.setFromEuler(cameraEuler)
1904
1912
  camera.updateMatrixWorld(true)
1905
1913
  syncElevatorRide(group)
@@ -1991,6 +1999,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1991
1999
  const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
1992
2000
  const interact = useFirstPersonHud((state) => state.interact)
1993
2001
  const suspended = useViewer((state) => state.walkthroughSuspended)
2002
+ const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
1994
2003
 
1995
2004
  const handleExit = useCallback(() => {
1996
2005
  if (document.pointerLockElement) {
@@ -1999,6 +2008,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1999
2008
  onExit()
2000
2009
  }, [onExit])
2001
2010
 
2011
+ if (isDroneMode) return <DroneHud onExit={handleExit} suspended={suspended} />
2012
+
2002
2013
  return (
2003
2014
  <WalkthroughHud
2004
2015
  floorLabel={floorLabel}