@pascal-app/editor 1.0.4 → 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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  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
@@ -7,7 +7,10 @@ import {
7
7
  type CameraControlFitSceneEvent,
8
8
  type CameraControlFrameEvent,
9
9
  type CameraPose,
10
+ deepestDrawnY,
10
11
  emitter,
12
+ groundHeightAt,
13
+ lowestGroundY,
11
14
  sceneRegistry,
12
15
  useScene,
13
16
  } from '@pascal-app/core'
@@ -53,9 +56,11 @@ import {
53
56
  syncKeyboardPanMotion,
54
57
  } from '../../lib/keyboard-pan'
55
58
  import {
59
+ diveFloorY,
56
60
  MAX_ORBIT_POLAR_ANGLE,
57
- ORBIT_FLOOR_CLEARANCE,
58
61
  ORBIT_TARGET_HEIGHT,
62
+ orbitFloorMinY,
63
+ overviewEyeY,
59
64
  } from '../../lib/orbit-floor-clearance'
60
65
  import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority'
61
66
  import { frameViewerCamera } from '../../lib/viewer-selection'
@@ -102,6 +107,18 @@ function orbitGroundY(
102
107
  return floorY + ORBIT_TARGET_HEIGHT
103
108
  }
104
109
 
110
+ /**
111
+ * `eyeY` for an overview eye at (x, z) whose pivot is being sent to another level: kept over the
112
+ * ground it was over (a deep level shown is looked at through the hole, not from under the lid),
113
+ * unless the person has dived.
114
+ */
115
+ function keepEyeOverGround(x: number, eyeY: number, z: number, previousEyeY = eyeY): number {
116
+ const { nodes } = useScene.getState()
117
+ const siteId = sceneRegistry.byType.site?.values().next().value as AnyNodeId | undefined
118
+ const groundY = groundHeightAt(siteId ? nodes[siteId] : null, x, z)
119
+ return overviewEyeY(eyeY, previousEyeY, groundY, useViewer.getState().eyeUnderGround)
120
+ }
121
+
105
122
  function levelOf(nodeId: string): string | null {
106
123
  const nodes = useScene.getState().nodes
107
124
  let node: AnyNode | undefined = nodes[nodeId as AnyNodeId]
@@ -393,7 +410,23 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
393
410
  currentLevelId ? getLevelPresentationY(currentLevelId, state.nodes, levelMode) : 0,
394
411
  )
395
412
  const allowUnderground = !isPreviewMode && allowUndergroundCamera
396
- const floorMinY = allowUnderground ? null : floorY + ORBIT_FLOOR_CLEARANCE
413
+ // With the ground on Auto the eye may dive under any building, down to the dive's floor; Solid
414
+ // keeps the eye over the ground.
415
+ const groundMode = useViewer((state) => state.groundMode)
416
+ const diveFloor = useScene((state) =>
417
+ groundMode === 'auto'
418
+ ? diveFloorY(lowestGroundY(state.nodes), deepestDrawnY(state.nodes))
419
+ : null,
420
+ )
421
+ const floorMinY = allowUnderground ? null : orbitFloorMinY(floorY, diveFloor)
422
+ // The viewer mounts the eye's fill light before the first dive, so crossing the ground compiles nothing.
423
+ const mayDive = allowUnderground || diveFloor !== null
424
+ useEffect(() => {
425
+ useViewer.setState({ diveAvailable: mayDive })
426
+ return () => {
427
+ useViewer.setState({ diveAvailable: false })
428
+ }
429
+ }, [mayDive])
397
430
  const maxPolarAngle =
398
431
  allowUnderground || cameraMode === 'perspective'
399
432
  ? MAX_ORBIT_POLAR_ANGLE
@@ -594,12 +627,39 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
594
627
  if (!controls.current) return
595
628
  if (firstLoad.current) {
596
629
  firstLoad.current = false
597
- controls.current.setLookAt(20, 20 + targetY, 20, 0, targetY, 0, true)
630
+ controls.current.setLookAt(
631
+ 20,
632
+ keepEyeOverGround(20, 20 + targetY, 20, 20),
633
+ 20,
634
+ 0,
635
+ targetY,
636
+ 0,
637
+ true,
638
+ )
598
639
  }
599
640
  controls.current.getOrbitTarget(currentTarget)
600
641
  // Idempotence guard: skip when already there — also swallows the thumbnail
601
642
  // generator's synchronous stacked→restore levelMode round-trip.
602
643
  if (Math.abs(currentTarget.y - targetY) < 1e-3) return
644
+ // The pivot goes down with the level; the eye follows it unless that would sink it under the
645
+ // ground (a basement four storeys down): then it stays over the ground, looking down.
646
+ controls.current.getPosition(tempPosition, false)
647
+ const movedEyeY = tempPosition.y + targetY - currentTarget.y
648
+ const eyeY = isPerspectiveCamera(camera)
649
+ ? keepEyeOverGround(tempPosition.x, movedEyeY, tempPosition.z, tempPosition.y)
650
+ : movedEyeY
651
+ if (eyeY > movedEyeY) {
652
+ controls.current.setLookAt(
653
+ tempPosition.x,
654
+ eyeY,
655
+ tempPosition.z,
656
+ currentTarget.x,
657
+ targetY,
658
+ currentTarget.z,
659
+ true,
660
+ )
661
+ return
662
+ }
603
663
  controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
604
664
  }, [
605
665
  camera,
@@ -627,8 +687,9 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
627
687
  floorSurface.levelId = currentLevelId ?? null
628
688
  if (!controls.current) return
629
689
  controls.current.floorMinY = floorMinY
630
- controls.current.floorSurface = floorSurface
631
- }, [camera, currentLevelId, floorMinY, floorSurface, isFirstPersonMode])
690
+ // The level's floor and the ground hold the eye over them; the dive goes through both.
691
+ controls.current.floorSurface = diveFloor === null ? floorSurface : null
692
+ }, [camera, currentLevelId, diveFloor, floorMinY, floorSurface, isFirstPersonMode])
632
693
 
633
694
  const focusNode = useCallback(
634
695
  (nodeId: string) => {
@@ -660,7 +721,11 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
660
721
  controls.current.getTarget(tempTarget)
661
722
  tempDelta.copy(tempCenter).sub(tempTarget)
662
723
 
663
- const focusedY = tempPosition.y + tempDelta.y
724
+ const focusedX = tempPosition.x + tempDelta.x
725
+ const focusedZ = tempPosition.z + tempDelta.z
726
+ const focusedY = isPerspectiveCamera(camera)
727
+ ? keepEyeOverGround(focusedX, tempPosition.y + tempDelta.y, focusedZ, tempPosition.y)
728
+ : tempPosition.y + tempDelta.y
664
729
  const floorMinY = isPerspectiveCamera(camera) ? controls.current.floorLimitY : null
665
730
  controls.current.setLookAt(
666
731
  tempPosition.x + tempDelta.x,
@@ -1315,7 +1380,15 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1315
1380
  const groundY = orbitGroundY(lastLevelId.current)
1316
1381
  if (!bounds) {
1317
1382
  // Restore default framing pose when no bounds were computed.
1318
- controls.current.setLookAt(20, 20 + groundY, 20, 0, groundY, 0, true)
1383
+ controls.current.setLookAt(
1384
+ 20,
1385
+ keepEyeOverGround(20, 20 + groundY, 20, 20),
1386
+ 20,
1387
+ 0,
1388
+ groundY,
1389
+ 0,
1390
+ true,
1391
+ )
1319
1392
  return
1320
1393
  }
1321
1394
  const [cx, cz] = bounds.center
@@ -1327,7 +1400,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) =
1327
1400
  const height = Math.max(maxExtent * 0.8, 10)
1328
1401
  controls.current.setLookAt(
1329
1402
  cx + distance * 0.7,
1330
- height + groundY,
1403
+ keepEyeOverGround(cx + distance * 0.7, height + groundY, cz + distance * 0.7, height),
1331
1404
  cz + distance * 0.7,
1332
1405
  cx,
1333
1406
  groundY,
@@ -214,7 +214,7 @@ export function EditorLayoutMobile({
214
214
  }, [panelSheetHeight, committedSheetH])
215
215
 
216
216
  return (
217
- <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
217
+ <div className="flex h-full w-full flex-col bg-sidebar text-foreground">
218
218
  {navbarSlot}
219
219
 
220
220
  <div
@@ -332,7 +332,7 @@ export function EditorLayoutV2({
332
332
  }
333
333
 
334
334
  return (
335
- <div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
335
+ <div className="flex h-full w-full flex-col bg-sidebar text-foreground">
336
336
  {/* Top navbar */}
337
337
  {navbarSlot}
338
338
 
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { emitter } from '@pascal-app/core'
3
+ import { emitter, getProjectFileSink, type ProjectFileSink } from '@pascal-app/core'
4
4
  import { disposeObject3DResources, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
5
5
  import { useThree } from '@react-three/fiber'
6
6
  import { useEffect } from 'react'
7
7
  import * as THREE from 'three'
8
8
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
9
9
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
10
+ import { downloadProjectFile } from '../../lib/download-project-file'
10
11
  import {
11
12
  exportSceneToGlb,
12
13
  prepareSceneForExport,
@@ -58,6 +59,7 @@ export function ExportManager() {
58
59
 
59
60
  useEffect(() => {
60
61
  const exportFn: ModelExport = async (format = 'glb', requested = {}) => {
62
+ const finishArtifact = artifactFinisher(getProjectFileSink())
61
63
  // Resolve once: scene preparation prunes hidden nodes by default, and the
62
64
  // IFC writer must drop the same nodes it rebuilds parametrically.
63
65
  const options = { ...requested, onlyVisible: requested.onlyVisible ?? true }
@@ -262,22 +264,15 @@ export function ExportManager() {
262
264
  return null
263
265
  }
264
266
 
265
- function finishArtifact(
266
- blob: Blob,
267
- filename: string,
268
- download: boolean | undefined,
269
- metadata?: unknown,
270
- warnings?: readonly string[],
271
- ): ModelExportArtifact {
272
- if (download !== false) downloadBlob(blob, filename)
273
- return { blob, filename, metadata, warnings: warnings?.length ? warnings : undefined }
274
- }
275
-
276
- function downloadBlob(blob: Blob, filename: string) {
277
- const url = URL.createObjectURL(blob)
278
- const link = document.createElement('a')
279
- link.href = url
280
- link.download = filename
281
- link.click()
282
- URL.revokeObjectURL(url)
267
+ function artifactFinisher(sink: ProjectFileSink | null) {
268
+ return async (
269
+ blob: Blob,
270
+ filename: string,
271
+ download: boolean | undefined,
272
+ metadata?: unknown,
273
+ warnings?: readonly string[],
274
+ ): Promise<ModelExportArtifact> => {
275
+ if (download !== false) await downloadProjectFile(blob, filename, sink)
276
+ return { blob, filename, metadata, warnings: warnings?.length ? warnings : undefined }
277
+ }
283
278
  }
@@ -7,16 +7,20 @@ import {
7
7
  CeilingNode,
8
8
  ColumnNode,
9
9
  createTerrainField,
10
+ DoorNode,
10
11
  ElevatorNode,
11
12
  encodeTerrainField,
12
13
  flattenPatch,
14
+ GeometryArtifactManifest,
13
15
  LevelNode,
14
16
  nodeRegistry,
15
17
  registerNode,
16
18
  ShelfNode,
17
19
  SiteNode,
18
20
  SlabNode,
21
+ StairNode,
19
22
  sceneRegistry,
23
+ useInteractive,
20
24
  useScene,
21
25
  } from '@pascal-app/core'
22
26
  import { hideFromScene, STAND_CLEARANCE, showInScene } from '@pascal-app/viewer'
@@ -325,3 +329,268 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
325
329
  world.dispose()
326
330
  })
327
331
  })
332
+
333
+ // A door built from a script is drawn by its model: the leaves are the model's own and its `open`
334
+ // clip moves them. What the player is stopped by is a stand-in leaf box at the door's frame, and
335
+ // that box has to follow the door's own open state, not the native `operationState` the clip
336
+ // never touches.
337
+ describe('a door built from a script', () => {
338
+ const source = {
339
+ kind: 'script' as const,
340
+ script: 'b'.repeat(64),
341
+ artifact: 'a'.repeat(64),
342
+ manifest: GeometryArtifactManifest.parse({
343
+ bounds: { min: [-0.6, 0, -0.11], max: [0.6, 2.55, 0.11] },
344
+ triangles: 12,
345
+ animations: [{ name: 'open', duration: 1.3 }],
346
+ }),
347
+ }
348
+ // The door mechanism's open state for a script: the first control toggle of the item.
349
+ const mechanism = {
350
+ verb: 'open',
351
+ icon: 'door',
352
+ has: (node: AnyNode) => Boolean((node as { source?: unknown }).source),
353
+ isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
354
+ Boolean(state.items[node.id as never]?.controlValues[0]),
355
+ set: () => {},
356
+ }
357
+
358
+ function mountScriptedDoor(language: 'three' | 'bpy' = 'three') {
359
+ registerNode({
360
+ kind: 'door',
361
+ schema: DoorNode,
362
+ schemaVersion: 1,
363
+ category: 'structure',
364
+ capabilities: { mechanism },
365
+ } as unknown as AnyNodeDefinition)
366
+ registerColliderDefinition('column', ColumnNode, 'structure')
367
+ const door = DoorNode.parse({
368
+ id: 'door_shoji',
369
+ doorType: 'sliding',
370
+ width: 1.2,
371
+ height: 2.55,
372
+ source: { ...source, language },
373
+ })
374
+ const column = ColumnNode.parse({ id: 'column_far' })
375
+ setSceneNodes([door, column])
376
+ mountNode(column, [1, 2, 1], [10, 1, 0])
377
+ // The door's registered group, as its renderer builds it: the compiled model under it, with
378
+ // the agent's 1 m deep `cutout` box and two leaves.
379
+ const group = new Group()
380
+ group.position.set(0, 1.275, 0)
381
+ const box = (name: string, size: [number, number, number], x: number, z: number) => {
382
+ const mesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial())
383
+ mesh.name = name
384
+ mesh.position.set(x, 0, z)
385
+ group.add(mesh)
386
+ }
387
+ box('cutout', [1.2, 2.55, 1], 0, 0)
388
+ box('leaf_0', [0.5, 2.4, 0.02], -0.255, 0.025)
389
+ box('leaf_1', [0.5, 2.4, 0.02], 0.255, -0.025)
390
+ group.updateMatrixWorld(true)
391
+ sceneRegistry.nodes.set(door.id, group)
392
+ sceneRegistry.byType.door!.add(door.id)
393
+ return door
394
+ }
395
+
396
+ /** Walk straight at the doorway from the front, at hip height: where the first thing is hit. */
397
+ function firstHitAcrossDoorway() {
398
+ const world = buildFirstPersonColliderWorldFromRegistry()
399
+ if (!world) return null
400
+ const raycaster = new Raycaster(new Vector3(0, 1, -3), new Vector3(0, 0, 1))
401
+ const hit = raycaster.intersectObject(world.mesh, false)[0]
402
+ world.dispose()
403
+ return hit ? { z: hit.point.z } : null
404
+ }
405
+
406
+ afterEach(() => {
407
+ useInteractive.setState({ items: {} } as never)
408
+ sceneRegistry.clear()
409
+ nodeRegistry._reset()
410
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
411
+ })
412
+
413
+ test('a closed one stops the player at its leaf, not at the model’s cutout box', () => {
414
+ mountScriptedDoor()
415
+ useInteractive.setState({ items: { door_shoji: { controlValues: [false] } } } as never)
416
+
417
+ const hit = firstHitAcrossDoorway()
418
+ expect(hit).not.toBeNull()
419
+ // the stand-in leaf is 0.06 m deep at the frame; the cutout would stop the player at z = -0.5
420
+ expect(hit!.z).toBeCloseTo(-0.03, 3)
421
+ })
422
+
423
+ test('an open one can be walked through, and its cutout never collides', () => {
424
+ mountScriptedDoor()
425
+ useInteractive.setState({ items: { door_shoji: { controlValues: [true] } } } as never)
426
+
427
+ expect(firstHitAcrossDoorway()).toBeNull()
428
+ })
429
+ test('a Blender-built door follows its mechanism the same way: the language makes no difference', () => {
430
+ mountScriptedDoor('bpy')
431
+ useInteractive.setState({ items: { door_shoji: { controlValues: [false] } } } as never)
432
+ expect(firstHitAcrossDoorway()?.z).toBeCloseTo(-0.03, 3)
433
+
434
+ useInteractive.setState({ items: { door_shoji: { controlValues: [true] } } } as never)
435
+ expect(firstHitAcrossDoorway()).toBeNull()
436
+ })
437
+ })
438
+
439
+ // The ground is a sheet across the lot, and under a house with a basement the sheet ran across the
440
+ // stairwell: a walker going down the stair met it at grade. The ground is drawn open over what the
441
+ // house covers (`coveredBelowGradeFootprints`), and the walker's ground is cut open over the same.
442
+ describe('a basement under the house', () => {
443
+ afterEach(() => {
444
+ sceneRegistry.clear()
445
+ nodeRegistry._reset()
446
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
447
+ })
448
+
449
+ type Box = { size: [number, number, number]; at: [number, number, number] }
450
+
451
+ function mountBoxes(node: AnyNode, boxes: Box[]) {
452
+ const group = new Group()
453
+ for (const { size, at } of boxes) {
454
+ const mesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial())
455
+ mesh.position.set(...at)
456
+ group.add(mesh)
457
+ }
458
+ group.updateMatrixWorld(true)
459
+ sceneRegistry.nodes.set(node.id, group)
460
+ sceneRegistry.byType[node.type]!.add(node.id)
461
+ }
462
+
463
+ const rect = (x0: number, x1: number, z0: number, z1: number): Array<[number, number]> => [
464
+ [x0, z0],
465
+ [x1, z0],
466
+ [x1, z1],
467
+ [x0, z1],
468
+ ]
469
+ const TREADS = 14
470
+ const RISE = 0.2
471
+ const FLOOR_G = 0.05
472
+ const FLOOR_B = -2.95
473
+
474
+ /**
475
+ * An 8 m square house on a flat site (or a flat sculpted one) over a basement whose floor lies at
476
+ * -2.95 m, its plan 1.3..6.7 × 1.4..6.6. The ground floor is a slab with the stairwell cut out of
477
+ * it, and a stair of 14 treads, 0.2 m each, goes down along +x from x = 2 at z 3..4.
478
+ */
479
+ function house(sculpted: boolean) {
480
+ registerColliderDefinition('slab', SlabNode, 'structure', 'floor')
481
+ registerColliderDefinition('stair', StairNode, 'structure')
482
+ const field = createTerrainField({ cols: 21, rows: 21, spacing: 1, origin: [-6, -6] })
483
+ const site = SiteNode.parse({
484
+ id: 'site_test',
485
+ children: ['building_a'],
486
+ ...(sculpted ? { terrain: encodeTerrainField(field) } : {}),
487
+ })
488
+ const building = BuildingNode.parse({
489
+ id: 'building_a',
490
+ parentId: site.id,
491
+ children: ['level_b', 'level_g'],
492
+ })
493
+ const basement = LevelNode.parse({
494
+ id: 'level_b',
495
+ parentId: building.id,
496
+ level: -1,
497
+ baseElevation: -3,
498
+ height: 3,
499
+ children: ['slab_b'],
500
+ })
501
+ const ground = LevelNode.parse({
502
+ id: 'level_g',
503
+ parentId: building.id,
504
+ level: 0,
505
+ height: 3,
506
+ children: ['slab_g'],
507
+ })
508
+ const slabB = SlabNode.parse({
509
+ id: 'slab_b',
510
+ parentId: basement.id,
511
+ polygon: rect(1.3, 6.7, 1.4, 6.6),
512
+ elevation: 0.05,
513
+ })
514
+ const slabG = SlabNode.parse({
515
+ id: 'slab_g',
516
+ parentId: ground.id,
517
+ polygon: rect(0, 8, 0, 8),
518
+ elevation: FLOOR_G,
519
+ })
520
+ const stair = StairNode.parse({ id: 'stair_a', parentId: basement.id })
521
+ setSceneNodes([site, building, basement, ground, slabB, slabG, stair])
522
+ mountRegistryGroup(site)
523
+ mountBoxes(slabB, [{ size: [5.4, 0.1, 5.2], at: [4, FLOOR_B - 0.05, 4] }])
524
+ // The ground floor around the stairwell (x 2..5.4, z 3..4).
525
+ mountBoxes(slabG, [
526
+ { size: [2, 0.1, 8], at: [1, FLOOR_G - 0.05, 4] },
527
+ { size: [2.6, 0.1, 8], at: [6.7, FLOOR_G - 0.05, 4] },
528
+ { size: [3.4, 0.1, 3], at: [3.7, FLOOR_G - 0.05, 1.5] },
529
+ { size: [3.4, 0.1, 4], at: [3.7, FLOOR_G - 0.05, 6] },
530
+ ])
531
+ mountBoxes(
532
+ stair,
533
+ Array.from({ length: TREADS }, (_, i) => ({
534
+ size: [0.3, RISE, 1] as [number, number, number],
535
+ at: [2.15 + 0.3 * i, FLOOR_G - RISE * (i + 1) - RISE / 2, 3.5] as [number, number, number],
536
+ })),
537
+ )
538
+ }
539
+
540
+ const UP = new Vector3(0, 1, 0)
541
+ /** The first upward-facing surface under (x, z), looking down from `from`. */
542
+ function floorUnder(mesh: Mesh, x: number, z: number, from: number): number | null {
543
+ const raycaster = new Raycaster()
544
+ raycaster.set(new Vector3(x, from, z), new Vector3(0, -1, 0))
545
+ const hit = raycaster
546
+ .intersectObject(mesh, false)
547
+ .find(
548
+ (h) => !h.face || h.face.normal.clone().transformDirection(mesh.matrixWorld).dot(UP) > 0.2,
549
+ )
550
+ return hit ? hit.point.y : null
551
+ }
552
+
553
+ for (const sculpted of [false, true]) {
554
+ describe(sculpted ? 'on sculpted ground' : 'on flat ground', () => {
555
+ test('a walker goes down the stair from the ground floor to the basement', () => {
556
+ house(sculpted)
557
+ const world = buildFirstPersonColliderWorldFromRegistry()!
558
+ expect(world).not.toBeNull()
559
+
560
+ // Eye-level search from half a metre over the floor the walker stands on, a step at a time
561
+ // along the stair's run: the floor found is the next one he stands on.
562
+ let floor = floorUnder(world.mesh, 1, 3.5, 3)!
563
+ expect(floor).toBeCloseTo(FLOOR_G, 6)
564
+ const floors = [floor]
565
+ for (let x = 1.1; x < 6.5; x += 0.1) {
566
+ floor = floorUnder(world.mesh, x, 3.5, floor + 0.5) ?? Number.NaN
567
+ floors.push(floor)
568
+ }
569
+ // He never stands on the ground sheet (0 m) once past the stairwell's edge, and ends on
570
+ // the basement's floor; no step is deeper than a tread.
571
+ expect(Math.min(...floors)).toBeCloseTo(FLOOR_B, 6)
572
+ for (let i = 1; i < floors.length; i += 1)
573
+ expect(floors[i - 1]! - floors[i]!).toBeLessThanOrEqual(RISE + 1e-6)
574
+ world.dispose()
575
+ })
576
+
577
+ test('the ground is open over the part of the basement the house covers, and closed past it', () => {
578
+ house(sculpted)
579
+ const world = buildFirstPersonColliderWorldFromRegistry()!
580
+ // Under the ground floor's slab, looking down from just under it: the ground sheet is
581
+ // there outside the basement's plan and gone inside it, where the basement's floor is.
582
+ const under = (x: number, z: number) => floorUnder(world.mesh, x, z, FLOOR_G - 0.03)
583
+ expect(under(1.2, 5)).toBeCloseTo(0, 6)
584
+ expect(under(1.4, 5)).toBeCloseTo(FLOOR_B, 6)
585
+ expect(under(6.6, 5)).toBeCloseTo(FLOOR_B, 6)
586
+ expect(under(6.8, 5)).toBeCloseTo(0, 6)
587
+ expect(under(4, 1.3)).toBeCloseTo(0, 6)
588
+ expect(under(4, 1.5)).toBeCloseTo(FLOOR_B, 6)
589
+ // Outside the house: the garden is walked on as before.
590
+ expect(floorUnder(world.mesh, 12, 5, 3)).toBeCloseTo(0, 6)
591
+ expect(floorUnder(world.mesh, -3, -3, 3)).toBeCloseTo(0, 6)
592
+ world.dispose()
593
+ })
594
+ })
595
+ }
596
+ })
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ coveredBelowGradeFootprints,
4
5
  type DoorNode,
5
6
  getGarageVisibleOpeningRatio,
6
7
  getLevelElevations,
7
8
  isOperationDoorType,
9
+ nodeMechanism,
8
10
  nodeRegistry,
9
11
  sceneRegistry,
10
12
  terrainFieldOf,
@@ -15,6 +17,7 @@ import * as THREE from 'three'
15
17
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
16
18
  import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
17
19
  import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
20
+ import { cutGroundCollider } from './ground-holes'
18
21
  import { createTerrainColliderGeometry } from './terrain-collider'
19
22
 
20
23
  const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
@@ -160,19 +163,28 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
160
163
  // player at the datum inside an excavation and bury them inside a hill. Derived
161
164
  // from the field rather than the rendered terrain mesh for the same
162
165
  // mount-timing reason as the flat slab.
166
+ //
167
+ // Either way the ground is cut open over what the house covers of its basement
168
+ // (`coveredBelowGradeFootprints`, the rings the ground is drawn open over): a sheet
169
+ // across the stairwell would hold the player at grade on the way down, with the
170
+ // stair and the basement's floor below it. The house covers that part, so it is cut
171
+ // whatever the view does with the ground; past it the ground is walked on as before.
163
172
  function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
164
173
  if (site.visible === false) return null
165
174
 
166
175
  const siteObject = sceneRegistry.nodes.get(site.id)
167
176
  if (!siteObject?.visible) return null
168
177
 
178
+ const covered = coveredBelowGradeFootprints(nodes)
179
+
169
180
  const field = terrainFieldOf(site)
170
181
  if (field) {
171
182
  const terrainGeometry = createTerrainColliderGeometry(field)
172
183
  if (terrainGeometry) {
184
+ const cut = cutGroundCollider(terrainGeometry, covered)
173
185
  siteObject.updateWorldMatrix(true, false)
174
- terrainGeometry.applyMatrix4(siteObject.matrixWorld)
175
- return terrainGeometry
186
+ cut.applyMatrix4(siteObject.matrixWorld)
187
+ return cut
176
188
  }
177
189
  }
178
190
 
@@ -194,8 +206,9 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
194
206
  geometry.applyMatrix4(
195
207
  new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
196
208
  )
197
- geometry.applyMatrix4(siteObject.matrixWorld)
198
- return geometry
209
+ const cut = cutGroundCollider(geometry, covered)
210
+ cut.applyMatrix4(siteObject.matrixWorld)
211
+ return cut
199
212
  }
200
213
 
201
214
  function collectSiteGroundColliderGeometries(nodes: SceneNodes) {
@@ -281,6 +294,10 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
281
294
  const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
282
295
  if (!hasLeafContent) return null
283
296
 
297
+ // A door built from a script opens by its model's `open` clip, which the mechanism runs through
298
+ // its own control: the native `operationState` below never moves for it, so open is read there.
299
+ if (node.source && nodeMechanism(node)?.isOn(node, useInteractive.getState())) return null
300
+
284
301
  const leafW = node.width - 2 * node.frameThickness
285
302
  const leafH = node.height - node.frameThickness
286
303
  if (leafW <= 0 || leafH <= 0) return null
@@ -457,7 +474,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
457
474
  ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
458
475
  ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
459
476
  bvhGeometry.computeBoundsTree?.({
460
- maxLeafSize: 12,
477
+ targetLeafSize: 12,
461
478
  strategy: 0,
462
479
  } as never)
463
480
  bvhGeometry.computeBoundingBox()