@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,128 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ BoxGeometry,
4
+ BufferAttribute,
5
+ BufferGeometry,
6
+ Mesh,
7
+ MeshBasicMaterial,
8
+ Raycaster,
9
+ Vector3,
10
+ } from 'three'
11
+ import { cutGroundCollider } from './ground-holes'
12
+
13
+ /** A 10 × 10 m square on the plane y = 0.1 x (two triangles, facing up), plan 0..10 × 0..10. */
14
+ function slopedSquare(flip = false) {
15
+ const corner = (x: number, z: number): [number, number, number] => [x, 0.1 * x, z]
16
+ const up = [
17
+ corner(0, 0),
18
+ corner(0, 10),
19
+ corner(10, 0),
20
+ corner(10, 0),
21
+ corner(0, 10),
22
+ corner(10, 10),
23
+ ]
24
+ const vertices = flip ? [...up].reverse() : up
25
+ const geometry = new BufferGeometry()
26
+ geometry.setAttribute('position', new BufferAttribute(new Float32Array(vertices.flat()), 3))
27
+ const slope = new Vector3(-0.1, 1, 0).normalize()
28
+ const normal = flip ? slope.clone().negate() : slope
29
+ geometry.setAttribute(
30
+ 'normal',
31
+ new BufferAttribute(
32
+ new Float32Array(vertices.flatMap(() => [normal.x, normal.y, normal.z])),
33
+ 3,
34
+ ),
35
+ )
36
+ return geometry
37
+ }
38
+
39
+ const HOLE: Array<[number, number]> = [
40
+ [3, 3],
41
+ [7, 3],
42
+ [7, 6],
43
+ [3, 6],
44
+ ]
45
+
46
+ /** The up (1) or down (-1) facing of every triangle of a non-indexed geometry. */
47
+ function triangles(geometry: BufferGeometry) {
48
+ const position = geometry.getAttribute('position')
49
+ const out: Array<{ facing: number }> = []
50
+ for (let i = 0; i < position.count; i += 3) {
51
+ const [a, b, c] = [0, 1, 2].map(
52
+ (k) => new Vector3(position.getX(i + k), position.getY(i + k), position.getZ(i + k)),
53
+ ) as [Vector3, Vector3, Vector3]
54
+ const normal = b.clone().sub(a).cross(c.clone().sub(a))
55
+ out.push({ facing: Math.sign(normal.y) })
56
+ }
57
+ return out
58
+ }
59
+
60
+ function hitUnder(geometry: BufferGeometry, x: number, z: number): number | null {
61
+ const mesh = new Mesh(geometry, new MeshBasicMaterial())
62
+ mesh.updateMatrixWorld(true)
63
+ const raycaster = new Raycaster()
64
+ raycaster.set(new Vector3(x, 50, z), new Vector3(0, -1, 0))
65
+ return raycaster.intersectObject(mesh, false)[0]?.point.y ?? null
66
+ }
67
+
68
+ describe('cutGroundCollider', () => {
69
+ test('without holes the geometry is returned as it is', () => {
70
+ const geometry = slopedSquare()
71
+ expect(cutGroundCollider(geometry, [])).toBe(geometry)
72
+ })
73
+
74
+ test('the ground is gone over the hole and is the same surface everywhere else', () => {
75
+ const cut = cutGroundCollider(slopedSquare(), [HOLE])
76
+ expect(hitUnder(cut, 5, 4)).toBeNull()
77
+ expect(hitUnder(cut, 3.2, 3.2)).toBeNull()
78
+ expect(hitUnder(cut, 6.8, 5.8)).toBeNull()
79
+ // Past the rim, on the slope the uncut triangle had: y = 0.1 x.
80
+ for (const [x, z] of [
81
+ [2.8, 4],
82
+ [7.2, 4],
83
+ [5, 2.8],
84
+ [5, 6.2],
85
+ [1, 1],
86
+ [9, 9],
87
+ ] as const)
88
+ expect(hitUnder(cut, x, z)).toBeCloseTo(0.1 * x, 5)
89
+ })
90
+
91
+ test('what is left has the area of the ground less the hole, and faces as the ground did', () => {
92
+ for (const flip of [false, true]) {
93
+ const cut = cutGroundCollider(slopedSquare(flip), [HOLE])
94
+ const pieces = triangles(cut)
95
+ // The slope stretches the surface, so compare in plan: 100 - 12 m².
96
+ const position = cut.getAttribute('position')
97
+ let plan = 0
98
+ for (let i = 0; i < position.count; i += 3) {
99
+ const [a, b, c] = [0, 1, 2].map((k) => [position.getX(i + k), position.getZ(i + k)]) as [
100
+ [number, number],
101
+ [number, number],
102
+ [number, number],
103
+ ]
104
+ plan += Math.abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2
105
+ }
106
+ expect(plan).toBeCloseTo(100 - 12, 3)
107
+ for (const piece of pieces) expect(piece.facing).toBe(flip ? -1 : 1)
108
+ }
109
+ })
110
+
111
+ test('the normals follow the ground and stay unit length', () => {
112
+ const cut = cutGroundCollider(slopedSquare(), [HOLE])
113
+ const normal = cut.getAttribute('normal')
114
+ for (let i = 0; i < normal.count; i += 1) {
115
+ expect(Math.hypot(normal.getX(i), normal.getY(i), normal.getZ(i))).toBeCloseTo(1, 5)
116
+ expect(normal.getX(i)).toBeCloseTo(-0.1 / Math.hypot(0.1, 1), 5)
117
+ }
118
+ })
119
+
120
+ test('a triangle wholly inside the hole is dropped, vertical faces are kept', () => {
121
+ const box = new BoxGeometry(2, 2, 2).toNonIndexed()
122
+ box.translate(5, 0, 4.5)
123
+ const before = box.getAttribute('position').count
124
+ const cut = cutGroundCollider(box, [HOLE])
125
+ // Top and bottom faces lie wholly inside the hole and go; the four sides (no plan area) stay.
126
+ expect(cut.getAttribute('position').count).toBe(before - 12)
127
+ })
128
+ })
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Holes in the site ground's collision geometry.
3
+ *
4
+ * The walkthrough stands on one merged BVH mesh, and the site's ground (a flat slab, or the terrain)
5
+ * is a sheet across the whole lot. Where the house stands over a basement the ground is drawn open
6
+ * (`coveredBelowGradeFootprints`), but the sheet under the stairwell stayed: the player descending
7
+ * the stair met it at grade and could go no deeper. Cutting the collider by the same rings the
8
+ * ground is drawn open over lets the player follow the building's floors and stairs there.
9
+ *
10
+ * Every triangle that reaches a hole is cut against it in plan; the pieces keep the source
11
+ * triangle's plane (height and normal interpolated from its corners) and its facing, so the cut
12
+ * surface is exactly the one the uncut triangle was.
13
+ */
14
+
15
+ import { difference, type Ring, union } from '@pascal-app/core'
16
+ import { BufferAttribute, BufferGeometry, ShapeUtils, Vector2 } from 'three'
17
+
18
+ /** Triangles flatter than this in plan (m²) are vertical faces: they hold nothing up. */
19
+ const MIN_PLAN_AREA = 1e-9
20
+
21
+ type Corner = { x: number; y: number; z: number; nx: number; ny: number; nz: number }
22
+
23
+ /** Twice the signed plan area of `a b c` (x right, z down). */
24
+ function orient(a: [number, number], b: [number, number], c: [number, number]): number {
25
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
26
+ }
27
+
28
+ /**
29
+ * `geometry` (non-indexed, `position` and `normal`, in the frame the rings are in) without the part
30
+ * of it that lies over `holes`. A triangle the clip fails on is kept whole: the ground closed is
31
+ * the safe side. The geometry is returned as it is when there are no holes.
32
+ */
33
+ export function cutGroundCollider(
34
+ geometry: BufferGeometry,
35
+ holes: readonly Ring[],
36
+ ): BufferGeometry {
37
+ if (holes.length === 0) return geometry
38
+ const cutouts = union(holes)
39
+ if (cutouts.length === 0) return geometry
40
+ const boxes = cutouts.map(({ outer }) => ({
41
+ minX: Math.min(...outer.map(([x]) => x)),
42
+ maxX: Math.max(...outer.map(([x]) => x)),
43
+ minZ: Math.min(...outer.map(([, z]) => z)),
44
+ maxZ: Math.max(...outer.map(([, z]) => z)),
45
+ }))
46
+
47
+ const position = geometry.getAttribute('position')
48
+ const normal = geometry.getAttribute('normal')
49
+ const positions: number[] = []
50
+ const normals: number[] = []
51
+ const push = (corner: Corner) => {
52
+ positions.push(corner.x, corner.y, corner.z)
53
+ normals.push(corner.nx, corner.ny, corner.nz)
54
+ }
55
+
56
+ for (let i = 0; i + 2 < position.count; i += 3) {
57
+ const corners: Corner[] = [0, 1, 2].map((k) => ({
58
+ x: position.getX(i + k),
59
+ y: position.getY(i + k),
60
+ z: position.getZ(i + k),
61
+ nx: normal.getX(i + k),
62
+ ny: normal.getY(i + k),
63
+ nz: normal.getZ(i + k),
64
+ }))
65
+ const [a, b, c] = corners as [Corner, Corner, Corner]
66
+ const minX = Math.min(a.x, b.x, c.x)
67
+ const maxX = Math.max(a.x, b.x, c.x)
68
+ const minZ = Math.min(a.z, b.z, c.z)
69
+ const maxZ = Math.max(a.z, b.z, c.z)
70
+ const reached = boxes.some(
71
+ (box) => box.maxX > minX && box.minX < maxX && box.maxZ > minZ && box.minZ < maxZ,
72
+ )
73
+ const plan: [[number, number], [number, number], [number, number]] = [
74
+ [a.x, a.z],
75
+ [b.x, b.z],
76
+ [c.x, c.z],
77
+ ]
78
+ const determinant = orient(plan[0], plan[1], plan[2])
79
+ if (!reached || Math.abs(determinant) < MIN_PLAN_AREA) {
80
+ corners.forEach(push)
81
+ continue
82
+ }
83
+
84
+ let pieces: ReturnType<typeof difference>
85
+ try {
86
+ pieces = difference(plan, cutouts, { throwOnError: true })
87
+ } catch {
88
+ corners.forEach(push)
89
+ continue
90
+ }
91
+
92
+ const at = (x: number, z: number): Corner => {
93
+ const wa = ((b.z - c.z) * (x - c.x) + (c.x - b.x) * (z - c.z)) / determinant
94
+ const wb = ((c.z - a.z) * (x - c.x) + (a.x - c.x) * (z - c.z)) / determinant
95
+ const wc = 1 - wa - wb
96
+ const nx = a.nx * wa + b.nx * wb + c.nx * wc
97
+ const ny = a.ny * wa + b.ny * wb + c.ny * wc
98
+ const nz = a.nz * wa + b.nz * wb + c.nz * wc
99
+ const length = Math.hypot(nx, ny, nz) || 1
100
+ return {
101
+ x,
102
+ y: a.y * wa + b.y * wb + c.y * wc,
103
+ z,
104
+ nx: nx / length,
105
+ ny: ny / length,
106
+ nz: nz / length,
107
+ }
108
+ }
109
+
110
+ for (const piece of pieces) {
111
+ const rings = [piece.outer, ...piece.holes]
112
+ const points = rings.flat()
113
+ const contour = piece.outer.map(([x, z]) => new Vector2(x, z))
114
+ const voids = piece.holes.map((ring) => ring.map(([x, z]) => new Vector2(x, z)))
115
+ for (const [p, q, r] of ShapeUtils.triangulateShape(contour, voids)) {
116
+ const corner = [p, q, r].map((index) => points[index as number] as [number, number])
117
+ const swap =
118
+ Math.sign(orient(corner[0]!, corner[1]!, corner[2]!)) !== Math.sign(determinant)
119
+ const ordered = swap ? [corner[0]!, corner[2]!, corner[1]!] : corner
120
+ for (const [x, z] of ordered) push(at(x, z))
121
+ }
122
+ }
123
+ }
124
+
125
+ const cut = new BufferGeometry()
126
+ cut.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
127
+ cut.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3))
128
+ geometry.dispose()
129
+ return cut
130
+ }
@@ -1,14 +1,21 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeId,
5
+ nodeRegistry,
5
6
  registerNode,
6
7
  sceneRegistry,
7
8
  useInteractive,
8
9
  useScene,
9
10
  } from '@pascal-app/core'
10
11
  import { Object3D } from 'three'
11
- import { mechanismHudInteract, mechanismTargetIds } from './mechanism-targets'
12
+ import {
13
+ hasNativeLeaf,
14
+ mechanismHudInteract,
15
+ mechanismTargetIds,
16
+ nativeOpeningIds,
17
+ watchScriptedDoors,
18
+ } from './mechanism-targets'
12
19
 
13
20
  const switchMechanism = {
14
21
  has: (node: AnyNode) => (node as unknown as { joints: number }).joints > 0,
@@ -73,3 +80,83 @@ describe('walkthrough mechanism targets', () => {
73
80
  expect(mechanismHudInteract(mount(registerKind({}), 'plain_hud'))).toBeNull()
74
81
  })
75
82
  })
83
+
84
+ // A door or window built from a script has no native leaf or sash: the walkthrough's own door and
85
+ // window targets only start a native swing on it, which draws nothing. Its `open` clip runs
86
+ // through its mechanism, as an authored object's does.
87
+ describe('walkthrough doors and windows', () => {
88
+ const script = { source: { kind: 'script', script: 'b'.repeat(64), artifact: 'a'.repeat(64) } }
89
+
90
+ test('the doors and windows it swings itself are the ones with a native leaf', () => {
91
+ const native = mount('door', 'door_native')
92
+ const scripted = mount('door', 'door_scripted', script)
93
+ mount('window', 'window_native')
94
+ mount('window', 'window_scripted', script)
95
+
96
+ expect(nativeOpeningIds().sort()).toEqual(['door_native', 'window_native'] as AnyNodeId[])
97
+ expect(hasNativeLeaf(native)).toBe(true)
98
+ expect(hasNativeLeaf(scripted)).toBe(false)
99
+ expect(hasNativeLeaf(undefined)).toBe(true)
100
+ })
101
+ })
102
+
103
+ // The walkthrough's collider world is rebuilt when a native door's tween ends. A door built from a
104
+ // script opens by a clip that no tween system reports: its collider follows its mechanism, so
105
+ // the world has to be rebuilt when that flips.
106
+ describe('walkthrough scripted doors', () => {
107
+ const mechanism = {
108
+ verb: 'open',
109
+ has: (node: AnyNode) => Boolean((node as unknown as { source?: unknown }).source),
110
+ isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
111
+ Boolean(state.items[node.id as AnyNodeId]?.controlValues[0]),
112
+ set: () => {},
113
+ }
114
+ let restoreRegistry = () => {}
115
+
116
+ beforeEach(() => {
117
+ restoreRegistry = nodeRegistry._snapshot()
118
+ registerNode({
119
+ kind: 'door',
120
+ schemaVersion: 1,
121
+ category: 'structure',
122
+ defaults: () => ({}),
123
+ capabilities: { mechanism },
124
+ } as never)
125
+ })
126
+
127
+ afterEach(() => {
128
+ useInteractive.setState({ items: {} } as never)
129
+ restoreRegistry()
130
+ })
131
+
132
+ test('a rebuild is asked for when a scripted door opens or closes, and for nothing else', () => {
133
+ mount('door', 'door_scripted_watch', { source: { kind: 'script' } })
134
+ mount('door', 'door_native_watch')
135
+ let rebuilds = 0
136
+ const stop = watchScriptedDoors(() => rebuilds++)
137
+ const setControls = (scripted: boolean, native: boolean) =>
138
+ useInteractive.setState({
139
+ items: {
140
+ door_scripted_watch: { controlValues: [scripted] },
141
+ door_native_watch: { controlValues: [native] },
142
+ },
143
+ } as never)
144
+ try {
145
+ // its model initialising the item closed is not a change
146
+ setControls(false, false)
147
+ expect(rebuilds).toBe(0)
148
+ setControls(true, false)
149
+ expect(rebuilds).toBe(1)
150
+ // the same state again, and a native door's control, are not its business
151
+ setControls(true, false)
152
+ setControls(true, true)
153
+ expect(rebuilds).toBe(1)
154
+ setControls(false, true)
155
+ expect(rebuilds).toBe(2)
156
+ } finally {
157
+ stop()
158
+ }
159
+ setControls(true, true)
160
+ expect(rebuilds).toBe(2)
161
+ })
162
+ })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ isScriptedNode,
4
5
  nodeMechanism,
5
6
  nodeRegistry,
6
7
  sceneRegistry,
@@ -9,6 +10,27 @@ import {
9
10
  } from '@pascal-app/core'
10
11
  import type { WalkthroughInteract } from '../../../store/use-first-person-hud'
11
12
 
13
+ /**
14
+ * Whether the walkthrough swings this door or window itself, through the native `operationState`
15
+ * its door and window systems draw. One built from a script has no native leaf or sash: a swing
16
+ * would draw nothing, so its clips run through its mechanism, as an authored object's do.
17
+ */
18
+ export function hasNativeLeaf(node: AnyNode | undefined): boolean {
19
+ return !isScriptedNode(node)
20
+ }
21
+
22
+ /** Mounted doors and windows the walkthrough's own door and window targets cover. */
23
+ export function nativeOpeningIds(): AnyNodeId[] {
24
+ const nodes = useScene.getState().nodes
25
+ const ids: AnyNodeId[] = []
26
+ for (const kind of ['door', 'window'] as const) {
27
+ for (const id of sceneRegistry.byType[kind] ?? []) {
28
+ if (hasNativeLeaf(nodes[id as AnyNodeId])) ids.push(id as AnyNodeId)
29
+ }
30
+ }
31
+ return ids
32
+ }
33
+
12
34
  /**
13
35
  * Mounted nodes the walkthrough runs through `capabilities.mechanism`, any kind
14
36
  * included. `covered` holds the nodes a finer target already tested (procedural
@@ -34,3 +56,33 @@ export function mechanismHudInteract(node: AnyNode): WalkthroughInteract {
34
56
  const verb = mechanism.verb === 'open' ? (on ? 'close' : 'open') : on ? 'turn off' : 'turn on'
35
57
  return { label: node.name ?? 'item', verb }
36
58
  }
59
+
60
+ /** Ids of the mounted doors built from a script that are open, by their mechanism. */
61
+ function openScriptedDoors(): string {
62
+ const nodes = useScene.getState().nodes
63
+ const state = useInteractive.getState()
64
+ const open: string[] = []
65
+ for (const id of sceneRegistry.byType.door ?? []) {
66
+ const node = nodes[id as AnyNodeId]
67
+ if (isScriptedNode(node) && nodeMechanism(node)?.isOn(node, state)) open.push(id)
68
+ }
69
+ return open.sort().join(',')
70
+ }
71
+
72
+ /**
73
+ * Calls `onChange` when a mounted door built from a script opens or closes. The walkthrough's
74
+ * collider follows the door's mechanism, and its model's clip is moved by no tween system that
75
+ * would report the end, as a native door's does: without this the world keeps the leaf that was
76
+ * there when it was last built. It fires at the toggle: the doorway is passable as the leaves
77
+ * start to move, and solid again as they start to close.
78
+ */
79
+ export function watchScriptedDoors(onChange: () => void): () => void {
80
+ let known = openScriptedDoors()
81
+ return useInteractive.subscribe((state, previous) => {
82
+ if (state.items === previous.items) return
83
+ const next = openScriptedDoors()
84
+ if (next === known) return
85
+ known = next
86
+ onChange()
87
+ })
88
+ }
@@ -81,6 +81,7 @@ import {
81
81
  isOperationDoorType,
82
82
  toggleDoorOpenState,
83
83
  } from '../../lib/door-interaction'
84
+ import { isTypingTarget } from '../../lib/typing-target'
84
85
  import {
85
86
  closeWindowOpenState,
86
87
  getDisplayedWindowValue,
@@ -97,7 +98,13 @@ import {
97
98
  type FirstPersonColliderWorld,
98
99
  type FirstPersonSpawn,
99
100
  } from './first-person/build-collider-world'
100
- import { mechanismHudInteract, mechanismTargetIds } from './first-person/mechanism-targets'
101
+ import {
102
+ hasNativeLeaf,
103
+ mechanismHudInteract,
104
+ mechanismTargetIds,
105
+ nativeOpeningIds,
106
+ watchScriptedDoors,
107
+ } from './first-person/mechanism-targets'
101
108
 
102
109
  const CAMERA_EYE_OFFSET = 0.45
103
110
  const LOOK_SENSITIVITY = 0.002
@@ -515,7 +522,7 @@ function createElevatorColliderMesh(
515
522
  ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
516
523
  ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
517
524
  bvhGeometry.computeBoundsTree?.({
518
- maxLeafSize: 12,
525
+ targetLeafSize: 12,
519
526
  strategy: 0,
520
527
  } as never)
521
528
  bvhGeometry.computeBoundingBox()
@@ -838,6 +845,7 @@ export const FirstPersonControls = () => {
838
845
  const node = nodes[doorId as AnyNodeId]
839
846
  if (node?.type !== 'door') continue
840
847
  if (node.openingKind === 'opening') continue
848
+ if (!hasNativeLeaf(node)) continue
841
849
  if (node.segments.every((segment) => segment.type === 'empty')) continue
842
850
 
843
851
  const object = sceneRegistry.nodes.get(doorId)
@@ -936,6 +944,7 @@ export const FirstPersonControls = () => {
936
944
  const node = nodes[windowId as AnyNodeId]
937
945
  if (node?.type !== 'window') continue
938
946
  if (node.openingKind === 'opening') continue
947
+ if (!hasNativeLeaf(node)) continue
939
948
  if (!isOperableWindowType(node.windowType)) continue
940
949
 
941
950
  const object = sceneRegistry.nodes.get(windowId)
@@ -1082,9 +1091,9 @@ export const FirstPersonControls = () => {
1082
1091
  closestDistance = hit.distance
1083
1092
  }
1084
1093
  }
1085
- // Doors and windows have their own walkthrough targets (resolveInteractable*Id).
1086
- for (const rawId of sceneRegistry.byType.door ?? []) covered.add(rawId)
1087
- for (const rawId of sceneRegistry.byType.window ?? []) covered.add(rawId)
1094
+ // Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those
1095
+ // built from a script: their clips run through the mechanism target below.
1096
+ for (const id of nativeOpeningIds()) covered.add(id)
1088
1097
  for (const id of mechanismTargetIds(covered)) {
1089
1098
  const object = sceneRegistry.nodes.get(id)
1090
1099
  const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0]
@@ -1298,9 +1307,12 @@ export const FirstPersonControls = () => {
1298
1307
  if (isDroneMode) return
1299
1308
  emitter.on('door:animation-completed', rebuildColliderWorld)
1300
1309
  emitter.on('window:animation-completed', rebuildColliderWorld)
1310
+ // A door built from a script opens by its model's clip, which reports no animation end.
1311
+ const stopWatching = watchScriptedDoors(rebuildColliderWorld)
1301
1312
  return () => {
1302
1313
  emitter.off('door:animation-completed', rebuildColliderWorld)
1303
1314
  emitter.off('window:animation-completed', rebuildColliderWorld)
1315
+ stopWatching()
1304
1316
  }
1305
1317
  }, [isDroneMode, rebuildColliderWorld])
1306
1318
 
@@ -1456,9 +1468,7 @@ export const FirstPersonControls = () => {
1456
1468
  const canvas = gl.domElement
1457
1469
 
1458
1470
  const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
1459
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1460
- return false
1461
- }
1471
+ if (isTypingTarget(event.target)) return false
1462
1472
 
1463
1473
  const movement = getMovementInputForKey(event.code, active)
1464
1474
  if (!movement) return false
@@ -1473,9 +1483,7 @@ export const FirstPersonControls = () => {
1473
1483
  const handledMovement = applyMovementKey(event, true)
1474
1484
  if (handledMovement) return
1475
1485
 
1476
- if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
1477
- return
1478
- }
1486
+ if (isTypingTarget(event.target)) return
1479
1487
 
1480
1488
  if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
1481
1489
  // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
@@ -2,11 +2,13 @@ import { afterAll, beforeAll, expect, test } from 'bun:test'
2
2
  import { CameraControlsImpl } from '@react-three/drei'
3
3
  import * as THREE from 'three'
4
4
  import {
5
+ diveFloorY,
5
6
  LOOK_UP_PITCH_GAIN,
6
7
  LOOK_UP_PUSH_HALF_ANGLE,
7
8
  MAX_ORBIT_POLAR_ANGLE,
8
9
  ORBIT_FLOOR_CLEARANCE,
9
10
  ORBIT_TARGET_HEIGHT,
11
+ orbitFloorMinY,
10
12
  } from '../../lib/orbit-floor-clearance'
11
13
  import { FloorAwareCameraControls } from './floor-aware-camera-controls'
12
14
 
@@ -440,3 +442,25 @@ test('once the user moves the camera, a written low pose eases up to the clearan
440
442
  }
441
443
  expect(controls.camera.position.y).toBeCloseTo(0.05 + GROUND_MIN_Y, 4)
442
444
  })
445
+
446
+ test('with the dive on the eye goes under the ground and stops at the dive floor', () => {
447
+ const controls = groundFloorControls()
448
+ const diveFloor = diveFloorY(-0.05, null)
449
+ controls.floorMinY = orbitFloorMinY(0, diveFloor)
450
+ // The dive turns the surface off: the ground and the floor would hold the eye over them.
451
+ controls.floorSurface = null
452
+ const { distance } = controls
453
+ orbitTo(controls, 100)
454
+ // The pivot is at the ground's level: 10° past the horizon the eye is about a metre under it.
455
+ expect(controls.camera.position.y).toBeCloseTo(distance * Math.cos(degrees(100)), 6)
456
+ expect(controls.camera.position.y).toBeLessThan(0)
457
+ orbitTo(controls, 150)
458
+ expect(controls.camera.position.y).toBeCloseTo(diveFloor + ORBIT_FLOOR_CLEARANCE, 6)
459
+ })
460
+
461
+ test('without the dive the same orbit stays over the ground floor', () => {
462
+ const controls = groundFloorControls()
463
+ controls.floorSurface = { revision: 0, topAt: () => 0 }
464
+ orbitTo(controls, 100)
465
+ expect(controls.camera.position.y).toBeGreaterThanOrEqual(GROUND_MIN_Y - 1e-6)
466
+ })
@@ -59,7 +59,7 @@ export function FloorplanModeCoordinator() {
59
59
  <span>{notice.message}</span>
60
60
  {notice.kind === 'switch-to-expert' ? (
61
61
  <button
62
- className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
62
+ className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-700 dark:text-cyan-400 hover:bg-cyan-500/25"
63
63
  onClick={() => {
64
64
  setFloorplanMode('expert')
65
65
  dismissNotice()
@@ -71,7 +71,7 @@ export function FloorplanModeCoordinator() {
71
71
  ) : null}
72
72
  <button
73
73
  aria-label="Dismiss"
74
- className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
74
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
75
75
  onClick={dismissNotice}
76
76
  type="button"
77
77
  >