@pascal-app/editor 1.0.4 → 1.0.6

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,198 @@
1
+ import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from '@pascal-app/core'
2
+ import { sceneViewBounds, sceneViewFacing } from '@pascal-app/core/agent-operations'
3
+ import type { RevealEvent } from '@pascal-app/viewer'
4
+ import type { Box, FollowWant } from './follow-pascal'
5
+ import { anyExterior } from './follow-pascal-exterior'
6
+
7
+ type Nodes = Readonly<Record<string, AnyNode>>
8
+
9
+ /**
10
+ * The box `view_scene` would frame for `id`, in the site (a building away from the origin is framed
11
+ * where it stands): the same bounds the agent's own views take, so the person sees what the agent
12
+ * would look at. Null for a node there is nothing to frame of.
13
+ */
14
+ function boxOf(nodes: Nodes, id: string): Box | null {
15
+ try {
16
+ const { min, max } = sceneViewBounds(nodes, id)
17
+ return { min, max }
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ function unionOf(boxes: readonly (Box | null)[]): Box | null {
24
+ const known = boxes.filter((box): box is Box => box !== null)
25
+ const first = known[0]
26
+ if (!first) return null
27
+ const min: Box['min'] = [...first.min]
28
+ const max: Box['max'] = [...first.max]
29
+ for (const box of known) {
30
+ for (let axis = 0; axis < 3; axis += 1) {
31
+ min[axis] = Math.min(min[axis]!, box.min[axis]!)
32
+ max[axis] = Math.max(max[axis]!, box.max[axis]!)
33
+ }
34
+ }
35
+ return { min, max }
36
+ }
37
+
38
+ function buildingOf(nodes: Nodes, id: string): string | null {
39
+ let node = nodes[id]
40
+ for (let guard = 0; node && guard < 32; guard += 1) {
41
+ if (node.type === 'building') return node.id
42
+ node = node.parentId ? nodes[node.parentId] : undefined
43
+ }
44
+ return null
45
+ }
46
+
47
+ /** A roof that lifts for the furniture is looked down through: the eye clears its lifted top by this. */
48
+ const LIFTED_ROOF_MARGIN = 1.5
49
+
50
+ /**
51
+ * How high the eye must stand to look at something in a building: above every roof of it that lifts
52
+ * out of the way of the furnishing, where it is lifted to. Undefined when nothing lifts.
53
+ */
54
+ function clearanceAbove(nodes: Nodes, id: string): number | undefined {
55
+ const building = buildingOf(nodes, id)
56
+ if (!building) return undefined
57
+ let highest: number | undefined
58
+ for (const node of Object.values(nodes)) {
59
+ if (node.type !== 'roof' || buildingOf(nodes, node.id) !== building) continue
60
+ const clears = nodeRegistry.get(node.type)?.capabilities?.reveal?.clears
61
+ const box = boxOf(nodes, node.id)
62
+ if (!(clears && box)) continue
63
+ highest = Math.max(highest ?? Number.NEGATIVE_INFINITY, box.max[1] + clears.height)
64
+ }
65
+ return highest === undefined ? undefined : highest + LIFTED_ROOF_MARGIN
66
+ }
67
+
68
+ /** The building `id` is in, roof and all, seen as a group; the eye clears a roof that is lifted. */
69
+ function wholeHouse(nodes: Nodes, id: string, at: number): FollowWant | null {
70
+ const building = buildingOf(nodes, id)
71
+ if (!building) return null
72
+ const roofs = Object.values(nodes).filter(
73
+ (node) => node.type === 'roof' && buildingOf(nodes, node.id) === building,
74
+ )
75
+ const box = unionOf([boxOf(nodes, building), ...roofs.map((roof) => boxOf(nodes, roof.id))])
76
+ if (!box) return null
77
+ const eyeAbove = clearanceAbove(nodes, id)
78
+ return { kind: 'group', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
79
+ }
80
+
81
+ /**
82
+ * A look at the whole house (the furniture has landed; a build has finished), made a look at the
83
+ * level the person is already on: the level whole, as when its floor starts, with no floors above
84
+ * to unhide and no lifted roof to clear.
85
+ */
86
+ export function stayOnLevel(want: FollowWant, nodes: Nodes, levelId: string): FollowWant {
87
+ const box = boxOf(nodes, levelId)
88
+ return box ? { kind: 'floor', box, levelId, at: want.at } : { ...want, levelId }
89
+ }
90
+
91
+ /**
92
+ * What the camera should look at for something the reveal said, or null when it says nothing about
93
+ * where to look. A floor, whole, when its slabs and walls start (the walls are not targets one by
94
+ * one: the floor already is); each opening close up from outside; each piece of furniture from above;
95
+ * the roof from the group that assembles it; the whole house, roof and all, when it is finished. An
96
+ * undo's reverse play is followed the same way, group by group, on what goes.
97
+ */
98
+ export function wantFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWant | null {
99
+ let want = targetFromEvent(event, nodes, at)
100
+ if (want && (want.kind === 'detail' || want.kind === 'furnish' || want.kind === 'roof')) {
101
+ const nodeIds = 'id' in event ? [event.id] : 'nodeIds' in event ? event.nodeIds : []
102
+ want = { ...want, nodeIds }
103
+ }
104
+ if (want && exteriorStep(event, nodes)) want = { ...want, exterior: true }
105
+ if (!want || event.type === 'complete' || event.type === 'finale' || event.type === 'settle')
106
+ return want
107
+ let level = event.levelId ? nodes[event.levelId] : undefined
108
+ if (level?.type !== 'level') {
109
+ const id = 'id' in event ? event.id : event.nodeIds[0]
110
+ const levelId = id ? findLevelAncestorId(id as AnyNodeId, nodes) : null
111
+ level = levelId ? nodes[levelId] : undefined
112
+ }
113
+ return level?.type === 'level' ? { ...want, levelId: level.id } : want
114
+ }
115
+
116
+ /**
117
+ * Whether the step is a change seen from outside, not through a cutaway into a room: a piece the
118
+ * agent edited or moved that stands outdoors (a wall with an exterior face, an opening in one, a
119
+ * roof, the ground). Only edits and moves: a build is followed as it always was, whatever it builds
120
+ * (the owner's call, 15 Oct), and furnishing and interior walls are not outside either.
121
+ */
122
+ function exteriorStep(event: RevealEvent, nodes: Nodes): boolean {
123
+ if (!('changed' in event) || event.changed !== true) return false
124
+ const ids = 'id' in event ? [event.id] : 'nodeIds' in event ? [...event.nodeIds] : []
125
+ return anyExterior(nodes, ids)
126
+ }
127
+
128
+ function targetFromEvent(event: RevealEvent, nodes: Nodes, at: number): FollowWant | null {
129
+ switch (event.type) {
130
+ case 'start': {
131
+ // Hidden floors settle instantly and say only the group, not each node's start.
132
+ if (event.instant && (event.phase === 'openings' || event.phase === 'furnishing')) {
133
+ const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
134
+ return box ? { kind: event.phase === 'openings' ? 'detail' : 'furnish', box, at } : null
135
+ }
136
+ if (event.phase === 'roof') {
137
+ const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
138
+ return box ? { kind: 'roof', box, at } : null
139
+ }
140
+ if (
141
+ event.phase !== 'foundation' &&
142
+ event.phase !== 'structure' &&
143
+ event.phase !== 'circulation'
144
+ )
145
+ return null
146
+ const box =
147
+ (event.levelId ? boxOf(nodes, event.levelId) : null) ??
148
+ unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
149
+ return box ? { kind: 'floor', box, at } : null
150
+ }
151
+ case 'node-start': {
152
+ if (event.phase === 'openings') {
153
+ const box = boxOf(nodes, event.id)
154
+ if (!box) return null
155
+ const normal = sceneViewFacing(nodes, event.id)
156
+ return { kind: 'detail', box, ...(normal ? { normal } : {}), at }
157
+ }
158
+ if (event.phase === 'furnishing') {
159
+ const box = boxOf(nodes, event.id)
160
+ if (!box) return null
161
+ const eyeAbove = clearanceAbove(nodes, event.id)
162
+ return { kind: 'furnish', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
163
+ }
164
+ // A piece the agent edited (a wall bent, a slab resized) plays again: the camera goes to it,
165
+ // from the side that faces outdoors when it has one.
166
+ if (event.changed) {
167
+ const box = boxOf(nodes, event.id)
168
+ if (!box) return null
169
+ const normal = sceneViewFacing(nodes, event.id)
170
+ return { kind: 'detail', box, ...(normal ? { normal } : {}), at }
171
+ }
172
+ return null
173
+ }
174
+ case 'settle': {
175
+ // A piece the agent moved: nothing is coming down on the camera, it stays on the piece.
176
+ if (event.changed) return null
177
+ // The furniture has landed: pull back to the whole house while the roof is still up, so the
178
+ // roof does not come down on a camera that is close above it.
179
+ const id = event.phase === 'furnishing' ? event.nodeIds[0] : undefined
180
+ return id ? wholeHouse(nodes, id, at) : null
181
+ }
182
+ case 'finale':
183
+ return wholeHouse(nodes, event.id, at)
184
+ case 'retract-start': {
185
+ // An undo plays the build backwards: the camera is on what goes, a phase at a time.
186
+ const box = unionOf(event.nodeIds.map((id) => boxOf(nodes, id)))
187
+ if (!box) return null
188
+ if (event.phase === 'roof') return { kind: 'roof', box, at }
189
+ if (event.phase === 'furnishing') {
190
+ const eyeAbove = clearanceAbove(nodes, event.nodeIds[0] ?? '')
191
+ return { kind: 'furnish', box, ...(eyeAbove === undefined ? {} : { eyeAbove }), at }
192
+ }
193
+ return { kind: event.phase === 'openings' ? 'group' : 'floor', box, at }
194
+ }
195
+ default:
196
+ return null
197
+ }
198
+ }
@@ -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
+ })