@pascal-app/editor 1.0.6 → 1.1.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 (382) 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 +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -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'
@@ -76,21 +85,19 @@ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-
76
85
  import '../../three-types'
77
86
  import {
78
87
  closeDoorOpenState,
88
+ closeWindowOpenState,
79
89
  DOOR_SWING_OPEN_ANGLE,
80
90
  getDisplayedDoorValue,
81
- isOperationDoorType,
82
- toggleDoorOpenState,
83
- } from '../../lib/door-interaction'
84
- import { isTypingTarget } from '../../lib/typing-target'
85
- import {
86
- closeWindowOpenState,
87
91
  getDisplayedWindowValue,
88
92
  isOperableWindowType,
93
+ isOperationDoorType,
94
+ toggleDoorOpenState,
89
95
  toggleWindowOpenState,
90
- } from '../../lib/window-interaction'
96
+ } from '@pascal-app/core'
97
+ import { isTypingTarget } from '../../lib/typing-target'
91
98
  import useEditor from '../../store/use-editor'
92
99
  import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
93
- import { WalkthroughHud } from '../walkthrough-hud'
100
+ import { DroneHud, WalkthroughHud } from '../walkthrough-hud'
94
101
  import {
95
102
  buildFirstPersonColliderWorldFromRegistry,
96
103
  deriveFirstPersonSpawn,
@@ -107,13 +114,6 @@ import {
107
114
  } from './first-person/mechanism-targets'
108
115
 
109
116
  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
117
  const CONTROLLER_CENTER_FROM_EYE = 0.85
118
118
  const DOOR_INTERACTION_DISTANCE = 2.5
119
119
  const DOOR_LEAF_INTERACTION_DEPTH = 0.08
@@ -168,10 +168,7 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
168
168
 
169
169
  const cameraOffset = new Vector3()
170
170
  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()
171
+ const droneInput = createDroneInput()
175
172
  const standClearanceRaycaster = new Raycaster()
176
173
  const standClearanceUp = new Vector3(0, 1, 0)
177
174
  const centerScreenPoint = new Vector2(0, 0)
@@ -425,6 +422,40 @@ function resolveHudInteract(target: FirstPersonInteractableTarget | null): Walkt
425
422
  return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
426
423
  }
427
424
 
425
+ /** E plays what the viewer's callout plays for the same door, window or switch. */
426
+ function playTargetSound(target: FirstPersonInteractableTarget) {
427
+ const verb = resolveHudInteract(target)?.verb
428
+ if (!verb || verb === 'press') return
429
+ const node = useScene.getState().nodes[target.id]
430
+ // A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end.
431
+ if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) {
432
+ if (verb === 'open') {
433
+ emitter.emit('interaction:sound', 'panel-open')
434
+ return
435
+ }
436
+ if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing')
437
+ void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close'))
438
+ return
439
+ }
440
+ let sound: InteractionSound
441
+ if (verb !== 'open' && verb !== 'close')
442
+ sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off'
443
+ else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage'
444
+ else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close'
445
+ else sound = verb === 'open' ? 'door-open' : 'door-close'
446
+ // A built-in leaf or sash swings shut, then latches on the frame it lands.
447
+ if (
448
+ (sound === 'door-close' || sound === 'window-close') &&
449
+ (target.type === 'door' || target.type === 'window') &&
450
+ node
451
+ ) {
452
+ emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing')
453
+ void openingSettled(node).then(() => emitter.emit('interaction:sound', sound))
454
+ return
455
+ }
456
+ emitter.emit('interaction:sound', sound)
457
+ }
458
+
428
459
  function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
429
460
  let current: Object3D | null = object
430
461
 
@@ -734,8 +765,7 @@ export const FirstPersonControls = () => {
734
765
  const controllerRef = useRef<BVHEcctrlApi | null>(null)
735
766
  const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
736
767
  const hadPointerLockRef = useRef(false)
737
- const yawRef = useRef(0)
738
- const pitchRef = useRef(0)
768
+ const lookRef = useRef<DroneLook>({ yaw: 0, pitch: 0 })
739
769
  const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
740
770
  const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
741
771
  const crouchKeyRef = useRef(false)
@@ -1131,6 +1161,7 @@ export const FirstPersonControls = () => {
1131
1161
 
1132
1162
  const target = interactableTargetRef.current ?? resolveInteractableTarget()
1133
1163
  if (!target) return
1164
+ playTargetSound(target)
1134
1165
  if (target.type === 'mechanism') {
1135
1166
  const node = useScene.getState().nodes[target.id]
1136
1167
  if (node) toggleNodeMechanism(node)
@@ -1325,9 +1356,7 @@ export const FirstPersonControls = () => {
1325
1356
  // Drone picks up wherever the camera currently is — orbit pose or walk eye.
1326
1357
  useEffect(() => {
1327
1358
  if (!isDroneMode) return
1328
- droneEuler.setFromQuaternion(camera.quaternion)
1329
- yawRef.current = droneEuler.y
1330
- pitchRef.current = droneEuler.x
1359
+ seedDroneLook(lookRef.current, camera.quaternion)
1331
1360
  droneVelocityRef.current.set(0, 0, 0)
1332
1361
 
1333
1362
  // Interaction prompts belong to walk; drop whatever its frame left behind.
@@ -1344,8 +1373,8 @@ export const FirstPersonControls = () => {
1344
1373
 
1345
1374
  const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
1346
1375
  const [x, y, z] = spawn.position
1347
- yawRef.current = spawn.yaw
1348
- pitchRef.current = 0
1376
+ lookRef.current.yaw = spawn.yaw
1377
+ lookRef.current.pitch = 0
1349
1378
  setControllerStart({
1350
1379
  position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
1351
1380
  yaw: spawn.yaw,
@@ -1381,15 +1410,13 @@ export const FirstPersonControls = () => {
1381
1410
  // Shutter hold: the shot is rendering — a mouse twitch must not pan it.
1382
1411
  if (useEditor.getState().captureShutterHold) return
1383
1412
 
1384
- const lookSensitivity =
1385
- LOOK_SENSITIVITY *
1386
- (useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1413
+ turnFirstPersonLook(
1414
+ lookRef.current,
1415
+ e.movementX,
1416
+ e.movementY,
1417
+ useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
1387
1418
  ? 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),
1419
+ : 1,
1393
1420
  )
1394
1421
  }
1395
1422
 
@@ -1817,45 +1844,24 @@ export const FirstPersonControls = () => {
1817
1844
  return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
1818
1845
  }, [])
1819
1846
 
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.
1847
+ // Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's
1848
+ // `DroneController`) fed from this controller's keys — WASD along the view
1849
+ // axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows.
1823
1850
  useFrame((_, delta) => {
1824
1851
  if (!isDroneMode) return
1825
1852
  // Shutter hold: freeze the drone mid-air while the shot renders.
1826
1853
  if (useEditor.getState().captureShutterHold) return
1827
1854
 
1828
- const step = Math.min(delta, 0.1)
1829
1855
  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)
1856
+ droneInput.forward = movement.forward === true
1857
+ droneInput.backward = movement.backward === true
1858
+ droneInput.leftward = movement.leftward === true
1859
+ droneInput.rightward = movement.rightward === true
1860
+ droneInput.up = movement.jump === true || droneAscendKeyRef.current
1861
+ droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current
1862
+ droneInput.boost = movement.run === true
1863
+ droneInput.slow = droneSlowKeyRef.current
1864
+ stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta)
1859
1865
  }, 2.5)
1860
1866
 
1861
1867
  useFrame((_, delta) => {
@@ -1899,7 +1905,7 @@ export const FirstPersonControls = () => {
1899
1905
 
1900
1906
  group.rotation.y = 0
1901
1907
  camera.position.copy(group.position).add(cameraOffset)
1902
- cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
1908
+ cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ')
1903
1909
  camera.quaternion.setFromEuler(cameraEuler)
1904
1910
  camera.updateMatrixWorld(true)
1905
1911
  syncElevatorRide(group)
@@ -1991,6 +1997,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1991
1997
  const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
1992
1998
  const interact = useFirstPersonHud((state) => state.interact)
1993
1999
  const suspended = useViewer((state) => state.walkthroughSuspended)
2000
+ const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
1994
2001
 
1995
2002
  const handleExit = useCallback(() => {
1996
2003
  if (document.pointerLockElement) {
@@ -1999,6 +2006,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
1999
2006
  onExit()
2000
2007
  }, [onExit])
2001
2008
 
2009
+ if (isDroneMode) return <DroneHud onExit={handleExit} suspended={suspended} />
2010
+
2002
2011
  return (
2003
2012
  <WalkthroughHud
2004
2013
  floorLabel={floorLabel}
@@ -0,0 +1,318 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CameraControlsImpl } from '@react-three/drei'
3
+ import * as THREE from 'three'
4
+ import {
5
+ computeFitSceneLookAt,
6
+ type PendingFitScene,
7
+ planFitSceneOnEvent,
8
+ planFitSceneOnOrbitResume,
9
+ planLevelFollow,
10
+ } from './fit-scene-framing'
11
+
12
+ const sampleBounds = {
13
+ center: [10, 20] as [number, number],
14
+ size: [30, 40] as [number, number],
15
+ }
16
+
17
+ describe('fit-scene framing', () => {
18
+ test('computes default look-at when bounds are null', () => {
19
+ expect(computeFitSceneLookAt(null)).toEqual({
20
+ eyeX: 20,
21
+ eyeY: 20,
22
+ eyeZ: 20,
23
+ targetX: 0,
24
+ targetY: 0,
25
+ targetZ: 0,
26
+ eyeHeight: 20,
27
+ fitSphere: { center: [0, 0, 0], radius: 15 },
28
+ })
29
+ })
30
+
31
+ test('computes orbit look-at from scene bounds', () => {
32
+ const lookAt = computeFitSceneLookAt(sampleBounds)
33
+ // Longer extent is depth=40 → distance=56, height=32
34
+ expect(lookAt).toEqual({
35
+ eyeX: 10 + 56 * 0.7,
36
+ eyeY: 32,
37
+ eyeZ: 20 + 56 * 0.7,
38
+ targetX: 10,
39
+ targetY: 0,
40
+ targetZ: 20,
41
+ eyeHeight: 32,
42
+ fitSphere: { center: [10, 0, 20], radius: 25 * 1.3 },
43
+ })
44
+ })
45
+
46
+ test('rests the pivot and lifts the eye by the level ground height', () => {
47
+ const lookAt = computeFitSceneLookAt(sampleBounds, 3)
48
+ expect(lookAt.targetY).toBe(3)
49
+ expect(lookAt.eyeY).toBe(35)
50
+ expect(lookAt.eyeHeight).toBe(32)
51
+ expect(computeFitSceneLookAt(null, 3)).toMatchObject({ eyeY: 23, targetY: 3 })
52
+ })
53
+
54
+ test('fits the orthographic sphere around the footprint at the pivot', () => {
55
+ const lookAt = computeFitSceneLookAt(sampleBounds, 3)
56
+ expect(lookAt.fitSphere.center).toEqual([lookAt.targetX, lookAt.targetY, lookAt.targetZ])
57
+ const [cx, cz] = sampleBounds.center
58
+ const [w, d] = sampleBounds.size
59
+ for (const [x, z] of [
60
+ [cx - w / 2, cz - d / 2],
61
+ [cx + w / 2, cz + d / 2],
62
+ [cx - w / 2, cz + d / 2],
63
+ ] as const) {
64
+ expect(Math.hypot(x - cx, z - cz)).toBeLessThan(lookAt.fitSphere.radius)
65
+ }
66
+ const lone = computeFitSceneLookAt({ center: [0, 0], size: [1, 0.2] })
67
+ expect(lone.fitSphere.radius).toBe(5)
68
+ expect(computeFitSceneLookAt(null, 3).fitSphere).toEqual({ center: [0, 3, 0], radius: 15 })
69
+ })
70
+
71
+ test('an orthographic camera fitted to the sphere shows the whole footprint whatever its zoom was', () => {
72
+ const previous = globalThis.DOMRect
73
+ globalThis.DOMRect ??= class {} as typeof DOMRect
74
+ CameraControlsImpl.install({ THREE })
75
+ const camera = new THREE.OrthographicCamera(-8, 8, 5, -5, 0.1, 1000)
76
+ const control = new CameraControlsImpl(camera)
77
+ const bounds = { center: [60, -20] as [number, number], size: [80, 30] as [number, number] }
78
+ try {
79
+ // The previous project left the camera zoomed in on a small room.
80
+ control.zoomTo(12, false)
81
+ const lookAt = computeFitSceneLookAt(bounds)
82
+ control.setLookAt(
83
+ lookAt.eyeX,
84
+ lookAt.eyeY,
85
+ lookAt.eyeZ,
86
+ lookAt.targetX,
87
+ lookAt.targetY,
88
+ lookAt.targetZ,
89
+ false,
90
+ )
91
+ const sphere = new THREE.Sphere(
92
+ new THREE.Vector3(...lookAt.fitSphere.center),
93
+ lookAt.fitSphere.radius,
94
+ )
95
+ control.fitToSphere(sphere, false)
96
+ control.update(0)
97
+ camera.updateMatrixWorld(true)
98
+
99
+ const target = control.getTarget(new THREE.Vector3())
100
+ expect(target.toArray()).toEqual([lookAt.targetX, lookAt.targetY, lookAt.targetZ])
101
+ const [cx, cz] = bounds.center
102
+ const [w, d] = bounds.size
103
+ for (const x of [cx - w / 2, cx + w / 2])
104
+ for (const z of [cz - d / 2, cz + d / 2]) {
105
+ const projected = new THREE.Vector3(x, 0, z).project(camera)
106
+ expect(Math.abs(projected.x)).toBeLessThanOrEqual(1)
107
+ expect(Math.abs(projected.y)).toBeLessThanOrEqual(1)
108
+ }
109
+ } finally {
110
+ control.dispose()
111
+ globalThis.DOMRect = previous
112
+ }
113
+ })
114
+
115
+ test('applies immediately when orbit controls are live', () => {
116
+ const plan = planFitSceneOnEvent({
117
+ isPreviewMode: false,
118
+ isFirstPersonMode: false,
119
+ hasControls: true,
120
+ bounds: sampleBounds,
121
+ })
122
+
123
+ expect(plan).toEqual({
124
+ action: 'apply',
125
+ lookAt: computeFitSceneLookAt(sampleBounds),
126
+ })
127
+ })
128
+
129
+ test('ignores fit-scene while preview mode is active', () => {
130
+ const plan = planFitSceneOnEvent({
131
+ isPreviewMode: true,
132
+ isFirstPersonMode: false,
133
+ hasControls: true,
134
+ bounds: sampleBounds,
135
+ })
136
+ expect(plan).toEqual({ action: 'ignore' })
137
+ })
138
+
139
+ test('queues fit-scene while first-person is active (scene-ready during FP)', () => {
140
+ const plan = planFitSceneOnEvent({
141
+ isPreviewMode: false,
142
+ isFirstPersonMode: true,
143
+ hasControls: false,
144
+ bounds: sampleBounds,
145
+ })
146
+
147
+ expect(plan).toEqual({
148
+ action: 'queue',
149
+ pending: { bounds: sampleBounds },
150
+ })
151
+ })
152
+
153
+ test('queues fit-scene when orbit controls are not mounted yet', () => {
154
+ const plan = planFitSceneOnEvent({
155
+ isPreviewMode: false,
156
+ isFirstPersonMode: false,
157
+ hasControls: false,
158
+ bounds: sampleBounds,
159
+ })
160
+
161
+ expect(plan).toEqual({
162
+ action: 'queue',
163
+ pending: { bounds: sampleBounds },
164
+ })
165
+ })
166
+
167
+ test('scene-ready during first-person then return to orbit applies the pending frame', () => {
168
+ // Mirrors the blocking lifecycle: fit arrives while FP is active, then
169
+ // orbit remounts and the queued frame must still apply.
170
+ let pending: PendingFitScene | null = null
171
+
172
+ const duringFirstPerson = planFitSceneOnEvent({
173
+ isPreviewMode: false,
174
+ isFirstPersonMode: true,
175
+ hasControls: false,
176
+ bounds: sampleBounds,
177
+ })
178
+ expect(duringFirstPerson.action).toBe('queue')
179
+ if (duringFirstPerson.action === 'queue') {
180
+ pending = duringFirstPerson.pending
181
+ }
182
+
183
+ // Still in first-person: controls are not ready, keep the pending frame.
184
+ expect(
185
+ planFitSceneOnOrbitResume({
186
+ isPreviewMode: false,
187
+ isFirstPersonMode: true,
188
+ hasControls: false,
189
+ pending,
190
+ }),
191
+ ).toEqual({ action: 'noop' })
192
+
193
+ // Leave first-person; orbit controls remount and can apply the frame.
194
+ const afterOrbitResume = planFitSceneOnOrbitResume({
195
+ isPreviewMode: false,
196
+ isFirstPersonMode: false,
197
+ hasControls: true,
198
+ pending,
199
+ })
200
+
201
+ expect(afterOrbitResume).toEqual({
202
+ action: 'apply',
203
+ lookAt: computeFitSceneLookAt(sampleBounds),
204
+ })
205
+ })
206
+
207
+ test('latest fit-scene while first-person wins when orbit resumes', () => {
208
+ let pending: PendingFitScene | null = null
209
+ const first = planFitSceneOnEvent({
210
+ isPreviewMode: false,
211
+ isFirstPersonMode: true,
212
+ hasControls: false,
213
+ bounds: null,
214
+ })
215
+ if (first.action === 'queue') pending = first.pending
216
+
217
+ const secondBounds = {
218
+ center: [0, 0] as [number, number],
219
+ size: [10, 10] as [number, number],
220
+ }
221
+ const second = planFitSceneOnEvent({
222
+ isPreviewMode: false,
223
+ isFirstPersonMode: true,
224
+ hasControls: false,
225
+ bounds: secondBounds,
226
+ })
227
+ if (second.action === 'queue') pending = second.pending
228
+
229
+ expect(
230
+ planFitSceneOnOrbitResume({
231
+ isPreviewMode: false,
232
+ isFirstPersonMode: false,
233
+ hasControls: true,
234
+ pending,
235
+ }),
236
+ ).toEqual({
237
+ action: 'apply',
238
+ lookAt: computeFitSceneLookAt(secondBounds),
239
+ })
240
+ })
241
+
242
+ test('does not flush a pending frame into preview mode', () => {
243
+ expect(
244
+ planFitSceneOnOrbitResume({
245
+ isPreviewMode: true,
246
+ isFirstPersonMode: false,
247
+ hasControls: true,
248
+ pending: { bounds: sampleBounds },
249
+ }),
250
+ ).toEqual({ action: 'noop' })
251
+ })
252
+ })
253
+
254
+ describe('level follow', () => {
255
+ type Inputs = { levelId: string | null; levelMode: string; camera: object; sceneEmpty: boolean }
256
+
257
+ // The effect's bookkeeping: one instance per mounted CustomCameraControls.
258
+ function mountLevelFollow(initial: Inputs) {
259
+ let firstLoad = true
260
+ let previous = { ...initial }
261
+ return (next: Inputs) => {
262
+ const plan = planLevelFollow({
263
+ firstLoad,
264
+ sceneEmpty: next.sceneEmpty,
265
+ levelChanged: previous.levelId !== next.levelId,
266
+ modeChanged: previous.levelMode !== next.levelMode,
267
+ cameraChanged: previous.camera !== next.camera,
268
+ })
269
+ firstLoad = false
270
+ previous = { ...next }
271
+ return plan
272
+ }
273
+ }
274
+
275
+ const perspective = {}
276
+ const loaded: Inputs = {
277
+ levelId: 'level_1',
278
+ levelMode: 'stacked',
279
+ camera: perspective,
280
+ sceneEmpty: false,
281
+ }
282
+
283
+ test('an empty first load takes the default pose', () => {
284
+ const run = mountLevelFollow({ ...loaded, levelId: null, sceneEmpty: true })
285
+ expect(run({ ...loaded, levelId: null, sceneEmpty: true })).toBe('default-pose')
286
+ })
287
+
288
+ test('a remount over a loaded scene rests the fresh pivot on the level', () => {
289
+ const run = mountLevelFollow(loaded)
290
+ expect(run(loaded)).toBe('rest-pivot')
291
+ expect(run(loaded)).toBe('none')
292
+ })
293
+
294
+ test('a level-mode change pans the pivot', () => {
295
+ const run = mountLevelFollow(loaded)
296
+ run(loaded)
297
+ expect(run({ ...loaded, levelMode: 'exploded' })).toBe('rest-pivot')
298
+ })
299
+
300
+ test('the first pick after a site-phase load pans to the level', () => {
301
+ const site = { ...loaded, levelId: null }
302
+ const run = mountLevelFollow(site)
303
+ run(site)
304
+ expect(run(loaded)).toBe('rest-pivot')
305
+ })
306
+
307
+ test('re-picking the same level does not move the pivot', () => {
308
+ const run = mountLevelFollow(loaded)
309
+ run(loaded)
310
+ expect(run({ ...loaded })).toBe('none')
311
+ })
312
+
313
+ test('a camera swap re-rests the new controls pivot', () => {
314
+ const run = mountLevelFollow(loaded)
315
+ run(loaded)
316
+ expect(run({ ...loaded, camera: {} })).toBe('rest-pivot')
317
+ })
318
+ })