@pascal-app/editor 1.0.7 → 1.1.1

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 (336) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +101 -45
  5. package/src/components/editor/duplicate-node.ts +2 -1
  6. package/src/components/editor/editor-layout-v2.tsx +1 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +20 -1983
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +5 -3
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +8 -4
  15. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  16. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  17. package/src/components/editor/floorplan-panel.tsx +199 -30
  18. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  19. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  20. package/src/components/editor/follow-pascal.test.tsx +1 -2
  21. package/src/components/editor/follow-pascal.tsx +1 -1
  22. package/src/components/editor/footprint-height-handle.tsx +2 -2
  23. package/src/components/editor/group-actions.ts +1 -2
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-move-3d.ts +1 -2
  26. package/src/components/editor/group-rotate-handle.tsx +1 -2
  27. package/src/components/editor/group-transform-shared.ts +3 -1
  28. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  29. package/src/components/editor/index.tsx +43 -13
  30. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  31. package/src/components/editor/measurement-pill.tsx +1 -1
  32. package/src/components/editor/node-arrow-handles.tsx +2 -2
  33. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  34. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  35. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  36. package/src/components/editor/quick-measurement-card.tsx +1 -1
  37. package/src/components/editor/room-controls.tsx +10 -6
  38. package/src/components/editor/room-handles-2d.tsx +3 -3
  39. package/src/components/editor/room-handles.tsx +4 -5
  40. package/src/components/editor/room-highlight.tsx +6 -105
  41. package/src/components/editor/room-labels.test.ts +8 -822
  42. package/src/components/editor/room-labels.tsx +41 -1670
  43. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  44. package/src/components/editor/selection-manager.tsx +5 -3
  45. package/src/components/editor/site-edge-labels.tsx +7 -3
  46. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  47. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  48. package/src/components/editor/views/plugins-view.tsx +1 -1
  49. package/src/components/editor/views/view-bar.tsx +9 -9
  50. package/src/components/editor/wall-measurement-label.tsx +31 -15
  51. package/src/components/editor/wall-move-side-handles.tsx +11 -7
  52. package/src/components/editor/wall-pull-grip.tsx +2 -2
  53. package/src/components/editor/wall-region-handles.tsx +2 -2
  54. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  55. package/src/components/editor-2d/floorplan-group-move.tsx +3 -4
  56. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +13 -13
  57. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +2 -3
  58. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  59. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +23 -6
  60. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  62. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  63. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  64. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +14 -3
  65. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  66. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +30 -10
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +39 -35
  69. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  70. package/src/components/systems/roof/roof-edit-system.tsx +33 -21
  71. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  72. package/src/components/tools/distribution/distribution-run-tool.tsx +151 -33
  73. package/src/components/tools/item/move-tool.tsx +1 -6
  74. package/src/components/tools/item/placement-history.test.tsx +1 -1
  75. package/src/components/tools/item/use-placement-coordinator.tsx +3 -4
  76. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +7 -3
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +39 -16
  79. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  80. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  81. package/src/components/tools/shared/placement-box.tsx +2 -6
  82. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  83. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  84. package/src/components/tools/shared/segment-angle.ts +0 -4
  85. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  86. package/src/components/tools/tool-manager.tsx +1 -24
  87. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  88. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  89. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  90. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  91. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  92. package/src/components/ui/action-menu/index.tsx +3 -3
  93. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  94. package/src/components/ui/action-menu/placement.ts +0 -18
  95. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  96. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  97. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  98. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  99. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  100. package/src/components/ui/controls/select-control.tsx +1 -1
  101. package/src/components/ui/controls/slider-control.tsx +18 -6
  102. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  103. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  104. package/src/components/ui/floating-level-selector.tsx +3 -3
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  106. package/src/components/ui/helpers/helper-manager.test.tsx +54 -1
  107. package/src/components/ui/helpers/helper-manager.tsx +55 -1
  108. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  109. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  110. package/src/components/ui/panels/floor-foundation-panel.tsx +3 -3
  111. package/src/components/ui/panels/floor-opening-panel.tsx +5 -5
  112. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  113. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  114. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  115. package/src/components/ui/panels/node-display.ts +1 -1
  116. package/src/components/ui/panels/panel-manager.tsx +5 -3
  117. package/src/components/ui/panels/panel-wrapper.tsx +42 -19
  118. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  119. package/src/components/ui/panels/room-construction-rows.tsx +7 -7
  120. package/src/components/ui/panels/room-name-options.ts +1 -5
  121. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  122. package/src/components/ui/panels/room-panel.tsx +7 -9
  123. package/src/components/ui/primitives/color-dot.tsx +2 -2
  124. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  125. package/src/components/ui/primitives/sidebar.tsx +3 -3
  126. package/src/components/ui/right-stack.test.ts +13 -6
  127. package/src/components/ui/right-stack.tsx +22 -11
  128. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  129. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  130. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  131. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  132. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  133. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  134. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +2 -6
  135. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  136. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  137. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/site-panel/index.tsx +3 -10
  139. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  140. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  141. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  142. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  144. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  145. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  146. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  147. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  148. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  149. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +2 -2
  150. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  151. package/src/components/viewer-overlay.tsx +1 -2
  152. package/src/hooks/use-auto-frame.ts +1 -2
  153. package/src/hooks/use-keyboard.ts +1 -2
  154. package/src/hooks/use-selected-room.ts +5 -7
  155. package/src/i18n/floor-footprint-name.ts +1 -1
  156. package/src/i18n/locales/en/messages.po +1036 -306
  157. package/src/i18n/locales/en/messages.ts +1 -1
  158. package/src/i18n/locales/en-XA/messages.ts +1 -1
  159. package/src/i18n/locales/es/messages.po +2267 -439
  160. package/src/i18n/locales/fr/messages.po +2267 -439
  161. package/src/i18n/locales/ja/messages.po +2267 -439
  162. package/src/i18n/locales/zh-CN/messages.po +2267 -439
  163. package/src/i18n/locales.test.ts +1 -1
  164. package/src/index.tsx +183 -178
  165. package/src/lib/below-grade.test.tsx +4 -4
  166. package/src/lib/camera-facing.test.ts +1 -1
  167. package/src/lib/camera-facing.ts +2 -3
  168. package/src/lib/contextual-help.ts +6 -0
  169. package/src/lib/editor-level-display.ts +1 -1
  170. package/src/lib/elevation-guides.ts +21 -6
  171. package/src/lib/elevator-support.ts +1 -72
  172. package/src/lib/floor-footprints.test.ts +3 -3
  173. package/src/lib/floor-footprints.ts +1 -9
  174. package/src/lib/floor-foundation-support.test.ts +8 -3
  175. package/src/lib/floor-opening-draft.ts +2 -2
  176. package/src/lib/floor-region-session.ts +1 -1
  177. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  178. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  179. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  180. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  181. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  182. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  183. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  184. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  185. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  186. package/src/lib/floorplan/geometry.ts +2 -112
  187. package/src/lib/floorplan/index.ts +0 -9
  188. package/src/lib/floorplan/items.ts +2 -1
  189. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  190. package/src/lib/floorplan/schedules.ts +6 -3
  191. package/src/lib/floorplan/sheet-export.ts +7 -3
  192. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  193. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  194. package/src/lib/follow-pascal-presentation.ts +1 -1
  195. package/src/lib/gesture-lifecycle.ts +3 -1
  196. package/src/lib/glb-export.ts +1 -1
  197. package/src/lib/interaction/scope.ts +1 -0
  198. package/src/lib/layer-thickness.ts +1 -1
  199. package/src/lib/level-selection.test.ts +18 -2
  200. package/src/lib/level-selection.ts +31 -8
  201. package/src/lib/linear-display.ts +1 -1
  202. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  203. package/src/lib/material-paint.ts +1 -1
  204. package/src/lib/measurement-label.test.ts +1 -1
  205. package/src/lib/measurements.test.ts +7 -5
  206. package/src/lib/mezzanine-selection.test.ts +1 -1
  207. package/src/lib/node-action-movement.test.tsx +115 -0
  208. package/src/lib/node-action-movement.ts +2 -0
  209. package/src/lib/paint-region-mode.ts +1 -1
  210. package/src/lib/placement-metadata.ts +4 -0
  211. package/src/lib/point-ask/describe-target.ts +6 -8
  212. package/src/lib/point-ask/region.ts +1 -1
  213. package/src/lib/point-ask/session.ts +1 -2
  214. package/src/lib/print-shell-compiler.ts +12 -1
  215. package/src/lib/print-wall-solids.test.ts +11 -0
  216. package/src/lib/print-wall-solids.ts +9 -0
  217. package/src/lib/roof-duplication.test.ts +100 -0
  218. package/src/lib/roof-duplication.ts +30 -5
  219. package/src/lib/room-built-on.test.ts +2 -5
  220. package/src/lib/room-built-on.ts +1 -15
  221. package/src/lib/room-construction-commands.ts +3 -2
  222. package/src/lib/room-controls-phase6.test.ts +1 -1
  223. package/src/lib/room-handle-drag.ts +8 -37
  224. package/src/lib/room-name-catalog.test.ts +2 -1
  225. package/src/lib/room-name-messages.test.ts +2 -1
  226. package/src/lib/room-name-messages.ts +1 -118
  227. package/src/lib/room-name-storage.test.ts +2 -1
  228. package/src/lib/room-push-dimensions.test.ts +1 -1
  229. package/src/lib/room-selection-commands.ts +2 -2
  230. package/src/lib/room-selection.test.ts +6 -6
  231. package/src/lib/room-transform-session.ts +1 -2
  232. package/src/lib/room-zone-routing.ts +2 -2
  233. package/src/lib/scene-clipboard.ts +2 -1
  234. package/src/lib/selection-routing.test.ts +0 -28
  235. package/src/lib/selection-routing.ts +1 -40
  236. package/src/lib/sfx-bus.ts +8 -32
  237. package/src/lib/sfx-player.test.ts +72 -1
  238. package/src/lib/sfx-player.ts +49 -337
  239. package/src/lib/snapping-mode.test.ts +23 -1
  240. package/src/lib/viewer-selection.test.ts +38 -10
  241. package/src/lib/wall-pull.test.ts +1 -1
  242. package/src/lib/wall-pull.ts +1 -27
  243. package/src/lib/wall-region-list.ts +3 -1
  244. package/src/lib/window-interaction.test.ts +4 -2
  245. package/src/store/use-editor.tsx +22 -26
  246. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  247. package/src/store/use-placement-typing.test.ts +165 -0
  248. package/src/store/use-placement-typing.ts +186 -0
  249. package/src/store/use-wall-region-handles-selection.ts +1 -2
  250. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  251. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  252. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  253. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  254. package/src/components/editor/first-person/ground-holes.ts +0 -130
  255. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  256. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  257. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  258. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  259. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  260. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  261. package/src/components/editor/localized-html.tsx +0 -22
  262. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  263. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  264. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  265. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  266. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  267. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  268. package/src/components/editor-2d/svg-paths.ts +0 -124
  269. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  270. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  271. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  272. package/src/components/ui/action-menu/action-button.tsx +0 -89
  273. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  274. package/src/components/ui/primitives/button.tsx +0 -69
  275. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  276. package/src/components/ui/primitives/popover.tsx +0 -42
  277. package/src/components/ui/primitives/switch.tsx +0 -29
  278. package/src/components/ui/primitives/tooltip.tsx +0 -57
  279. package/src/components/ui/scene-loader.tsx +0 -73
  280. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  281. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  282. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  283. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  284. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  285. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  286. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  287. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  288. package/src/components/viewer/render-settings-menu.tsx +0 -294
  289. package/src/components/viewer/sky-control.tsx +0 -710
  290. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  291. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  292. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  293. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  294. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  295. package/src/components/viewer/viewer-rooms.tsx +0 -248
  296. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  297. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  298. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  299. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  300. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  301. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  302. package/src/components/viewer/viewer-stage.tsx +0 -221
  303. package/src/components/walkthrough-hud.tsx +0 -193
  304. package/src/hooks/use-mobile.ts +0 -19
  305. package/src/i18n/level-name.ts +0 -39
  306. package/src/i18n/scene-theme-name.ts +0 -16
  307. package/src/lib/below-grade.ts +0 -133
  308. package/src/lib/camera-pose.test.ts +0 -237
  309. package/src/lib/camera-pose.ts +0 -172
  310. package/src/lib/door-interaction.ts +0 -102
  311. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  312. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  313. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  314. package/src/lib/floorplan/marks.test.ts +0 -213
  315. package/src/lib/floorplan/marks.ts +0 -238
  316. package/src/lib/measurement-label.ts +0 -68
  317. package/src/lib/measurements.ts +0 -225
  318. package/src/lib/node-display-label.ts +0 -37
  319. package/src/lib/room-assembly-overlay.test.ts +0 -275
  320. package/src/lib/room-assembly-overlay.ts +0 -265
  321. package/src/lib/room-selection.ts +0 -417
  322. package/src/lib/scene-bounds.test.ts +0 -183
  323. package/src/lib/scene-bounds.ts +0 -169
  324. package/src/lib/typing-target.ts +0 -9
  325. package/src/lib/viewer-selection.ts +0 -595
  326. package/src/lib/window-interaction.ts +0 -142
  327. package/src/lib/zone-content.ts +0 -133
  328. package/src/store/camera-pose-store.test.ts +0 -29
  329. package/src/store/camera-pose-store.ts +0 -27
  330. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  331. package/src/store/navigation-sync-pose-store.ts +0 -27
  332. package/src/store/use-audio.test.ts +0 -146
  333. package/src/store/use-audio.tsx +0 -169
  334. package/src/store/use-first-person-hud.ts +0 -40
  335. package/src/store/use-follow-pascal.test.ts +0 -62
  336. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,549 +0,0 @@
1
- import {
2
- type AnyNode,
3
- type AnyNodeId,
4
- coveredBelowGradeFootprints,
5
- type DoorNode,
6
- getGarageVisibleOpeningRatio,
7
- getLevelElevations,
8
- isOperationDoorType,
9
- nodeMechanism,
10
- nodeRegistry,
11
- sceneRegistry,
12
- terrainFieldOf,
13
- useInteractive,
14
- useScene,
15
- } from '@pascal-app/core'
16
- import * as THREE from 'three'
17
- import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
18
- import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
19
- import { computeSceneBoundsXZ } from '../../../lib/scene-bounds'
20
- import { cutGroundCollider } from './ground-holes'
21
- import { createTerrainColliderGeometry } from './terrain-collider'
22
-
23
- const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh'])
24
- const COLLIDER_NODE_CATEGORIES = new Set(['structure', 'furnish'])
25
- const DEDICATED_COLLIDER_NODE_TYPES = new Set<AnyNode['type']>(['elevator'])
26
- const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial()
27
- const DOWN = new THREE.Vector3(0, -1, 0)
28
- const UP = new THREE.Vector3(0, 1, 0)
29
- const SPAWN_EYE_HEIGHT = 1.65
30
- const RAYCAST_CLEARANCE = 25
31
- const DOOR_LEAF_COLLIDER_DEPTH = 0.06
32
- const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
33
- const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
34
- const LEVEL_FALLBACK_FLOOR_PADDING = 2
35
- const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
36
- const SITE_GROUND_COLLIDER_MIN_SIZE = 2000
37
-
38
- export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
39
-
40
- export type FirstPersonColliderWorld = {
41
- mesh: THREE.Mesh
42
- bounds: THREE.Box3 | null
43
- dispose: () => void
44
- }
45
-
46
- export type FirstPersonSpawn = {
47
- position: [number, number, number]
48
- yaw: number
49
- }
50
-
51
- type LevelNode = Extract<AnyNode, { type: 'level' }>
52
- type SiteNode = Extract<AnyNode, { type: 'site' }>
53
- type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
54
-
55
- function isMesh(object: THREE.Object3D): object is THREE.Mesh {
56
- return 'isMesh' in object && (object as THREE.Mesh).isMesh
57
- }
58
-
59
- // Renderer-effective visibility: an invisible ancestor hides the whole
60
- // subtree at render time even when the object's own flag is true. The
61
- // collider world must match what's rendered — the roof keeps stale,
62
- // UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit
63
- // exit hides the wrapper without stripping geometry), and cloning those
64
- // meshes would block the walkthrough player at openings the visible
65
- // merged shell has cut through.
66
- function isEffectivelyVisible(object: THREE.Object3D) {
67
- let current: THREE.Object3D | null = object
68
- while (current) {
69
- if (!current.visible) return false
70
- current = current.parent
71
- }
72
- return true
73
- }
74
-
75
- function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
76
- return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
77
- }
78
-
79
- function isGenericColliderNode(node: AnyNode) {
80
- if (node.visible === false) return false
81
- if (DEDICATED_COLLIDER_NODE_TYPES.has(node.type)) return false
82
- const def = nodeRegistry.get(node.type)
83
- // Ceilings are a transparent mount surface for fixtures (lights, fans), not a
84
- // walkable or blocking structure — the walkthrough player must pass through
85
- // them rather than be held up as if standing on a floor slab.
86
- if (def?.surfaceRole === 'ceiling') return false
87
- return COLLIDER_NODE_CATEGORIES.has(def?.category ?? '')
88
- }
89
-
90
- function createBoxColliderGeometry(width: number, height: number, depth: number) {
91
- const sourceGeometry = new THREE.BoxGeometry(width, height, depth).toNonIndexed()
92
- const geometry = new THREE.BufferGeometry()
93
- geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
94
- geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
95
- sourceGeometry.dispose()
96
- return geometry
97
- }
98
-
99
- function getVisibleLevelChildren(level: LevelNode, nodes: SceneNodes) {
100
- return level.children
101
- .map((childId) => nodes[childId as AnyNodeId])
102
- .filter((child): child is AnyNode => Boolean(child && child.visible !== false))
103
- }
104
-
105
- function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) {
106
- if (level.visible === false) return null
107
-
108
- const children = getVisibleLevelChildren(level, nodes)
109
- if (children.some((child) => child.type === 'slab')) return null
110
-
111
- const levelObject = sceneRegistry.nodes.get(level.id)
112
- if (!levelObject?.visible) return null
113
-
114
- const bounds = computeSceneBoundsXZ(children)
115
- const [centerX, centerZ] = bounds?.center ?? [0, 0]
116
- const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
117
- const width = Math.max(
118
- boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
119
- LEVEL_FALLBACK_FLOOR_MIN_SIZE,
120
- )
121
- const depth = Math.max(
122
- boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
123
- LEVEL_FALLBACK_FLOOR_MIN_SIZE,
124
- )
125
-
126
- const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
127
-
128
- levelObject.updateWorldMatrix(true, false)
129
- geometry.applyMatrix4(
130
- new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
131
- )
132
- geometry.applyMatrix4(levelObject.matrixWorld)
133
- return geometry
134
- }
135
-
136
- function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
137
- const geometries: THREE.BufferGeometry[] = []
138
- const levelElevations = getLevelElevations(nodes)
139
-
140
- for (const levelId of sceneRegistry.byType.level!) {
141
- const node = nodes[levelId as AnyNodeId]
142
- if (node?.type !== 'level') continue
143
- if (levelElevations.get(node.id)?.baseY !== 0) continue
144
-
145
- const geometry = createLevelFallbackFloorGeometry(node, nodes)
146
- if (geometry) geometries.push(geometry)
147
- }
148
-
149
- return geometries
150
- }
151
-
152
- // The visible ground is the site node's ground mesh, but `site` is a `site`
153
- // category node and therefore excluded from the generic collider sweep. Without
154
- // a dedicated collider, a spawn on the bare ground (no slab, or not parented to
155
- // a level that triggers the per-level fallback) has no floor to stand on and the
156
- // walkthrough player falls through. Derive a thin ground slab from node data (not
157
- // the rendered mesh) so it exists regardless of geometry-mount timing. The slab
158
- // is effectively unbounded (not sized to the site polygon): the ground plane must
159
- // keep holding the player up even after they step past the site boundary,
160
- // otherwise they fall below the ground plane into the void.
161
- //
162
- // A sculpted site takes the terrain path instead: the flat slab would hold the
163
- // player at the datum inside an excavation and bury them inside a hill. Derived
164
- // from the field rather than the rendered terrain mesh for the same
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.
172
- function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
173
- if (site.visible === false) return null
174
-
175
- const siteObject = sceneRegistry.nodes.get(site.id)
176
- if (!siteObject?.visible) return null
177
-
178
- const covered = coveredBelowGradeFootprints(nodes)
179
-
180
- const field = terrainFieldOf(site)
181
- if (field) {
182
- const terrainGeometry = createTerrainColliderGeometry(field)
183
- if (terrainGeometry) {
184
- const cut = cutGroundCollider(terrainGeometry, covered)
185
- siteObject.updateWorldMatrix(true, false)
186
- cut.applyMatrix4(siteObject.matrixWorld)
187
- return cut
188
- }
189
- }
190
-
191
- const bounds = computeSceneBoundsXZ(nodes)
192
- const [centerX, centerZ] = bounds?.center ?? [0, 0]
193
- const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
194
- const width = Math.max(
195
- boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
196
- SITE_GROUND_COLLIDER_MIN_SIZE,
197
- )
198
- const depth = Math.max(
199
- boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
200
- SITE_GROUND_COLLIDER_MIN_SIZE,
201
- )
202
-
203
- const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
204
-
205
- siteObject.updateWorldMatrix(true, false)
206
- geometry.applyMatrix4(
207
- new THREE.Matrix4().makeTranslation(centerX, -LEVEL_FALLBACK_FLOOR_THICKNESS / 2, centerZ),
208
- )
209
- const cut = cutGroundCollider(geometry, covered)
210
- cut.applyMatrix4(siteObject.matrixWorld)
211
- return cut
212
- }
213
-
214
- function collectSiteGroundColliderGeometries(nodes: SceneNodes) {
215
- const geometries: THREE.BufferGeometry[] = []
216
-
217
- for (const siteId of sceneRegistry.byType.site ?? []) {
218
- const node = nodes[siteId as AnyNodeId]
219
- if (node?.type !== 'site') continue
220
-
221
- const geometry = createSiteGroundColliderGeometry(node, nodes)
222
- if (geometry) geometries.push(geometry)
223
- }
224
-
225
- return geometries
226
- }
227
-
228
- // Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
229
- // plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
230
- // merged geometry to share the same typed-array constructor for matching attributes, so
231
- // imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced
232
- // to Float32 to match wall/slab geometry.
233
- function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) {
234
- const itemSize = source.itemSize
235
- const array = new Float32Array(source.count * itemSize)
236
- for (let i = 0; i < source.count; i++) {
237
- const offset = i * itemSize
238
- array[offset] = source.getX(i)
239
- if (itemSize > 1) array[offset + 1] = source.getY(i)
240
- if (itemSize > 2) array[offset + 2] = source.getZ(i)
241
- if (itemSize > 3) array[offset + 3] = source.getW(i)
242
- }
243
- return new THREE.BufferAttribute(array, itemSize)
244
- }
245
-
246
- function cloneWorldGeometry(mesh: THREE.Mesh) {
247
- const sourceGeometry = mesh.geometry
248
- const position = sourceGeometry.getAttribute('position')
249
- if (!position || position.count < 3) return null
250
-
251
- const workingGeometry = sourceGeometry.index
252
- ? sourceGeometry.toNonIndexed()
253
- : sourceGeometry.clone()
254
- const cleanGeometry = new THREE.BufferGeometry()
255
- cleanGeometry.setAttribute(
256
- 'position',
257
- toFloat32Attribute(workingGeometry.getAttribute('position')),
258
- )
259
-
260
- const normal = workingGeometry.getAttribute('normal')
261
- if (normal) {
262
- cleanGeometry.setAttribute('normal', toFloat32Attribute(normal))
263
- } else {
264
- cleanGeometry.computeVertexNormals()
265
- }
266
-
267
- cleanGeometry.applyMatrix4(mesh.matrixWorld)
268
- workingGeometry.dispose()
269
-
270
- const worldPosition = cleanGeometry.getAttribute('position')
271
- if (!worldPosition || worldPosition.count < 3) {
272
- cleanGeometry.dispose()
273
- return null
274
- }
275
-
276
- return cleanGeometry
277
- }
278
-
279
- function shouldSkipColliderNode(node: AnyNode) {
280
- if (node.type === 'window') {
281
- return node.openingKind === 'opening'
282
- }
283
-
284
- if (node.type !== 'door') return false
285
-
286
- if (!node.segments.length) return true
287
-
288
- if (node.openingKind === 'opening') return true
289
-
290
- return node.segments.every((segment: { type: string }) => segment.type === 'empty')
291
- }
292
-
293
- function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
294
- const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
295
- if (!hasLeafContent) return null
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
-
301
- const leafW = node.width - 2 * node.frameThickness
302
- const leafH = node.height - node.frameThickness
303
- if (leafW <= 0 || leafH <= 0) return null
304
-
305
- const leafCenterY = -node.frameThickness / 2
306
- const runtimeDoorState = useInteractive.getState().doors[node.id]
307
- const operationState = runtimeDoorState?.operationState ?? node.operationState
308
- const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle
309
-
310
- root.updateWorldMatrix(true, false)
311
-
312
- if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
313
- const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState)
314
- const visibleHeight = leafH * (1 - openAmount)
315
- if (visibleHeight <= 0.12) return null
316
-
317
- const geometry = createBoxColliderGeometry(leafW, visibleHeight, DOOR_LEAF_COLLIDER_DEPTH)
318
- const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
319
- geometry.applyMatrix4(
320
- root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
321
- )
322
- return geometry
323
- }
324
-
325
- if (
326
- isOperationDoorType(node.doorType) &&
327
- (operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD
328
- ) {
329
- return null
330
- }
331
-
332
- const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
333
- const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
334
- const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
335
- const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
336
- const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
337
-
338
- const geometry = createBoxColliderGeometry(leafW, leafH, DOOR_LEAF_COLLIDER_DEPTH)
339
- const matrix = root.matrixWorld
340
- .clone()
341
- .multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
342
- .multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
343
- .multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
344
-
345
- geometry.applyMatrix4(matrix)
346
- return geometry
347
- }
348
-
349
- function buildRegisteredColliderNodeIds(nodes: SceneNodes) {
350
- const nodeIds = new Set<string>()
351
-
352
- for (const nodeId of sceneRegistry.nodes.keys()) {
353
- const node = nodes[nodeId as AnyNodeId]
354
- if (!node || !isGenericColliderNode(node)) continue
355
- if (shouldSkipColliderNode(node)) continue
356
- nodeIds.add(nodeId)
357
- }
358
-
359
- return nodeIds
360
- }
361
-
362
- function collectColliderGeometriesFromNode(
363
- root: THREE.Object3D,
364
- rootNodeId: string,
365
- visitedMeshes: WeakSet<THREE.Object3D>,
366
- registeredObjectIds: Map<THREE.Object3D, string>,
367
- registeredColliderNodeIds: Set<string>,
368
- ): THREE.BufferGeometry[] {
369
- const geometries: THREE.BufferGeometry[] = []
370
-
371
- const visit = (object: THREE.Object3D) => {
372
- if (visitedMeshes.has(object)) return
373
- visitedMeshes.add(object)
374
-
375
- // Prune hidden subtrees — children of an invisible group never render,
376
- // so they must not collide either (see isEffectivelyVisible). An idle joint
377
- // tree draws a merged rest pose that includes its moving parts; its hidden
378
- // split group holds the same static parts apart from them.
379
- if (object.userData.pascalProceduralRest) return
380
- if (!object.visible && !object.userData.pascalProceduralSplit) return
381
- if (object.userData.proceduralMotion) return
382
-
383
- if (
384
- isMesh(object) &&
385
- isColliderMaterialVisible(object.material) &&
386
- !SKIPPED_MESH_NAMES.has(object.name)
387
- ) {
388
- const geometry = cloneWorldGeometry(object)
389
- if (geometry) {
390
- geometries.push(geometry)
391
- }
392
- }
393
-
394
- for (const child of object.children) {
395
- const childNodeId = registeredObjectIds.get(child)
396
- if (childNodeId && childNodeId !== rootNodeId && registeredColliderNodeIds.has(childNodeId)) {
397
- continue
398
- }
399
-
400
- visit(child)
401
- }
402
- }
403
-
404
- visit(root)
405
-
406
- return geometries
407
- }
408
-
409
- export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null {
410
- const nodes = useScene.getState().nodes
411
- const geometries: THREE.BufferGeometry[] = []
412
- const visitedMeshes = new WeakSet<THREE.Object3D>()
413
- const registeredColliderNodeIds = buildRegisteredColliderNodeIds(nodes)
414
- const registeredObjectIds = new Map<THREE.Object3D, string>()
415
-
416
- for (const [nodeId, object] of sceneRegistry.nodes) {
417
- registeredObjectIds.set(object, nodeId)
418
- }
419
-
420
- for (const nodeId of registeredColliderNodeIds) {
421
- const node = nodes[nodeId as AnyNodeId]
422
- if (!node) continue
423
-
424
- const root = sceneRegistry.nodes.get(nodeId)
425
- if (!root) continue
426
-
427
- // Registered objects can sit inside a hidden wrapper (roof segments
428
- // under `segments-wrapper`) — the per-node traversal starts AT the
429
- // object, so the ancestor chain must be checked here.
430
- if (!isEffectivelyVisible(root)) continue
431
-
432
- if (node.type === 'door') {
433
- const doorGeometry = createDoorLeafColliderGeometry(root, node)
434
- if (doorGeometry) {
435
- geometries.push(doorGeometry)
436
- }
437
- continue
438
- }
439
-
440
- root.updateMatrixWorld(true)
441
- geometries.push(
442
- ...collectColliderGeometriesFromNode(
443
- root,
444
- nodeId,
445
- visitedMeshes,
446
- registeredObjectIds,
447
- registeredColliderNodeIds,
448
- ),
449
- )
450
- }
451
-
452
- geometries.push(...collectLevelFallbackFloorGeometries(nodes))
453
- geometries.push(...collectSiteGroundColliderGeometries(nodes))
454
-
455
- if (geometries.length === 0) {
456
- return null
457
- }
458
-
459
- const mergedGeometry = mergeGeometries(geometries, false)
460
- for (const geometry of geometries) {
461
- geometry.dispose()
462
- }
463
-
464
- if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
465
- mergedGeometry?.dispose()
466
- return null
467
- }
468
-
469
- const bvhGeometry = mergedGeometry as THREE.BufferGeometry & {
470
- computeBoundsTree?: typeof computeBoundsTree
471
- disposeBoundsTree?: typeof disposeBoundsTree
472
- }
473
-
474
- ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
475
- ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
476
- bvhGeometry.computeBoundsTree?.({
477
- targetLeafSize: 12,
478
- strategy: 0,
479
- } as never)
480
- bvhGeometry.computeBoundingBox()
481
-
482
- const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL)
483
- mesh.raycast = acceleratedRaycast
484
- mesh.visible = true
485
- mesh.userData = {
486
- type: 'STATIC',
487
- friction: 0.8,
488
- restitution: 0.05,
489
- excludeFloatHit: false,
490
- excludeCollisionCheck: false,
491
- }
492
- mesh.updateMatrixWorld(true)
493
-
494
- return {
495
- mesh,
496
- bounds: bvhGeometry.boundingBox?.clone() ?? null,
497
- dispose: () => {
498
- bvhGeometry.disposeBoundsTree?.()
499
- bvhGeometry.dispose()
500
- },
501
- }
502
- }
503
-
504
- export function deriveFirstPersonSpawn(
505
- camera: THREE.Camera,
506
- world: FirstPersonColliderWorld,
507
- ): FirstPersonSpawn {
508
- const direction = new THREE.Vector3()
509
- camera.getWorldDirection(direction)
510
- direction.y = 0
511
- if (direction.lengthSq() < 1e-6) {
512
- direction.set(0, 0, -1)
513
- } else {
514
- direction.normalize()
515
- }
516
-
517
- const yaw = Math.atan2(-direction.x, -direction.z)
518
- const raycaster = new THREE.Raycaster()
519
- const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]]
520
-
521
- const boundsCenter = world.bounds?.getCenter(new THREE.Vector3())
522
- if (boundsCenter) {
523
- candidates.push([boundsCenter.x, boundsCenter.z])
524
- }
525
-
526
- for (const [x, z] of candidates) {
527
- const topY =
528
- Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
529
- raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
530
- const intersections = raycaster.intersectObject(world.mesh, false)
531
- const hit = intersections.find((intersection) => {
532
- if (!intersection.face) return true
533
- const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld)
534
- return normal.dot(UP) > 0.2
535
- })
536
-
537
- if (hit) {
538
- return {
539
- position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z],
540
- yaw,
541
- }
542
- }
543
- }
544
-
545
- return {
546
- position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
547
- yaw,
548
- }
549
- }
@@ -1,128 +0,0 @@
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
- })