@pascal-app/editor 1.1.0 → 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 (260) 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 +11 -11
  5. package/src/components/editor/editor-layout-v2.tsx +1 -1
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +20 -1981
  8. package/src/components/editor/floating-action-menu.tsx +1 -1
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floor-region-controls.tsx +6 -2
  11. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  12. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  13. package/src/components/editor/floorplan-panel.tsx +26 -22
  14. package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
  15. package/src/components/editor/follow-pascal.test.tsx +1 -2
  16. package/src/components/editor/follow-pascal.tsx +1 -1
  17. package/src/components/editor/footprint-height-handle.tsx +1 -1
  18. package/src/components/editor/group-actions.ts +1 -2
  19. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  20. package/src/components/editor/group-move-3d.ts +1 -2
  21. package/src/components/editor/group-rotate-handle.tsx +1 -2
  22. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  23. package/src/components/editor/index.tsx +24 -14
  24. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  25. package/src/components/editor/node-arrow-handles.tsx +1 -1
  26. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  27. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  28. package/src/components/editor/room-controls.tsx +7 -4
  29. package/src/components/editor/room-handles-2d.tsx +2 -3
  30. package/src/components/editor/room-handles.tsx +1 -2
  31. package/src/components/editor/room-highlight.tsx +6 -105
  32. package/src/components/editor/room-labels.test.ts +8 -822
  33. package/src/components/editor/room-labels.tsx +40 -1671
  34. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  35. package/src/components/editor/selection-manager.tsx +5 -3
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  38. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  39. package/src/components/editor/views/plugins-view.tsx +1 -1
  40. package/src/components/editor/views/view-bar.tsx +9 -9
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +1 -1
  43. package/src/components/editor/wall-pull-grip.tsx +2 -2
  44. package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
  45. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
  46. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
  47. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
  48. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  49. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  50. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  51. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
  52. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
  53. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
  54. package/src/components/systems/roof/roof-edit-system.tsx +4 -3
  55. package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
  56. package/src/components/tools/item/placement-history.test.tsx +1 -1
  57. package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
  58. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
  59. package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
  60. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  61. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  62. package/src/components/tools/shared/placement-box.tsx +1 -1
  63. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  64. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  65. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  66. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  67. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  68. package/src/components/ui/action-menu/index.tsx +3 -3
  69. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  70. package/src/components/ui/action-menu/placement.ts +0 -18
  71. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  72. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  73. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  74. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  75. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  76. package/src/components/ui/controls/select-control.tsx +1 -1
  77. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  78. package/src/components/ui/floating-level-selector.tsx +3 -3
  79. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  80. package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
  81. package/src/components/ui/helpers/helper-manager.tsx +1 -1
  82. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  83. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  84. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  85. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  86. package/src/components/ui/panels/node-display.ts +1 -1
  87. package/src/components/ui/panels/panel-manager.tsx +1 -1
  88. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  89. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  90. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  91. package/src/components/ui/panels/room-name-options.ts +1 -5
  92. package/src/components/ui/panels/room-panel.tsx +6 -8
  93. package/src/components/ui/primitives/color-dot.tsx +2 -2
  94. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  95. package/src/components/ui/primitives/sidebar.tsx +3 -3
  96. package/src/components/ui/right-stack.tsx +1 -1
  97. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  98. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  99. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  100. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  104. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  107. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  109. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  110. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  112. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  113. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  114. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  115. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  116. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  117. package/src/components/viewer-overlay.tsx +1 -2
  118. package/src/hooks/use-auto-frame.ts +1 -2
  119. package/src/hooks/use-keyboard.ts +1 -2
  120. package/src/hooks/use-selected-room.ts +5 -7
  121. package/src/i18n/floor-footprint-name.ts +1 -1
  122. package/src/i18n/locales/en/messages.po +180 -182
  123. package/src/i18n/locales/es/messages.po +180 -182
  124. package/src/i18n/locales/fr/messages.po +180 -182
  125. package/src/i18n/locales/ja/messages.po +180 -182
  126. package/src/i18n/locales/zh-CN/messages.po +180 -182
  127. package/src/i18n/locales.test.ts +1 -1
  128. package/src/index.tsx +109 -131
  129. package/src/lib/below-grade.test.tsx +4 -4
  130. package/src/lib/camera-facing.test.ts +1 -1
  131. package/src/lib/camera-facing.ts +2 -3
  132. package/src/lib/editor-level-display.ts +1 -1
  133. package/src/lib/floor-footprints.test.ts +2 -2
  134. package/src/lib/floor-opening-draft.ts +1 -1
  135. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  136. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  137. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  138. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  139. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  140. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  141. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  143. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  144. package/src/lib/floorplan/geometry.ts +2 -112
  145. package/src/lib/floorplan/index.ts +0 -9
  146. package/src/lib/floorplan/items.ts +2 -1
  147. package/src/lib/floorplan/schedules.ts +6 -3
  148. package/src/lib/floorplan/sheet-export.ts +7 -3
  149. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  150. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  151. package/src/lib/follow-pascal-presentation.ts +1 -1
  152. package/src/lib/glb-export.ts +1 -1
  153. package/src/lib/material-paint.ts +1 -1
  154. package/src/lib/measurements.test.ts +2 -1
  155. package/src/lib/mezzanine-selection.test.ts +1 -1
  156. package/src/lib/paint-region-mode.ts +1 -1
  157. package/src/lib/point-ask/session.ts +1 -2
  158. package/src/lib/roof-duplication.test.ts +100 -0
  159. package/src/lib/roof-duplication.ts +30 -5
  160. package/src/lib/room-handle-drag.ts +2 -12
  161. package/src/lib/room-name-catalog.test.ts +2 -1
  162. package/src/lib/room-name-messages.test.ts +2 -1
  163. package/src/lib/room-name-messages.ts +1 -118
  164. package/src/lib/room-name-storage.test.ts +2 -1
  165. package/src/lib/room-selection-commands.ts +2 -2
  166. package/src/lib/room-selection.test.ts +6 -6
  167. package/src/lib/room-transform-session.ts +1 -2
  168. package/src/lib/room-zone-routing.ts +2 -2
  169. package/src/lib/selection-routing.test.ts +0 -28
  170. package/src/lib/selection-routing.ts +1 -40
  171. package/src/lib/sfx-bus.ts +8 -32
  172. package/src/lib/sfx-player.test.ts +72 -1
  173. package/src/lib/sfx-player.ts +49 -337
  174. package/src/lib/viewer-selection.test.ts +38 -10
  175. package/src/lib/wall-pull.test.ts +1 -1
  176. package/src/lib/wall-pull.ts +1 -27
  177. package/src/store/use-editor.tsx +17 -26
  178. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  179. package/src/store/use-wall-region-handles-selection.ts +1 -2
  180. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  181. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  182. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  183. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  184. package/src/components/editor/first-person/ground-holes.ts +0 -130
  185. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  186. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  187. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  188. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  189. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  190. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  191. package/src/components/editor/localized-html.tsx +0 -22
  192. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  193. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  194. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  195. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  196. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  197. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  198. package/src/components/editor-2d/svg-paths.ts +0 -124
  199. package/src/components/ui/action-menu/action-button.tsx +0 -89
  200. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  201. package/src/components/ui/primitives/button.tsx +0 -69
  202. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  203. package/src/components/ui/primitives/popover.tsx +0 -42
  204. package/src/components/ui/primitives/switch.tsx +0 -29
  205. package/src/components/ui/primitives/tooltip.tsx +0 -57
  206. package/src/components/ui/scene-loader.tsx +0 -73
  207. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  208. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  209. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  210. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  211. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  212. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  213. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  214. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  215. package/src/components/viewer/render-settings-menu.tsx +0 -294
  216. package/src/components/viewer/sky-control.tsx +0 -710
  217. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  218. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  219. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  220. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  221. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  222. package/src/components/viewer/viewer-rooms.tsx +0 -248
  223. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  224. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  225. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  226. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  227. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  228. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  229. package/src/components/viewer/viewer-stage.tsx +0 -221
  230. package/src/components/walkthrough-hud.tsx +0 -193
  231. package/src/hooks/use-mobile.ts +0 -19
  232. package/src/i18n/level-name.ts +0 -39
  233. package/src/i18n/scene-theme-name.ts +0 -16
  234. package/src/lib/below-grade.ts +0 -133
  235. package/src/lib/camera-pose.test.ts +0 -237
  236. package/src/lib/camera-pose.ts +0 -172
  237. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  238. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  239. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  240. package/src/lib/floorplan/marks.test.ts +0 -213
  241. package/src/lib/floorplan/marks.ts +0 -238
  242. package/src/lib/measurements.ts +0 -8
  243. package/src/lib/node-display-label.ts +0 -37
  244. package/src/lib/room-assembly-overlay.test.ts +0 -275
  245. package/src/lib/room-assembly-overlay.ts +0 -265
  246. package/src/lib/room-selection.ts +0 -417
  247. package/src/lib/scene-bounds.test.ts +0 -327
  248. package/src/lib/scene-bounds.ts +0 -233
  249. package/src/lib/typing-target.ts +0 -9
  250. package/src/lib/viewer-selection.ts +0 -595
  251. package/src/lib/zone-content.ts +0 -133
  252. package/src/store/camera-pose-store.test.ts +0 -29
  253. package/src/store/camera-pose-store.ts +0 -27
  254. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  255. package/src/store/navigation-sync-pose-store.ts +0 -27
  256. package/src/store/use-audio.test.ts +0 -146
  257. package/src/store/use-audio.tsx +0 -169
  258. package/src/store/use-first-person-hud.ts +0 -40
  259. package/src/store/use-follow-pascal.test.ts +0 -62
  260. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,130 +0,0 @@
1
- /**
2
- * Holes in the site ground's collision geometry.
3
- *
4
- * The walkthrough stands on one merged BVH mesh, and the site's ground (a flat slab, or the terrain)
5
- * is a sheet across the whole lot. Where the house stands over a basement the ground is drawn open
6
- * (`coveredBelowGradeFootprints`), but the sheet under the stairwell stayed: the player descending
7
- * the stair met it at grade and could go no deeper. Cutting the collider by the same rings the
8
- * ground is drawn open over lets the player follow the building's floors and stairs there.
9
- *
10
- * Every triangle that reaches a hole is cut against it in plan; the pieces keep the source
11
- * triangle's plane (height and normal interpolated from its corners) and its facing, so the cut
12
- * surface is exactly the one the uncut triangle was.
13
- */
14
-
15
- import { difference, type Ring, union } from '@pascal-app/core'
16
- import { BufferAttribute, BufferGeometry, ShapeUtils, Vector2 } from 'three'
17
-
18
- /** Triangles flatter than this in plan (m²) are vertical faces: they hold nothing up. */
19
- const MIN_PLAN_AREA = 1e-9
20
-
21
- type Corner = { x: number; y: number; z: number; nx: number; ny: number; nz: number }
22
-
23
- /** Twice the signed plan area of `a b c` (x right, z down). */
24
- function orient(a: [number, number], b: [number, number], c: [number, number]): number {
25
- return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
26
- }
27
-
28
- /**
29
- * `geometry` (non-indexed, `position` and `normal`, in the frame the rings are in) without the part
30
- * of it that lies over `holes`. A triangle the clip fails on is kept whole: the ground closed is
31
- * the safe side. The geometry is returned as it is when there are no holes.
32
- */
33
- export function cutGroundCollider(
34
- geometry: BufferGeometry,
35
- holes: readonly Ring[],
36
- ): BufferGeometry {
37
- if (holes.length === 0) return geometry
38
- const cutouts = union(holes)
39
- if (cutouts.length === 0) return geometry
40
- const boxes = cutouts.map(({ outer }) => ({
41
- minX: Math.min(...outer.map(([x]) => x)),
42
- maxX: Math.max(...outer.map(([x]) => x)),
43
- minZ: Math.min(...outer.map(([, z]) => z)),
44
- maxZ: Math.max(...outer.map(([, z]) => z)),
45
- }))
46
-
47
- const position = geometry.getAttribute('position')
48
- const normal = geometry.getAttribute('normal')
49
- const positions: number[] = []
50
- const normals: number[] = []
51
- const push = (corner: Corner) => {
52
- positions.push(corner.x, corner.y, corner.z)
53
- normals.push(corner.nx, corner.ny, corner.nz)
54
- }
55
-
56
- for (let i = 0; i + 2 < position.count; i += 3) {
57
- const corners: Corner[] = [0, 1, 2].map((k) => ({
58
- x: position.getX(i + k),
59
- y: position.getY(i + k),
60
- z: position.getZ(i + k),
61
- nx: normal.getX(i + k),
62
- ny: normal.getY(i + k),
63
- nz: normal.getZ(i + k),
64
- }))
65
- const [a, b, c] = corners as [Corner, Corner, Corner]
66
- const minX = Math.min(a.x, b.x, c.x)
67
- const maxX = Math.max(a.x, b.x, c.x)
68
- const minZ = Math.min(a.z, b.z, c.z)
69
- const maxZ = Math.max(a.z, b.z, c.z)
70
- const reached = boxes.some(
71
- (box) => box.maxX > minX && box.minX < maxX && box.maxZ > minZ && box.minZ < maxZ,
72
- )
73
- const plan: [[number, number], [number, number], [number, number]] = [
74
- [a.x, a.z],
75
- [b.x, b.z],
76
- [c.x, c.z],
77
- ]
78
- const determinant = orient(plan[0], plan[1], plan[2])
79
- if (!reached || Math.abs(determinant) < MIN_PLAN_AREA) {
80
- corners.forEach(push)
81
- continue
82
- }
83
-
84
- let pieces: ReturnType<typeof difference>
85
- try {
86
- pieces = difference(plan, cutouts, { throwOnError: true })
87
- } catch {
88
- corners.forEach(push)
89
- continue
90
- }
91
-
92
- const at = (x: number, z: number): Corner => {
93
- const wa = ((b.z - c.z) * (x - c.x) + (c.x - b.x) * (z - c.z)) / determinant
94
- const wb = ((c.z - a.z) * (x - c.x) + (a.x - c.x) * (z - c.z)) / determinant
95
- const wc = 1 - wa - wb
96
- const nx = a.nx * wa + b.nx * wb + c.nx * wc
97
- const ny = a.ny * wa + b.ny * wb + c.ny * wc
98
- const nz = a.nz * wa + b.nz * wb + c.nz * wc
99
- const length = Math.hypot(nx, ny, nz) || 1
100
- return {
101
- x,
102
- y: a.y * wa + b.y * wb + c.y * wc,
103
- z,
104
- nx: nx / length,
105
- ny: ny / length,
106
- nz: nz / length,
107
- }
108
- }
109
-
110
- for (const piece of pieces) {
111
- const rings = [piece.outer, ...piece.holes]
112
- const points = rings.flat()
113
- const contour = piece.outer.map(([x, z]) => new Vector2(x, z))
114
- const voids = piece.holes.map((ring) => ring.map(([x, z]) => new Vector2(x, z)))
115
- for (const [p, q, r] of ShapeUtils.triangulateShape(contour, voids)) {
116
- const corner = [p, q, r].map((index) => points[index as number] as [number, number])
117
- const swap =
118
- Math.sign(orient(corner[0]!, corner[1]!, corner[2]!)) !== Math.sign(determinant)
119
- const ordered = swap ? [corner[0]!, corner[2]!, corner[1]!] : corner
120
- for (const [x, z] of ordered) push(at(x, z))
121
- }
122
- }
123
- }
124
-
125
- const cut = new BufferGeometry()
126
- cut.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
127
- cut.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3))
128
- geometry.dispose()
129
- return cut
130
- }
@@ -1,162 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
- import {
3
- type AnyNode,
4
- type AnyNodeId,
5
- nodeRegistry,
6
- registerNode,
7
- sceneRegistry,
8
- useInteractive,
9
- useScene,
10
- } from '@pascal-app/core'
11
- import { Object3D } from 'three'
12
- import {
13
- hasNativeLeaf,
14
- mechanismHudInteract,
15
- mechanismTargetIds,
16
- nativeOpeningIds,
17
- watchScriptedDoors,
18
- } from './mechanism-targets'
19
-
20
- const switchMechanism = {
21
- has: (node: AnyNode) => (node as unknown as { joints: number }).joints > 0,
22
- isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
23
- Boolean(state.mechanisms[node.id]),
24
- set: (node: AnyNode, on: boolean) => useInteractive.getState().setMechanism(node.id, on),
25
- }
26
-
27
- const mounted: [string, string][] = []
28
-
29
- function mount(kind: string, id: string, fields: Record<string, unknown> = {}): AnyNode {
30
- const node = { object: 'node', id, type: kind, ...fields } as unknown as AnyNode
31
- useScene.setState((state) => ({ nodes: { ...state.nodes, [id]: node } }) as never)
32
- sceneRegistry.nodes.set(id, new Object3D())
33
- sceneRegistry.byType[kind]!.add(id)
34
- mounted.push([kind, id])
35
- return node
36
- }
37
-
38
- function registerKind(capabilities: Record<string, unknown>): string {
39
- const kind = `test:walk-${crypto.randomUUID()}`
40
- registerNode({
41
- kind,
42
- schemaVersion: 1,
43
- category: 'furnish',
44
- defaults: () => ({}),
45
- capabilities,
46
- } as never)
47
- return kind
48
- }
49
-
50
- afterEach(() => {
51
- for (const [kind, id] of mounted.splice(0)) {
52
- sceneRegistry.nodes.delete(id)
53
- sceneRegistry.byType[kind]!.delete(id)
54
- useInteractive.getState().removeMechanism(id as AnyNodeId)
55
- }
56
- })
57
-
58
- describe('walkthrough mechanism targets', () => {
59
- test('any mounted node whose kind declares a mechanism is a target, once', () => {
60
- const kind = registerKind({ mechanism: switchMechanism })
61
- mount(kind, 'articulated_1', { joints: 2 })
62
- mount(kind, 'articulated_still', { joints: 0 })
63
- mount(registerKind({}), 'plain_1')
64
- const covered = mount(kind, 'articulated_covered', { joints: 1 })
65
-
66
- expect(mechanismTargetIds(new Set([covered.id]))).toEqual(['articulated_1' as AnyNodeId])
67
- })
68
-
69
- test('the HUD names the node and says what E will do', () => {
70
- const kind = registerKind({ mechanism: switchMechanism })
71
- const node = mount(kind, 'articulated_hud', { joints: 1, name: 'Desk lamp' })
72
- expect(mechanismHudInteract(node)).toEqual({ label: 'Desk lamp', verb: 'turn on' })
73
- useInteractive.getState().setMechanism(node.id, true)
74
- expect(mechanismHudInteract(node)).toEqual({ label: 'Desk lamp', verb: 'turn off' })
75
-
76
- const lid = mount(registerKind({ mechanism: { ...switchMechanism, verb: 'open' } }), 'lid_1', {
77
- joints: 1,
78
- })
79
- expect(mechanismHudInteract(lid)).toEqual({ label: 'item', verb: 'open' })
80
- expect(mechanismHudInteract(mount(registerKind({}), 'plain_hud'))).toBeNull()
81
- })
82
- })
83
-
84
- // A door or window built from a script has no native leaf or sash: the walkthrough's own door and
85
- // window targets only start a native swing on it, which draws nothing. Its `open` clip runs
86
- // through its mechanism, as an authored object's does.
87
- describe('walkthrough doors and windows', () => {
88
- const script = { source: { kind: 'script', script: 'b'.repeat(64), artifact: 'a'.repeat(64) } }
89
-
90
- test('the doors and windows it swings itself are the ones with a native leaf', () => {
91
- const native = mount('door', 'door_native')
92
- const scripted = mount('door', 'door_scripted', script)
93
- mount('window', 'window_native')
94
- mount('window', 'window_scripted', script)
95
-
96
- expect(nativeOpeningIds().sort()).toEqual(['door_native', 'window_native'] as AnyNodeId[])
97
- expect(hasNativeLeaf(native)).toBe(true)
98
- expect(hasNativeLeaf(scripted)).toBe(false)
99
- expect(hasNativeLeaf(undefined)).toBe(true)
100
- })
101
- })
102
-
103
- // The walkthrough's collider world is rebuilt when a native door's tween ends. A door built from a
104
- // script opens by a clip that no tween system reports: its collider follows its mechanism, so
105
- // the world has to be rebuilt when that flips.
106
- describe('walkthrough scripted doors', () => {
107
- const mechanism = {
108
- verb: 'open',
109
- has: (node: AnyNode) => Boolean((node as unknown as { source?: unknown }).source),
110
- isOn: (node: AnyNode, state: ReturnType<typeof useInteractive.getState>) =>
111
- Boolean(state.items[node.id as AnyNodeId]?.controlValues[0]),
112
- set: () => {},
113
- }
114
- let restoreRegistry = () => {}
115
-
116
- beforeEach(() => {
117
- restoreRegistry = nodeRegistry._snapshot()
118
- registerNode({
119
- kind: 'door',
120
- schemaVersion: 1,
121
- category: 'structure',
122
- defaults: () => ({}),
123
- capabilities: { mechanism },
124
- } as never)
125
- })
126
-
127
- afterEach(() => {
128
- useInteractive.setState({ items: {} } as never)
129
- restoreRegistry()
130
- })
131
-
132
- test('a rebuild is asked for when a scripted door opens or closes, and for nothing else', () => {
133
- mount('door', 'door_scripted_watch', { source: { kind: 'script' } })
134
- mount('door', 'door_native_watch')
135
- let rebuilds = 0
136
- const stop = watchScriptedDoors(() => rebuilds++)
137
- const setControls = (scripted: boolean, native: boolean) =>
138
- useInteractive.setState({
139
- items: {
140
- door_scripted_watch: { controlValues: [scripted] },
141
- door_native_watch: { controlValues: [native] },
142
- },
143
- } as never)
144
- try {
145
- // its model initialising the item closed is not a change
146
- setControls(false, false)
147
- expect(rebuilds).toBe(0)
148
- setControls(true, false)
149
- expect(rebuilds).toBe(1)
150
- // the same state again, and a native door's control, are not its business
151
- setControls(true, false)
152
- setControls(true, true)
153
- expect(rebuilds).toBe(1)
154
- setControls(false, true)
155
- expect(rebuilds).toBe(2)
156
- } finally {
157
- stop()
158
- }
159
- setControls(true, true)
160
- expect(rebuilds).toBe(2)
161
- })
162
- })
@@ -1,88 +0,0 @@
1
- import {
2
- type AnyNode,
3
- type AnyNodeId,
4
- isScriptedNode,
5
- nodeMechanism,
6
- nodeRegistry,
7
- sceneRegistry,
8
- useInteractive,
9
- useScene,
10
- } from '@pascal-app/core'
11
- import type { WalkthroughInteract } from '../../../store/use-first-person-hud'
12
-
13
- /**
14
- * Whether the walkthrough swings this door or window itself, through the native `operationState`
15
- * its door and window systems draw. One built from a script has no native leaf or sash: a swing
16
- * would draw nothing, so its clips run through its mechanism, as an authored object's do.
17
- */
18
- export function hasNativeLeaf(node: AnyNode | undefined): boolean {
19
- return !isScriptedNode(node)
20
- }
21
-
22
- /** Mounted doors and windows the walkthrough's own door and window targets cover. */
23
- export function nativeOpeningIds(): AnyNodeId[] {
24
- const nodes = useScene.getState().nodes
25
- const ids: AnyNodeId[] = []
26
- for (const kind of ['door', 'window'] as const) {
27
- for (const id of sceneRegistry.byType[kind] ?? []) {
28
- if (hasNativeLeaf(nodes[id as AnyNodeId])) ids.push(id as AnyNodeId)
29
- }
30
- }
31
- return ids
32
- }
33
-
34
- /**
35
- * Mounted nodes the walkthrough runs through `capabilities.mechanism`, any kind
36
- * included. `covered` holds the nodes a finer target already tested (procedural
37
- * parts, catalog item toggles), so no node is targeted twice.
38
- */
39
- export function mechanismTargetIds(covered: ReadonlySet<string>): AnyNodeId[] {
40
- const nodes = useScene.getState().nodes
41
- const ids: AnyNodeId[] = []
42
- for (const [kind, kindIds] of Object.entries(sceneRegistry.byType)) {
43
- if (!nodeRegistry.get(kind)?.capabilities.mechanism) continue
44
- for (const id of kindIds) {
45
- if (!covered.has(id) && nodeMechanism(nodes[id as AnyNodeId])) ids.push(id as AnyNodeId)
46
- }
47
- }
48
- return ids
49
- }
50
-
51
- /** The walkthrough prompt for a mechanism target: its name and what E does next. */
52
- export function mechanismHudInteract(node: AnyNode): WalkthroughInteract {
53
- const mechanism = nodeMechanism(node)
54
- if (!mechanism) return null
55
- const on = mechanism.isOn(node, useInteractive.getState())
56
- const verb = mechanism.verb === 'open' ? (on ? 'close' : 'open') : on ? 'turn off' : 'turn on'
57
- return { label: node.name ?? 'item', verb }
58
- }
59
-
60
- /** Ids of the mounted doors built from a script that are open, by their mechanism. */
61
- function openScriptedDoors(): string {
62
- const nodes = useScene.getState().nodes
63
- const state = useInteractive.getState()
64
- const open: string[] = []
65
- for (const id of sceneRegistry.byType.door ?? []) {
66
- const node = nodes[id as AnyNodeId]
67
- if (isScriptedNode(node) && nodeMechanism(node)?.isOn(node, state)) open.push(id)
68
- }
69
- return open.sort().join(',')
70
- }
71
-
72
- /**
73
- * Calls `onChange` when a mounted door built from a script opens or closes. The walkthrough's
74
- * collider follows the door's mechanism, and its model's clip is moved by no tween system that
75
- * would report the end, as a native door's does: without this the world keeps the leaf that was
76
- * there when it was last built. It fires at the toggle: the doorway is passable as the leaves
77
- * start to move, and solid again as they start to close.
78
- */
79
- export function watchScriptedDoors(onChange: () => void): () => void {
80
- let known = openScriptedDoors()
81
- return useInteractive.subscribe((state, previous) => {
82
- if (state.items === previous.items) return
83
- const next = openScriptedDoors()
84
- if (next === known) return
85
- known = next
86
- onChange()
87
- })
88
- }
@@ -1,132 +0,0 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import {
3
- applyHeightPatch,
4
- createTerrainField,
5
- flattenPatch,
6
- heightAt,
7
- type TerrainField,
8
- } from '@pascal-app/core'
9
- import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
10
- import { createTerrainColliderGeometry } from './terrain-collider'
11
-
12
- const DOWN = new Vector3(0, -1, 0)
13
- const UP = new Vector3(0, 1, 0)
14
-
15
- /** A 2.5 m plateau over x,z ∈ [2,5] on an otherwise flat field. */
16
- function plateauField(): TerrainField {
17
- const base = createTerrainField({ cols: 17, rows: 17, spacing: 1, origin: [-8, -8] })
18
- const patch = flattenPatch(base, { minX: 2, minZ: 2, maxX: 5, maxZ: 5 }, 2.5)
19
- return applyHeightPatch(base, patch as never)
20
- }
21
-
22
- /** A field sloping 0.1 m per metre along +x, so every sample differs. */
23
- function slopedField(): TerrainField {
24
- const field = createTerrainField({ cols: 9, rows: 9, spacing: 1, origin: [-4, -4] })
25
- const heights = new Int16Array(field.heights)
26
- for (let r = 0; r < field.rows; r++) {
27
- for (let c = 0; c < field.cols; c++) {
28
- heights[r * field.cols + c] = Math.round((c * 0.1) /* m */ / field.step)
29
- }
30
- }
31
- return { ...field, heights }
32
- }
33
-
34
- function colliderMesh(field: TerrainField) {
35
- const geometry = createTerrainColliderGeometry(field)
36
- if (!geometry) throw new Error('no collider geometry')
37
- const mesh = new Mesh(geometry, new MeshBasicMaterial())
38
- mesh.updateMatrixWorld(true)
39
- return mesh
40
- }
41
-
42
- /** The height a downward ray finds — what the walkthrough player stands on. */
43
- function groundUnder(mesh: Mesh, x: number, z: number): number | null {
44
- const raycaster = new Raycaster()
45
- raycaster.set(new Vector3(x, 500, z), DOWN)
46
- const hits = raycaster.intersectObject(mesh, false)
47
- const floor = hits.find((hit) => {
48
- if (!hit.face) return true
49
- return hit.face.normal.clone().transformDirection(mesh.matrixWorld).dot(UP) > 0.2
50
- })
51
- return floor ? floor.point.y : null
52
- }
53
-
54
- describe('createTerrainColliderGeometry', () => {
55
- test('the walkable surface is the field, not a plane', () => {
56
- const field = plateauField()
57
- const mesh = colliderMesh(field)
58
-
59
- // On the plateau, off it, and on the slope between: the collider has to agree
60
- // with `heightAt` at each, or what you see is not what you stand on.
61
- for (const [x, z] of [
62
- [3, 3],
63
- [-3, -3],
64
- [1.5, 3],
65
- [4.5, 4.5],
66
- ] as Array<[number, number]>) {
67
- expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
68
- }
69
- })
70
-
71
- test('a sloped field is sampled per vertex, not averaged', () => {
72
- const field = slopedField()
73
- const mesh = colliderMesh(field)
74
-
75
- // Sample midpoints, where a flat or decimated collider would disagree most.
76
- for (const x of [-3.5, -0.5, 1.5, 3.5]) {
77
- expect(groundUnder(mesh, x, 0)).toBeCloseTo(heightAt(field, x, 0), 4)
78
- }
79
- })
80
-
81
- test('the skirt keeps holding the player far past the field', () => {
82
- const field = slopedField()
83
- const mesh = colliderMesh(field)
84
-
85
- // Past every edge and past a corner: `heightAt` clamps to the border there, so
86
- // the collider must too. Without the skirt these are null and the player falls.
87
- for (const [x, z] of [
88
- [400, 0],
89
- [-400, 0],
90
- [0, 400],
91
- [0, -400],
92
- [400, 400],
93
- [-400, -400],
94
- ] as Array<[number, number]>) {
95
- expect(groundUnder(mesh, x, z)).toBeCloseTo(heightAt(field, x, z), 4)
96
- }
97
- })
98
-
99
- test('every triangle carries a normal, and the surface faces up', () => {
100
- const geometry = createTerrainColliderGeometry(plateauField())
101
- if (!geometry) throw new Error('no collider geometry')
102
-
103
- const position = geometry.getAttribute('position')
104
- const normal = geometry.getAttribute('normal')
105
- expect(normal).toBeDefined()
106
- expect(normal.count).toBe(position.count)
107
- // Non-indexed triangles — `mergeGeometries` needs every contributor to match.
108
- expect(geometry.index).toBeNull()
109
- expect(position.count % 3).toBe(0)
110
-
111
- for (let i = 0; i < normal.count; i++) {
112
- expect(normal.getY(i)).toBeGreaterThan(0)
113
- }
114
- })
115
-
116
- test('decimates rather than exploding on an absurd field', () => {
117
- // 1025² is inside the schema's decode-bomb guard but 1M cells — an exact
118
- // collider would be 2M triangles for a surface nobody can walk that far across.
119
- const field = createTerrainField({ cols: 1025, rows: 1025, spacing: 0.5 })
120
- const geometry = createTerrainColliderGeometry(field)
121
- if (!geometry) throw new Error('no collider geometry')
122
-
123
- const triangles = geometry.getAttribute('position').count / 3
124
- expect(triangles).toBeLessThan(200_000)
125
- // Still a real surface, not an empty one.
126
- expect(triangles).toBeGreaterThan(1000)
127
- })
128
-
129
- test('returns null for a degenerate field', () => {
130
- expect(createTerrainColliderGeometry(createTerrainField({ cols: 1, rows: 1 }))).toBeNull()
131
- })
132
- })