@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,350 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
3
+ import { BATCHED_LAYER, OVERLAY_LAYER, SHADOW_ONLY_LAYER, useViewer } from '@pascal-app/viewer'
4
+ import { BoxGeometry, DoubleSide, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
5
+ import {
6
+ type Box,
7
+ clearFrame,
8
+ FOLLOW_DWELL_S,
9
+ FOLLOW_ELEVATION,
10
+ FOLLOW_GATHER_S,
11
+ Follower,
12
+ type FollowWant,
13
+ frameOf,
14
+ mergeWants,
15
+ OrbitSpring,
16
+ } from './follow-pascal'
17
+ import { FollowSight } from './follow-pascal-sight'
18
+
19
+ const box = (min: [number, number, number], max: [number, number, number]): Box => ({ min, max })
20
+ const want = (kind: FollowWant['kind'], b: Box, at = 0, normal?: [number, number]): FollowWant => ({
21
+ kind,
22
+ box: b,
23
+ at,
24
+ ...(normal ? { normal } : {}),
25
+ })
26
+ const VIEW = { fov: 45, aspect: 1.6, azimuth: 0.6 }
27
+
28
+ // Follow Pascal (the owner, 2026-10-08): the camera eases to what the current step is working on,
29
+ // at the distance its scale needs; about 1.5 s on each target at least, several things landing
30
+ // together framed as one; a level horizon; touching the camera pauses it.
31
+ describe('what the camera looks at next', () => {
32
+ test('waits to gather what lands together, and then to dwell on the last target', () => {
33
+ const follower = new Follower()
34
+ follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
35
+ expect(follower.due(0.1)).toBeNull()
36
+ expect(follower.due(FOLLOW_GATHER_S - 0.01)).toBeNull()
37
+ const first = follower.due(FOLLOW_GATHER_S + 0.01)
38
+ expect(first?.kind).toBe('floor')
39
+ // Nothing is due while it dwells, however much is waiting.
40
+ follower.want(want('detail', box([1, 1, 0], [2, 2, 0.2]), 0.5))
41
+ expect(follower.due(FOLLOW_GATHER_S + 0.9)).toBeNull()
42
+ expect(follower.due(FOLLOW_GATHER_S + 0.01 + FOLLOW_DWELL_S - 0.02)).toBeNull()
43
+ expect(follower.due(FOLLOW_GATHER_S + 0.01 + FOLLOW_DWELL_S + 0.02)?.kind).toBe('detail')
44
+ })
45
+
46
+ test('several things landing together are one target: their union', () => {
47
+ const merged = mergeWants([
48
+ want('detail', box([0, 0, 0], [1, 2, 0.2])),
49
+ want('detail', box([4, 0, 0], [5, 2, 0.2])),
50
+ want('detail', box([8, 0, 0], [9, 2, 0.2])),
51
+ ])
52
+ expect(merged.box).toEqual(box([0, 0, 0], [9, 2, 0.2]))
53
+ expect(merged.kind).toBe('detail')
54
+ })
55
+
56
+ test('different kinds together are a group', () => {
57
+ expect(
58
+ mergeWants([
59
+ want('detail', box([0, 0, 0], [1, 1, 1])),
60
+ want('roof', box([0, 3, 0], [8, 5, 6])),
61
+ ]).kind,
62
+ ).toBe('group')
63
+ })
64
+
65
+ test('targets on different floors are visited separately, not framed across hidden floors', () => {
66
+ const follower = new Follower()
67
+ follower.want({ ...want('furnish', box([0, 0, 0], [1, 1, 1])), levelId: 'level_lower' })
68
+ follower.want({
69
+ ...want('furnish', box([0, 3, 0], [1, 4, 1]), 0.1),
70
+ levelId: 'level_upper',
71
+ })
72
+ expect(follower.due(0.5)?.levelId).toBe('level_lower')
73
+ expect(follower.due(0.6)).toBeNull()
74
+ expect(follower.due(2)?.levelId).toBe('level_upper')
75
+ })
76
+
77
+ test('a shared direction survives the merge; a mixed one does not', () => {
78
+ const facing = mergeWants([
79
+ want('detail', box([0, 0, 0], [1, 1, 1]), 0, [0, 1]),
80
+ want('detail', box([2, 0, 0], [3, 1, 1]), 0, [0.2, 0.98]),
81
+ ])
82
+ expect(facing.normal?.[1]).toBeGreaterThan(0.9)
83
+ const mixed = mergeWants([
84
+ want('detail', box([0, 0, 0], [1, 1, 1]), 0, [0, 1]),
85
+ want('detail', box([2, 0, 0], [3, 1, 1]), 0, [0, -1]),
86
+ ])
87
+ expect(mixed.normal).toBeUndefined()
88
+ })
89
+
90
+ test('a blocked follower (the person is pointing at something) holds what it has', () => {
91
+ const follower = new Follower()
92
+ follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
93
+ expect(follower.due(5, true)).toBeNull()
94
+ expect(follower.due(5)?.kind).toBe('floor')
95
+ })
96
+
97
+ test('a reset forgets what waits and starts its dwell over', () => {
98
+ const follower = new Follower()
99
+ follower.want(want('floor', box([0, 0, 0], [10, 3, 8]), 0))
100
+ follower.due(1)
101
+ follower.want(want('roof', box([0, 3, 0], [10, 5, 8]), 1.2))
102
+ follower.reset()
103
+ expect(follower.due(10)).toBeNull()
104
+ follower.want(want('roof', box([0, 3, 0], [10, 5, 8]), 10))
105
+ expect(follower.due(10.5)?.kind).toBe('roof')
106
+ })
107
+ })
108
+
109
+ describe('how far the camera stands', () => {
110
+ const house = box([0, 0, 0], [12, 3, 8])
111
+
112
+ test('a floor is seen whole: the camera stands back until it fits', () => {
113
+ const goal = frameOf(want('floor', house), VIEW)
114
+ expect([goal.x, goal.z]).toEqual([6, 4])
115
+ expect(goal.el).toBe(FOLLOW_ELEVATION.floor)
116
+ // Half of the wide side over the tangent of the narrower half-angle, with room to spare.
117
+ const needed = 6 / Math.tan(Math.atan(Math.tan((45 * Math.PI) / 360) * 1.6) * 1)
118
+ expect(goal.r).toBeGreaterThan(Math.min(needed, 6 / Math.tan((45 * Math.PI) / 360)) * 0.9)
119
+ })
120
+
121
+ test('a window is seen close, a floor far, a group in between', () => {
122
+ const window = frameOf(want('detail', box([3, 0.9, -0.1], [4.2, 2.4, 0.1])), VIEW)
123
+ const floor = frameOf(want('floor', house), VIEW)
124
+ const group = frameOf(want('group', box([0, 0, 0], [6, 3, 4])), VIEW)
125
+ expect(window.r).toBeLessThan(floor.r / 2)
126
+ expect(group.r).toBeLessThan(floor.r)
127
+ expect(group.r).toBeGreaterThan(window.r)
128
+ // The sill of a close-up is seen from nearly level; the furniture, from above.
129
+ expect(window.el).toBeLessThan(frameOf(want('furnish', box([1, 0, 1], [2, 1, 2])), VIEW).el)
130
+ })
131
+
132
+ test('a narrow window of the screen needs more distance than a wide one', () => {
133
+ const wide = frameOf(want('floor', house), { ...VIEW, aspect: 2 })
134
+ const narrow = frameOf(want('floor', house), { ...VIEW, aspect: 0.8 })
135
+ expect(narrow.r).toBeGreaterThan(wide.r)
136
+ })
137
+
138
+ test('what faces a wall is looked at from its outside, on the side nearest the way it already looks', () => {
139
+ const goal = frameOf(want('detail', box([3, 0.9, -0.1], [4.2, 2.4, 0.1]), 0, [0, -1]), {
140
+ ...VIEW,
141
+ azimuth: 0.3,
142
+ })
143
+ // The wall's outside is -z: the eye stands at -z, within half a radian of facing it.
144
+ const toEye = Math.atan2(Math.sin(goal.az), Math.cos(goal.az))
145
+ expect(Math.abs(Math.abs(toEye) - Math.PI)).toBeLessThan(0.6)
146
+ })
147
+
148
+ test('the roof is looked at from above its ridge line, the floor from its middle height', () => {
149
+ const roof = frameOf(want('roof', box([0, 3, 0], [12, 5.2, 8])), VIEW)
150
+ expect(roof.y).toBeLessThan(4.1 + 1e-9)
151
+ expect(roof.el).toBeGreaterThan(FOLLOW_ELEVATION.floor)
152
+ })
153
+ })
154
+
155
+ describe('an eye that must clear something', () => {
156
+ const item = box([4, 0, 3], [4.6, 0.9, 3.6])
157
+
158
+ test('stands back until it is above what it must clear', () => {
159
+ const plain = frameOf(want('furnish', item), VIEW)
160
+ const high = frameOf({ ...want('furnish', item), eyeAbove: 8 }, VIEW)
161
+ const eyeY = (goal: ReturnType<typeof frameOf>) => goal.y + goal.r * Math.sin(goal.el)
162
+ expect(eyeY(plain)).toBeLessThan(8)
163
+ expect(eyeY(high)).toBeGreaterThanOrEqual(8 - 1e-9)
164
+ expect(high.r).toBeGreaterThan(plain.r)
165
+ // Same place, same direction: only further back.
166
+ expect([high.x, high.y, high.z, high.el]).toEqual([plain.x, plain.y, plain.z, plain.el])
167
+ })
168
+
169
+ test('is not pushed back when it is already clear', () => {
170
+ const roomy = box([0, 0, 0], [12, 3, 8])
171
+ const goal = frameOf({ ...want('floor', roomy), eyeAbove: 2 }, VIEW)
172
+ expect(goal.r).toBe(frameOf(want('floor', roomy), VIEW).r)
173
+ })
174
+
175
+ test('what lands together keeps the highest it must clear', () => {
176
+ const merged = mergeWants([
177
+ { ...want('furnish', item), eyeAbove: 6 },
178
+ { ...want('furnish', item), eyeAbove: 9 },
179
+ want('furnish', item),
180
+ ])
181
+ expect(merged.eyeAbove).toBe(9)
182
+ })
183
+ })
184
+
185
+ describe('keeping the followed target in sight', () => {
186
+ test('sight queries ignore the target, hidden helpers and upper floors, but detect batch sources', () => {
187
+ const scene = new Group()
188
+ scene.name = 'scene-renderer'
189
+ const target = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
190
+ target.position.set(0, 1, 0)
191
+ const upperRoot = new Group()
192
+ const obstacle = new Mesh(new BoxGeometry(8, 8, 1), new MeshBasicMaterial())
193
+ obstacle.position.set(0, 4, 5)
194
+ upperRoot.add(obstacle)
195
+ scene.add(target, upperRoot)
196
+ sceneRegistry.nodes.set('item_sight', target)
197
+ sceneRegistry.nodes.set('level_sight_upper', upperRoot)
198
+ const beforeScene = useScene.getState()
199
+ const beforeViewer = useViewer.getState()
200
+ const lower = LevelNode.parse({ id: 'level_sight_lower', level: 0 })
201
+ const upper = LevelNode.parse({ id: 'level_sight_upper', level: 1 })
202
+ const request = {
203
+ ...want('furnish', box([-0.5, 0.5, -0.5], [0.5, 1.5, 0.5])),
204
+ nodeIds: ['item_sight'],
205
+ }
206
+ const pose = { position: [0, 3, 10], target: [0, 1, 0], projection: 'perspective' } as const
207
+ const sight = new FollowSight(scene)
208
+ const clear = () => {
209
+ sight.refresh(request)
210
+ return sight.clear({ ...pose, position: [...pose.position], target: [...pose.target] })
211
+ }
212
+ try {
213
+ useScene.setState({ nodes: { [lower.id]: lower, [upper.id]: upper } })
214
+ useViewer.getState().setSelection({ levelId: lower.id })
215
+ useViewer.setState({ hideLevelsAboveSelection: false })
216
+ expect(clear()).toBe(false)
217
+ upperRoot.visible = false
218
+ expect(clear()).toBe(true)
219
+ upperRoot.visible = true
220
+ obstacle.layers.set(OVERLAY_LAYER)
221
+ expect(clear()).toBe(true)
222
+ obstacle.layers.set(SHADOW_ONLY_LAYER)
223
+ expect(clear()).toBe(true)
224
+ obstacle.layers.set(BATCHED_LAYER)
225
+ expect(clear()).toBe(false)
226
+ useViewer.setState({ hideLevelsAboveSelection: true })
227
+ expect(clear()).toBe(true)
228
+ // The storey a reveal is playing on shows, so what is batched on it stands in the way again.
229
+ useViewer.setState({ levelsRevealing: [upper.id] })
230
+ expect(clear()).toBe(false)
231
+ useViewer.setState({ levelsRevealing: [], hideLevelsAboveSelection: false })
232
+ ;(obstacle.material as MeshBasicMaterial).opacity = 0
233
+ expect(clear()).toBe(true)
234
+ } finally {
235
+ useScene.setState(beforeScene)
236
+ useViewer.setState(beforeViewer)
237
+ sceneRegistry.nodes.delete('item_sight')
238
+ sceneRegistry.nodes.delete('level_sight_upper')
239
+ for (const mesh of [target, obstacle]) {
240
+ mesh.geometry.dispose()
241
+ ;(mesh.material as MeshBasicMaterial).dispose()
242
+ }
243
+ }
244
+ })
245
+
246
+ test('raises the camera to a clear line of sight past an obstacle without changing its target or distance', () => {
247
+ const goal = frameOf(want('furnish', box([-0.5, 0, -0.5], [0.5, 1, 0.5])), {
248
+ ...VIEW,
249
+ azimuth: 0,
250
+ })
251
+ const obstacle = new Mesh(
252
+ new BoxGeometry(8, 5, 0.5),
253
+ new MeshBasicMaterial({ side: DoubleSide }),
254
+ )
255
+ obstacle.position.set(0, 2.5, 2)
256
+ obstacle.updateMatrixWorld(true)
257
+ const ray = new Raycaster()
258
+ const sees = (pose: ReturnType<OrbitSpring['pose']>) => {
259
+ const eye = new Vector3(...pose.position)
260
+ const direction = new Vector3(...pose.target).sub(eye)
261
+ ray.set(eye, direction.clone().normalize())
262
+ ray.far = direction.length() - 0.01
263
+ return ray.intersectObject(obstacle).length === 0
264
+ }
265
+ expect(sees(new OrbitSpring(goal).pose())).toBe(false)
266
+ const clear = clearFrame(goal, sees)!
267
+ expect(clear).not.toBeNull()
268
+ expect(sees(new OrbitSpring(clear).pose())).toBe(true)
269
+ expect([clear.x, clear.y, clear.z, clear.r]).toEqual([goal.x, goal.y, goal.z, goal.r])
270
+ expect(clear.el).toBeGreaterThan(goal.el)
271
+ expect(clear.el).toBeLessThanOrEqual((75 * Math.PI) / 180)
272
+ expect(clearFrame(clear, sees)).toEqual(clear)
273
+ obstacle.geometry.dispose()
274
+ ;(obstacle.material as MeshBasicMaterial).dispose()
275
+ })
276
+
277
+ test('does not change an unobstructed view and reports when the bounded search has no clear view', () => {
278
+ const goal = frameOf(want('detail', box([0, 0, 0], [1, 1, 1])), VIEW)
279
+ expect(clearFrame(goal, () => true)).toEqual(goal)
280
+ expect(clearFrame(goal, () => false)).toBeNull()
281
+ })
282
+ })
283
+
284
+ describe('the camera moves like a spring', () => {
285
+ const goalA = { x: 0, y: 1, z: 0, r: 20, az: 0.5, el: 0.5 }
286
+ const goalB = { x: 6, y: 1.2, z: 3, r: 9, az: 1.4, el: 0.3 }
287
+
288
+ test('reaches its goal without overshooting any of its six values', () => {
289
+ const spring = new OrbitSpring(goalA)
290
+ spring.to(goalB)
291
+ const worst = { r: Math.min(), az: Math.max(), x: Math.max() }
292
+ for (let t = 0; t < 4; t += 1 / 60) {
293
+ spring.step(1 / 60)
294
+ const now = spring.value()
295
+ worst.r = Math.min(worst.r, now.r)
296
+ worst.az = Math.max(worst.az, now.az)
297
+ worst.x = Math.max(worst.x, now.x)
298
+ }
299
+ expect(worst.r).toBeGreaterThanOrEqual(9 - 1e-6)
300
+ expect(worst.az).toBeLessThanOrEqual(1.4 + 1e-6)
301
+ expect(worst.x).toBeLessThanOrEqual(6 + 1e-6)
302
+ const end = spring.value()
303
+ for (const key of Object.keys(goalB) as (keyof typeof goalB)[])
304
+ expect(end[key]).toBeCloseTo(goalB[key], 3)
305
+ })
306
+
307
+ test('goes round the short way, however the angle was written', () => {
308
+ const spring = new OrbitSpring({ ...goalA, az: 3.0 })
309
+ spring.to({ ...goalA, az: -3.0 })
310
+ // -3.0 is 0.28 rad past pi: the way round is 0.28 rad, not 6.
311
+ spring.step(0.05)
312
+ const early = spring.value().az
313
+ expect(Math.abs(early - 3.0)).toBeLessThan(0.3)
314
+ for (let t = 0; t < 4; t += 1 / 60) spring.step(1 / 60)
315
+ const end = spring.value().az
316
+ expect(Math.abs(Math.sin(end - -3.0))).toBeLessThan(1e-3)
317
+ })
318
+
319
+ test('a new goal turns it round from where it is, without a jump', () => {
320
+ const spring = new OrbitSpring(goalA)
321
+ spring.to(goalB)
322
+ for (let index = 0; index < 20; index += 1) spring.step(1 / 60)
323
+ const before = spring.value()
324
+ spring.to(goalA)
325
+ spring.step(1 / 600)
326
+ const after = spring.value()
327
+ expect(Math.abs(after.x - before.x)).toBeLessThan(0.05)
328
+ expect(Math.abs(after.r - before.r)).toBeLessThan(0.1)
329
+ })
330
+
331
+ test('a pose is the eye at its radius and angles about its centre, level with the horizon', () => {
332
+ const spring = new OrbitSpring({ x: 2, y: 1, z: 3, r: 10, az: 0, el: Math.PI / 6 })
333
+ const pose = spring.pose()
334
+ expect(pose.target).toEqual([2, 1, 3])
335
+ // az 0 looks along -z from +z; the eye is r away, 30 degrees up.
336
+ expect(pose.position[0]).toBeCloseTo(2)
337
+ expect(pose.position[1]).toBeCloseTo(1 + 10 * Math.sin(Math.PI / 6))
338
+ expect(pose.position[2]).toBeCloseTo(3 + 10 * Math.cos(Math.PI / 6))
339
+ expect(pose.projection).toBe('perspective')
340
+ })
341
+
342
+ test('it can be set where the camera already is, so following starts from the view the person has', () => {
343
+ const spring = new OrbitSpring(goalA)
344
+ spring.fromPose({ position: [10, 6, 10], target: [0, 1, 0], projection: 'perspective' })
345
+ const value = spring.value()
346
+ expect(value.r).toBeCloseTo(Math.hypot(10, 5, 10))
347
+ expect(value.el).toBeCloseTo(Math.asin(5 / Math.hypot(10, 5, 10)))
348
+ expect(Math.sin(value.az)).toBeCloseTo(10 / Math.hypot(10, 10))
349
+ })
350
+ })
@@ -0,0 +1,284 @@
1
+ import { type CameraPose, criticallyDamped } from '@pascal-app/core'
2
+
3
+ /**
4
+ * Follow Pascal: the camera follows what the agent is building (the owner, 2026-10-08). It eases to
5
+ * what the current step works on, at the distance its scale needs; stays about 1.5 s on each target at
6
+ * least; frames what lands together as one; keeps the horizon level (it moves as an orbit about the
7
+ * thing it looks at, never as a free flight); and moves like a critically damped spring.
8
+ *
9
+ * Everything here is arithmetic on boxes and angles, so it is tested without a canvas; the component
10
+ * that feeds it the reveal's events and writes its poses to the camera is `FollowPascal`.
11
+ */
12
+
13
+ export type Vec3 = [number, number, number]
14
+ export type Box = { min: Vec3; max: Vec3 }
15
+
16
+ /** What kind of thing a step works on decides how far and from how high it is looked at. */
17
+ export type FollowKind = 'floor' | 'detail' | 'roof' | 'furnish' | 'group'
18
+
19
+ export type FollowWant = {
20
+ box: Box
21
+ kind: FollowKind
22
+ levelId?: string
23
+ nodeIds?: readonly string[]
24
+ /** The outward direction of the wall it is on, in the plan (x, z), for what is looked at square on. */
25
+ normal?: [number, number]
26
+ /** The eye must stand above this height (metres): a roof lifted out of the way is looked down through, not from within. */
27
+ eyeAbove?: number
28
+ /**
29
+ * What it works on is seen from outside (an exterior wall bent, a window moved on one, a roof, the
30
+ * ground): the view is from outdoors with no cutaway, whatever hides the target.
31
+ */
32
+ exterior?: boolean
33
+ /** When it was asked for, in seconds on the follower's clock. */
34
+ at: number
35
+ }
36
+
37
+ /** The camera stays on a target at least this long. */
38
+ export const FOLLOW_DWELL_S = 1.5
39
+ /** What lands within this of the first is framed with it. */
40
+ export const FOLLOW_GATHER_S = 0.35
41
+ /** How long a flight takes: a spring's response, not a duration. */
42
+ export const FOLLOW_RESPONSE_S = 1.1
43
+
44
+ /** The eye's elevation above the horizon for each kind, in radians. */
45
+ export const FOLLOW_ELEVATION: Record<FollowKind, number> = {
46
+ floor: 0.5,
47
+ detail: 0.27,
48
+ roof: 0.61,
49
+ furnish: 0.92,
50
+ group: 0.45,
51
+ }
52
+
53
+ const centreOf = ({ min, max }: Box): Vec3 => [
54
+ (min[0] + max[0]) / 2,
55
+ (min[1] + max[1]) / 2,
56
+ (min[2] + max[2]) / 2,
57
+ ]
58
+ const sizeOf = ({ min, max }: Box): Vec3 => [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
59
+
60
+ /** What lands together, as one target: the union of their boxes, a common direction if they share one. */
61
+ export function mergeWants(wants: readonly FollowWant[]): FollowWant {
62
+ const first = wants[0]!
63
+ const min: Vec3 = [...first.box.min]
64
+ const max: Vec3 = [...first.box.max]
65
+ for (const { box } of wants) {
66
+ for (let axis = 0; axis < 3; axis += 1) {
67
+ min[axis] = Math.min(min[axis]!, box.min[axis]!)
68
+ max[axis] = Math.max(max[axis]!, box.max[axis]!)
69
+ }
70
+ }
71
+ const kind = wants.every((want) => want.kind === first.kind) ? first.kind : 'group'
72
+ let normal: [number, number] | undefined
73
+ if (wants.every((want) => want.normal)) {
74
+ const sum: [number, number] = [0, 0]
75
+ for (const want of wants) {
76
+ sum[0] += want.normal![0]
77
+ sum[1] += want.normal![1]
78
+ }
79
+ const length = Math.hypot(sum[0], sum[1])
80
+ // They share a direction only when they mostly agree: the length of the sum over their count.
81
+ if (length / wants.length > 0.6) normal = [sum[0] / length, sum[1] / length]
82
+ }
83
+ const eyes = wants.flatMap((want) => (want.eyeAbove === undefined ? [] : [want.eyeAbove]))
84
+ const nodeIds = [...new Set(wants.flatMap((want) => want.nodeIds ?? []))]
85
+ return {
86
+ box: { min, max },
87
+ kind,
88
+ ...(first.levelId ? { levelId: first.levelId } : {}),
89
+ ...(nodeIds.length ? { nodeIds } : {}),
90
+ ...(normal ? { normal } : {}),
91
+ ...(eyes.length ? { eyeAbove: Math.max(...eyes) } : {}),
92
+ // Seen from outside when any of what lands together is: a mixed step takes the outside view.
93
+ ...(wants.some((want) => want.exterior) ? { exterior: true } : {}),
94
+ at: Math.max(...wants.map((want) => want.at)),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Decides when the camera changes target. Wants are gathered for a moment so things landing together
100
+ * are framed as one, and the camera never leaves a target before its dwell is over.
101
+ */
102
+ export class Follower {
103
+ private pending: FollowWant[] = []
104
+ private last = Number.NEGATIVE_INFINITY
105
+
106
+ want(want: FollowWant): void {
107
+ this.pending.push(want)
108
+ }
109
+
110
+ /** The target to fly to now, if one is due. `blocked` holds it (the person is pointing at something). */
111
+ due(now: number, blocked = false): FollowWant | null {
112
+ const oldest = this.pending[0]
113
+ if (!oldest || blocked) return null
114
+ if (now - this.last < FOLLOW_DWELL_S || now - oldest.at < FOLLOW_GATHER_S) return null
115
+ const nextFloor = this.pending.findIndex((want) => want.levelId !== oldest.levelId)
116
+ const count = nextFloor < 0 ? this.pending.length : nextFloor
117
+ const merged = mergeWants(this.pending.splice(0, count))
118
+ this.last = now
119
+ return merged
120
+ }
121
+
122
+ /** Forgets what waits and starts its dwell over (a new build; the person took the camera back). */
123
+ reset(): void {
124
+ this.pending = []
125
+ this.last = Number.NEGATIVE_INFINITY
126
+ }
127
+ }
128
+
129
+ export type OrbitValue = { x: number; y: number; z: number; r: number; az: number; el: number }
130
+
131
+ export type FollowView = {
132
+ /** The camera's vertical field of view, in degrees. */
133
+ fov: number
134
+ /** The canvas' width over its height. */
135
+ aspect: number
136
+ /** Where the camera looks from now, so a flight takes the nearest way round. */
137
+ azimuth: number
138
+ }
139
+
140
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
141
+ const TAU = Math.PI * 2
142
+
143
+ /** The angle `to` written as the nearest equivalent of `from`, so it never goes the long way round. */
144
+ export function nearestTurn(to: number, from: number): number {
145
+ let angle = to
146
+ while (angle - from > Math.PI) angle -= TAU
147
+ while (angle - from < -Math.PI) angle += TAU
148
+ return angle
149
+ }
150
+
151
+ /**
152
+ * Where the camera stands to see `want`: the box's centre, its scale decides how far (a window up
153
+ * close, a floor whole, a group between) at the elevation of its kind, from the side nearest the way
154
+ * it already looks, or square to the wall it is on.
155
+ */
156
+ export function frameOf(want: FollowWant, view: FollowView): OrbitValue {
157
+ const [cx, cy, cz] = centreOf(want.box)
158
+ const [sx, sy, sz] = sizeOf(want.box)
159
+ const detail = want.kind === 'detail'
160
+ const extent = Math.max(sx, sz, sy * 1.4, detail ? 1.2 : 3)
161
+ const halfVertical = (view.fov * Math.PI) / 360
162
+ const halfHorizontal = Math.atan(Math.tan(halfVertical) * Math.max(0.5, view.aspect))
163
+ const fits = (extent * 0.5) / Math.tan(Math.min(halfVertical, halfHorizontal))
164
+ let r = clamp(fits * 1.25 + sy * 0.3, detail ? 2.8 : 6, 90)
165
+ const el = FOLLOW_ELEVATION[want.kind]
166
+ const eyeY = Math.max(cy, 0.9) + r * Math.sin(el)
167
+ if (want.eyeAbove !== undefined && eyeY < want.eyeAbove) {
168
+ // Further back along the same line of sight, until the eye is above what it must clear.
169
+ r = (want.eyeAbove - Math.max(cy, 0.9)) / Math.sin(el)
170
+ }
171
+ let az = view.azimuth
172
+ if (want.normal) {
173
+ const facing = Math.atan2(want.normal[0], want.normal[1])
174
+ // A little off square, to the side it is already seen from.
175
+ const [left, right] = [facing + 0.5, facing - 0.5]
176
+ az =
177
+ Math.abs(nearestTurn(left, view.azimuth) - view.azimuth) <=
178
+ Math.abs(nearestTurn(right, view.azimuth) - view.azimuth)
179
+ ? left
180
+ : right
181
+ }
182
+ return {
183
+ x: cx,
184
+ y: want.kind === 'roof' ? cy - 0.6 : Math.max(cy, 0.9),
185
+ z: cz,
186
+ r,
187
+ az: nearestTurn(az, view.azimuth),
188
+ el,
189
+ }
190
+ }
191
+
192
+ const KEYS = ['x', 'y', 'z', 'r', 'az', 'el'] as const
193
+
194
+ function orbitPose({ x, y, z, r, az, el }: OrbitValue): CameraPose {
195
+ const flat = Math.cos(el)
196
+ return {
197
+ position: [x + r * flat * Math.sin(az), y + r * Math.sin(el), z + r * flat * Math.cos(az)],
198
+ target: [x, y, z],
199
+ projection: 'perspective',
200
+ }
201
+ }
202
+
203
+ /** Try a higher eye before turning away; never reorient an already clear view. */
204
+ export function clearFrame(
205
+ goal: OrbitValue,
206
+ sees: (pose: CameraPose) => boolean,
207
+ ): OrbitValue | null {
208
+ for (const offset of [
209
+ 0,
210
+ Math.PI / 6,
211
+ -Math.PI / 6,
212
+ Math.PI / 3,
213
+ -Math.PI / 3,
214
+ Math.PI / 2,
215
+ -Math.PI / 2,
216
+ ]) {
217
+ for (const elevation of [goal.el, 0.9, 1.2, (75 * Math.PI) / 180]) {
218
+ const candidate = { ...goal, az: goal.az + offset, el: Math.max(goal.el, elevation) }
219
+ if (sees(orbitPose(candidate))) return candidate
220
+ }
221
+ }
222
+ return null
223
+ }
224
+
225
+ /** The camera as six springs on an orbit: the point it looks at, how far, and which way round. */
226
+ export class OrbitSpring {
227
+ private states = Object.fromEntries(KEYS.map((key) => [key, { x: 0, v: 0 }])) as Record<
228
+ (typeof KEYS)[number],
229
+ { x: number; v: number }
230
+ >
231
+ private goal: OrbitValue
232
+
233
+ constructor(
234
+ start: OrbitValue,
235
+ private response = FOLLOW_RESPONSE_S,
236
+ ) {
237
+ this.goal = { ...start }
238
+ this.snap(start)
239
+ }
240
+
241
+ snap(value: OrbitValue): void {
242
+ this.goal = { ...value }
243
+ for (const key of KEYS) this.states[key] = { x: value[key], v: 0 }
244
+ }
245
+
246
+ /** Flies to `value`, from where it is, carrying its speed. */
247
+ to(value: OrbitValue): void {
248
+ this.goal = { ...value, az: nearestTurn(value.az, this.states.az.x) }
249
+ }
250
+
251
+ step(dt: number): void {
252
+ for (const key of KEYS) criticallyDamped(this.states[key], this.goal[key], this.response, dt)
253
+ }
254
+
255
+ value(): OrbitValue {
256
+ return Object.fromEntries(KEYS.map((key) => [key, this.states[key].x])) as OrbitValue
257
+ }
258
+
259
+ /** The orbit read off a camera that is already somewhere. */
260
+ fromPose(pose: CameraPose): void {
261
+ const dx = pose.position[0] - pose.target[0]
262
+ const dy = pose.position[1] - pose.target[1]
263
+ const dz = pose.position[2] - pose.target[2]
264
+ const r = Math.hypot(dx, dy, dz) || 1
265
+ this.snap({
266
+ x: pose.target[0],
267
+ y: pose.target[1],
268
+ z: pose.target[2],
269
+ r,
270
+ az: Math.atan2(dx, dz),
271
+ el: Math.asin(clamp(dy / r, -1, 1)),
272
+ })
273
+ }
274
+
275
+ /** Where the camera is now: at the radius and angles about its centre, always looking at it, level. */
276
+ pose(): CameraPose {
277
+ return orbitPose(this.value())
278
+ }
279
+
280
+ /** The goal it is flying to. */
281
+ destination(): OrbitValue {
282
+ return { ...this.goal }
283
+ }
284
+ }