@jgengine/core 0.11.0 → 0.13.0

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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -0,0 +1,699 @@
1
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2
+ const BASE64_INDEX = new Int8Array(128).fill(-1);
3
+ for (let i = 0; i < BASE64.length; i += 1)
4
+ BASE64_INDEX[BASE64.charCodeAt(i)] = i;
5
+ function bytesToBase64(bytes) {
6
+ let out = "";
7
+ let i = 0;
8
+ for (; i + 2 < bytes.length; i += 3) {
9
+ const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
10
+ out += BASE64[(chunk >> 18) & 63] + BASE64[(chunk >> 12) & 63] + BASE64[(chunk >> 6) & 63] + BASE64[chunk & 63];
11
+ }
12
+ const rest = bytes.length - i;
13
+ if (rest === 1) {
14
+ const chunk = bytes[i] << 16;
15
+ out += BASE64[(chunk >> 18) & 63] + BASE64[(chunk >> 12) & 63] + "==";
16
+ }
17
+ else if (rest === 2) {
18
+ const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8);
19
+ out += BASE64[(chunk >> 18) & 63] + BASE64[(chunk >> 12) & 63] + BASE64[(chunk >> 6) & 63] + "=";
20
+ }
21
+ return out;
22
+ }
23
+ function base64ToBytes(text) {
24
+ let length = text.length;
25
+ while (length > 0 && text[length - 1] === "=")
26
+ length -= 1;
27
+ if (text.length % 4 !== 0 && text.length - length !== 0)
28
+ return null;
29
+ const byteLength = Math.floor((length * 3) / 4);
30
+ const out = new Uint8Array(byteLength);
31
+ let outIndex = 0;
32
+ let buffer = 0;
33
+ let bits = 0;
34
+ for (let i = 0; i < length; i += 1) {
35
+ const code = text.charCodeAt(i);
36
+ const value = code < 128 ? BASE64_INDEX[code] : -1;
37
+ if (value < 0)
38
+ return null;
39
+ buffer = (buffer << 6) | value;
40
+ bits += 6;
41
+ if (bits >= 8) {
42
+ bits -= 8;
43
+ out[outIndex] = (buffer >> bits) & 0xff;
44
+ outIndex += 1;
45
+ }
46
+ }
47
+ if (outIndex !== byteLength)
48
+ return null;
49
+ return out;
50
+ }
51
+ /**
52
+ * Quantize a triangle soup onto the 16-bit grid, weld coincident vertices, and drop triangles that
53
+ * collapse — the reindex-time compressor producing {@link CollisionMeshData}. Returns `null` when no
54
+ * finite, non-degenerate triangle survives.
55
+ */
56
+ export function encodeCollisionMesh(source) {
57
+ const positions = source.positions;
58
+ const indices = source.indices;
59
+ if (positions.length < 9 || positions.length % 3 !== 0)
60
+ return null;
61
+ if (indices.length < 3 || indices.length % 3 !== 0)
62
+ return null;
63
+ const sourceVertexCount = positions.length / 3;
64
+ let minX = Infinity;
65
+ let minY = Infinity;
66
+ let minZ = Infinity;
67
+ let maxX = -Infinity;
68
+ let maxY = -Infinity;
69
+ let maxZ = -Infinity;
70
+ const referenced = new Uint8Array(sourceVertexCount);
71
+ for (let i = 0; i < indices.length; i += 1) {
72
+ const index = indices[i];
73
+ if (!Number.isInteger(index) || index < 0 || index >= sourceVertexCount)
74
+ continue;
75
+ referenced[index] = 1;
76
+ }
77
+ for (let v = 0; v < sourceVertexCount; v += 1) {
78
+ if (referenced[v] === 0)
79
+ continue;
80
+ const x = positions[v * 3];
81
+ const y = positions[v * 3 + 1];
82
+ const z = positions[v * 3 + 2];
83
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
84
+ referenced[v] = 0;
85
+ continue;
86
+ }
87
+ if (x < minX)
88
+ minX = x;
89
+ if (y < minY)
90
+ minY = y;
91
+ if (z < minZ)
92
+ minZ = z;
93
+ if (x > maxX)
94
+ maxX = x;
95
+ if (y > maxY)
96
+ maxY = y;
97
+ if (z > maxZ)
98
+ maxZ = z;
99
+ }
100
+ if (!Number.isFinite(minX) || !Number.isFinite(maxX))
101
+ return null;
102
+ const spanX = maxX - minX;
103
+ const spanY = maxY - minY;
104
+ const spanZ = maxZ - minZ;
105
+ const quantized = new Int32Array(sourceVertexCount).fill(-1);
106
+ const welded = new Map();
107
+ const weldedQuantized = [];
108
+ for (let v = 0; v < sourceVertexCount; v += 1) {
109
+ if (referenced[v] === 0)
110
+ continue;
111
+ const qx = spanX > 0 ? Math.round(((positions[v * 3] - minX) / spanX) * 65535) : 0;
112
+ const qy = spanY > 0 ? Math.round(((positions[v * 3 + 1] - minY) / spanY) * 65535) : 0;
113
+ const qz = spanZ > 0 ? Math.round(((positions[v * 3 + 2] - minZ) / spanZ) * 65535) : 0;
114
+ const key = qx + qy * 65536 + qz * 4294967296;
115
+ let weldedIndex = welded.get(key);
116
+ if (weldedIndex === undefined) {
117
+ weldedIndex = weldedQuantized.length / 3;
118
+ welded.set(key, weldedIndex);
119
+ weldedQuantized.push(qx, qy, qz);
120
+ }
121
+ quantized[v] = weldedIndex;
122
+ }
123
+ const triangles = [];
124
+ for (let t = 0; t + 2 < indices.length; t += 3) {
125
+ const a = quantized[indices[t]] ?? -1;
126
+ const b = quantized[indices[t + 1]] ?? -1;
127
+ const c = quantized[indices[t + 2]] ?? -1;
128
+ if (a < 0 || b < 0 || c < 0)
129
+ continue;
130
+ if (a === b || b === c || a === c)
131
+ continue;
132
+ triangles.push(a, b, c);
133
+ }
134
+ if (triangles.length === 0)
135
+ return null;
136
+ const vertexCount = weldedQuantized.length / 3;
137
+ const positionBytes = new Uint8Array(vertexCount * 6);
138
+ const positionView = new DataView(positionBytes.buffer);
139
+ for (let i = 0; i < weldedQuantized.length; i += 1) {
140
+ positionView.setUint16(i * 2, weldedQuantized[i], true);
141
+ }
142
+ const wide = vertexCount > 65536;
143
+ const indexBytes = new Uint8Array(triangles.length * (wide ? 4 : 2));
144
+ const indexView = new DataView(indexBytes.buffer);
145
+ for (let i = 0; i < triangles.length; i += 1) {
146
+ if (wide)
147
+ indexView.setUint32(i * 4, triangles[i], true);
148
+ else
149
+ indexView.setUint16(i * 2, triangles[i], true);
150
+ }
151
+ const boxes = voxelizeToBoxes(source, 0.25, 32);
152
+ return {
153
+ min: [minX, minY, minZ],
154
+ max: [maxX, maxY, maxZ],
155
+ vertexCount,
156
+ triangleCount: triangles.length / 3,
157
+ positions: bytesToBase64(positionBytes),
158
+ indices: bytesToBase64(indexBytes),
159
+ ...(boxes.length > 0 ? { boxes } : {}),
160
+ };
161
+ }
162
+ /** SAT test: do the three triangle vertices (already translated so the cell center is the origin) overlap
163
+ * an axis-aligned box of half-size `h`? Projects both onto `axis`; returns false only on a separating gap. */
164
+ function triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, ax, ay, az, hx, hy, hz) {
165
+ const p0 = v0x * ax + v0y * ay + v0z * az;
166
+ const p1 = v1x * ax + v1y * ay + v1z * az;
167
+ const p2 = v2x * ax + v2y * ay + v2z * az;
168
+ const min = Math.min(p0, p1, p2);
169
+ const max = Math.max(p0, p1, p2);
170
+ const radius = Math.abs(ax) * hx + Math.abs(ay) * hy + Math.abs(az) * hz;
171
+ return min > radius || max < -radius;
172
+ }
173
+ /** Akenine–Möller triangle vs axis-aligned box overlap (13-axis SAT), box given by center + half-size. */
174
+ function triangleOverlapsBox(ax, ay, az, bx, by, bz, cx, cy, cz, centerX, centerY, centerZ, hx, hy, hz) {
175
+ const v0x = ax - centerX, v0y = ay - centerY, v0z = az - centerZ;
176
+ const v1x = bx - centerX, v1y = by - centerY, v1z = bz - centerZ;
177
+ const v2x = cx - centerX, v2y = cy - centerY, v2z = cz - centerZ;
178
+ const e0x = v1x - v0x, e0y = v1y - v0y, e0z = v1z - v0z;
179
+ const e1x = v2x - v1x, e1y = v2y - v1y, e1z = v2z - v1z;
180
+ const e2x = v0x - v2x, e2y = v0y - v2y, e2z = v0z - v2z;
181
+ const edges = [
182
+ [e0x, e0y, e0z],
183
+ [e1x, e1y, e1z],
184
+ [e2x, e2y, e2z],
185
+ ];
186
+ // 9 axes: each triangle edge crossed with each box face normal (X, Y, Z).
187
+ for (const [ex, ey, ez] of edges) {
188
+ // edge × X = (0, -ez, ey); edge × Y = (ez, 0, -ex); edge × Z = (-ey, ex, 0)
189
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, 0, -ez, ey, hx, hy, hz))
190
+ return false;
191
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, ez, 0, -ex, hx, hy, hz))
192
+ return false;
193
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, -ey, ex, 0, hx, hy, hz))
194
+ return false;
195
+ }
196
+ // 3 box face normals.
197
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, 1, 0, 0, hx, hy, hz))
198
+ return false;
199
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, 0, 1, 0, hx, hy, hz))
200
+ return false;
201
+ if (triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, 0, 0, 1, hx, hy, hz))
202
+ return false;
203
+ // 1 axis: the triangle's face normal.
204
+ const nx = e0y * e1z - e0z * e1y;
205
+ const ny = e0z * e1x - e0x * e1z;
206
+ const nz = e0x * e1y - e0y * e1x;
207
+ if ((nx !== 0 || ny !== 0 || nz !== 0) &&
208
+ triangleSeparatedOnAxis(v0x, v0y, v0z, v1x, v1y, v1z, v2x, v2y, v2z, nx, ny, nz, hx, hy, hz)) {
209
+ return false;
210
+ }
211
+ return true;
212
+ }
213
+ const VOXEL_MAX_CELLS = 2_000_000;
214
+ /**
215
+ * Decompose a triangle soup into a compact, deterministic set of model-space AABBs approximating its
216
+ * solid volume — the movement-obstruction counterpart to the raycast BVH. Rasterizes triangles onto a
217
+ * `cellSize` voxel grid, fills each vertical column between its lowest and highest occupied voxel (so a
218
+ * solid becomes solid and a concave opening — an archway — keeps its central gap empty while its pillars
219
+ * and lintel fill in), then greedy-merges occupied voxels into AABBs in a fixed z→y→x order. Pure and
220
+ * allocation-tolerant (runs once per asset at reindex). Caps the output at `maxBoxes`, folding any
221
+ * remainder into one enclosing AABB so obstruction stays conservative. Returns `[]` for degenerate input.
222
+ * @internal
223
+ */
224
+ export function voxelizeToBoxes(source, cellSize = 0.25, maxBoxes = 32) {
225
+ const positions = source.positions;
226
+ const indices = source.indices;
227
+ if (!(cellSize > 0) || !Number.isFinite(cellSize))
228
+ return [];
229
+ if (!(maxBoxes >= 1))
230
+ return [];
231
+ if (positions.length < 9 || positions.length % 3 !== 0)
232
+ return [];
233
+ if (indices.length < 3 || indices.length % 3 !== 0)
234
+ return [];
235
+ const vertexCount = positions.length / 3;
236
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
237
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
238
+ const referenced = new Uint8Array(vertexCount);
239
+ for (let i = 0; i < indices.length; i += 1) {
240
+ const index = indices[i];
241
+ if (!Number.isInteger(index) || index < 0 || index >= vertexCount)
242
+ continue;
243
+ referenced[index] = 1;
244
+ }
245
+ for (let v = 0; v < vertexCount; v += 1) {
246
+ if (referenced[v] === 0)
247
+ continue;
248
+ const x = positions[v * 3], y = positions[v * 3 + 1], z = positions[v * 3 + 2];
249
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
250
+ referenced[v] = 0;
251
+ continue;
252
+ }
253
+ if (x < minX)
254
+ minX = x;
255
+ if (y < minY)
256
+ minY = y;
257
+ if (z < minZ)
258
+ minZ = z;
259
+ if (x > maxX)
260
+ maxX = x;
261
+ if (y > maxY)
262
+ maxY = y;
263
+ if (z > maxZ)
264
+ maxZ = z;
265
+ }
266
+ if (!Number.isFinite(minX) || !Number.isFinite(maxX))
267
+ return [];
268
+ const spanX = maxX - minX, spanY = maxY - minY, spanZ = maxZ - minZ;
269
+ if (!(spanX > 0) && !(spanY > 0) && !(spanZ > 0))
270
+ return [];
271
+ // Coarsen the grid up until it fits the cell budget, so a huge model can't allocate an enormous grid.
272
+ let cell = cellSize;
273
+ let nx = 1, ny = 1, nz = 1;
274
+ for (;;) {
275
+ nx = Math.max(1, Math.ceil(spanX / cell));
276
+ ny = Math.max(1, Math.ceil(spanY / cell));
277
+ nz = Math.max(1, Math.ceil(spanZ / cell));
278
+ if (nx * ny * nz <= VOXEL_MAX_CELLS)
279
+ break;
280
+ cell *= 2;
281
+ }
282
+ const total = nx * ny * nz;
283
+ const stride = nx * ny;
284
+ const occupancy = new Uint8Array(total);
285
+ // Rasterize: mark every voxel each triangle actually overlaps (true SAT, not a fat AABB), so a triangle
286
+ // spanning an opening never leaks solidity into the gap between the surfaces it connects.
287
+ const half = cell / 2;
288
+ for (let t = 0; t + 2 < indices.length; t += 3) {
289
+ const ia = indices[t], ib = indices[t + 1], ic = indices[t + 2];
290
+ if (referenced[ia] === 0 || referenced[ib] === 0 || referenced[ic] === 0)
291
+ continue;
292
+ const ax = positions[ia * 3], ay = positions[ia * 3 + 1], az = positions[ia * 3 + 2];
293
+ const bx = positions[ib * 3], by = positions[ib * 3 + 1], bz = positions[ib * 3 + 2];
294
+ const cx = positions[ic * 3], cy = positions[ic * 3 + 1], cz = positions[ic * 3 + 2];
295
+ const cellOf = (v, mn, n) => Math.min(n - 1, Math.max(0, Math.floor((v - mn) / cell)));
296
+ const ix0 = cellOf(Math.min(ax, bx, cx), minX, nx);
297
+ const ix1 = cellOf(Math.max(ax, bx, cx), minX, nx);
298
+ const iy0 = cellOf(Math.min(ay, by, cy), minY, ny);
299
+ const iy1 = cellOf(Math.max(ay, by, cy), minY, ny);
300
+ const iz0 = cellOf(Math.min(az, bz, cz), minZ, nz);
301
+ const iz1 = cellOf(Math.max(az, bz, cz), minZ, nz);
302
+ for (let iz = iz0; iz <= iz1; iz += 1) {
303
+ const czc = minZ + (iz + 0.5) * cell;
304
+ for (let iy = iy0; iy <= iy1; iy += 1) {
305
+ const cyc = minY + (iy + 0.5) * cell;
306
+ for (let ix = ix0; ix <= ix1; ix += 1) {
307
+ const idx = ix + iy * nx + iz * stride;
308
+ if (occupancy[idx] === 1)
309
+ continue;
310
+ const cxc = minX + (ix + 0.5) * cell;
311
+ if (triangleOverlapsBox(ax, ay, az, bx, by, bz, cx, cy, cz, cxc, cyc, czc, half, half, half)) {
312
+ occupancy[idx] = 1;
313
+ }
314
+ }
315
+ }
316
+ }
317
+ }
318
+ // Column fill: solidify each (x,z) column between its lowest and highest occupied voxel. A span becomes
319
+ // a filled interior; an arch keeps its central gap empty (that column only touches the lintel up top).
320
+ for (let iz = 0; iz < nz; iz += 1) {
321
+ for (let ix = 0; ix < nx; ix += 1) {
322
+ let firstY = -1, lastY = -1;
323
+ for (let iy = 0; iy < ny; iy += 1) {
324
+ if (occupancy[ix + iy * nx + iz * stride] === 1) {
325
+ if (firstY < 0)
326
+ firstY = iy;
327
+ lastY = iy;
328
+ }
329
+ }
330
+ for (let iy = firstY; iy <= lastY; iy += 1)
331
+ occupancy[ix + iy * nx + iz * stride] = 1;
332
+ }
333
+ }
334
+ // Greedy-merge occupied voxels into AABBs in fixed z→y→x order: grow +x along the row, then +y over the
335
+ // whole x-strip, then +z over the whole xy-slab. Deterministic — output order is the scan order.
336
+ const cellToModel = (i, mn, mx) => Math.min(mx, mn + i * cell);
337
+ const claimed = new Uint8Array(total);
338
+ const boxes = [];
339
+ for (let z0 = 0; z0 < nz; z0 += 1) {
340
+ for (let y0 = 0; y0 < ny; y0 += 1) {
341
+ for (let x0 = 0; x0 < nx; x0 += 1) {
342
+ const i0 = x0 + y0 * nx + z0 * stride;
343
+ if (occupancy[i0] === 0 || claimed[i0] === 1)
344
+ continue;
345
+ // At the cap, fold this voxel and every remaining unclaimed occupied voxel into one enclosing AABB.
346
+ if (boxes.length === maxBoxes - 1) {
347
+ let ex0 = nx, ey0 = ny, ez0 = nz, ex1 = -1, ey1 = -1, ez1 = -1;
348
+ for (let z = 0; z < nz; z += 1)
349
+ for (let y = 0; y < ny; y += 1)
350
+ for (let x = 0; x < nx; x += 1) {
351
+ const i = x + y * nx + z * stride;
352
+ if (occupancy[i] === 0 || claimed[i] === 1)
353
+ continue;
354
+ if (x < ex0)
355
+ ex0 = x;
356
+ if (y < ey0)
357
+ ey0 = y;
358
+ if (z < ez0)
359
+ ez0 = z;
360
+ if (x > ex1)
361
+ ex1 = x;
362
+ if (y > ey1)
363
+ ey1 = y;
364
+ if (z > ez1)
365
+ ez1 = z;
366
+ }
367
+ boxes.push({
368
+ min: [cellToModel(ex0, minX, maxX), cellToModel(ey0, minY, maxY), cellToModel(ez0, minZ, maxZ)],
369
+ max: [cellToModel(ex1 + 1, minX, maxX), cellToModel(ey1 + 1, minY, maxY), cellToModel(ez1 + 1, minZ, maxZ)],
370
+ });
371
+ return boxes;
372
+ }
373
+ let x1 = x0;
374
+ while (x1 + 1 < nx) {
375
+ const i = x1 + 1 + y0 * nx + z0 * stride;
376
+ if (occupancy[i] === 0 || claimed[i] === 1)
377
+ break;
378
+ x1 += 1;
379
+ }
380
+ let y1 = y0;
381
+ for (let grow = true; grow && y1 + 1 < ny;) {
382
+ const yy = y1 + 1;
383
+ for (let x = x0; x <= x1; x += 1) {
384
+ const i = x + yy * nx + z0 * stride;
385
+ if (occupancy[i] === 0 || claimed[i] === 1) {
386
+ grow = false;
387
+ break;
388
+ }
389
+ }
390
+ if (grow)
391
+ y1 = yy;
392
+ }
393
+ let z1 = z0;
394
+ for (let grow = true; grow && z1 + 1 < nz;) {
395
+ const zz = z1 + 1;
396
+ for (let y = y0; grow && y <= y1; y += 1) {
397
+ for (let x = x0; x <= x1; x += 1) {
398
+ const i = x + y * nx + zz * stride;
399
+ if (occupancy[i] === 0 || claimed[i] === 1) {
400
+ grow = false;
401
+ break;
402
+ }
403
+ }
404
+ }
405
+ if (grow)
406
+ z1 = zz;
407
+ }
408
+ for (let z = z0; z <= z1; z += 1)
409
+ for (let y = y0; y <= y1; y += 1)
410
+ for (let x = x0; x <= x1; x += 1)
411
+ claimed[x + y * nx + z * stride] = 1;
412
+ boxes.push({
413
+ min: [cellToModel(x0, minX, maxX), cellToModel(y0, minY, maxY), cellToModel(z0, minZ, maxZ)],
414
+ max: [cellToModel(x1 + 1, minX, maxX), cellToModel(y1 + 1, minY, maxY), cellToModel(z1 + 1, minZ, maxZ)],
415
+ });
416
+ }
417
+ }
418
+ }
419
+ return boxes;
420
+ }
421
+ const LEAF_TRIANGLES = 4;
422
+ function buildBvh(positions, indices) {
423
+ const triangleCount = indices.length / 3;
424
+ const triOrder = new Uint32Array(triangleCount);
425
+ for (let t = 0; t < triangleCount; t += 1)
426
+ triOrder[t] = t;
427
+ const triBounds = new Float32Array(triangleCount * 6);
428
+ const centroids = new Float32Array(triangleCount * 3);
429
+ for (let t = 0; t < triangleCount; t += 1) {
430
+ const a = indices[t * 3] * 3;
431
+ const b = indices[t * 3 + 1] * 3;
432
+ const c = indices[t * 3 + 2] * 3;
433
+ for (let axis = 0; axis < 3; axis += 1) {
434
+ const pa = positions[a + axis];
435
+ const pb = positions[b + axis];
436
+ const pc = positions[c + axis];
437
+ const lo = Math.min(pa, pb, pc);
438
+ const hi = Math.max(pa, pb, pc);
439
+ triBounds[t * 6 + axis] = lo;
440
+ triBounds[t * 6 + 3 + axis] = hi;
441
+ centroids[t * 3 + axis] = (lo + hi) / 2;
442
+ }
443
+ }
444
+ const maxNodes = Math.max(1, 2 * triangleCount - 1);
445
+ const nodeBounds = new Float32Array(maxNodes * 6);
446
+ const nodeStart = new Int32Array(maxNodes);
447
+ const nodeCount = new Int32Array(maxNodes);
448
+ let usedNodes = 0;
449
+ function build(start, count) {
450
+ const node = usedNodes;
451
+ usedNodes += 1;
452
+ let minX = Infinity;
453
+ let minY = Infinity;
454
+ let minZ = Infinity;
455
+ let maxX = -Infinity;
456
+ let maxY = -Infinity;
457
+ let maxZ = -Infinity;
458
+ for (let i = start; i < start + count; i += 1) {
459
+ const t = triOrder[i] * 6;
460
+ if (triBounds[t] < minX)
461
+ minX = triBounds[t];
462
+ if (triBounds[t + 1] < minY)
463
+ minY = triBounds[t + 1];
464
+ if (triBounds[t + 2] < minZ)
465
+ minZ = triBounds[t + 2];
466
+ if (triBounds[t + 3] > maxX)
467
+ maxX = triBounds[t + 3];
468
+ if (triBounds[t + 4] > maxY)
469
+ maxY = triBounds[t + 4];
470
+ if (triBounds[t + 5] > maxZ)
471
+ maxZ = triBounds[t + 5];
472
+ }
473
+ nodeBounds[node * 6] = minX;
474
+ nodeBounds[node * 6 + 1] = minY;
475
+ nodeBounds[node * 6 + 2] = minZ;
476
+ nodeBounds[node * 6 + 3] = maxX;
477
+ nodeBounds[node * 6 + 4] = maxY;
478
+ nodeBounds[node * 6 + 5] = maxZ;
479
+ if (count <= LEAF_TRIANGLES) {
480
+ nodeStart[node] = start;
481
+ nodeCount[node] = count;
482
+ return node;
483
+ }
484
+ const spanX = maxX - minX;
485
+ const spanY = maxY - minY;
486
+ const spanZ = maxZ - minZ;
487
+ const axis = spanX >= spanY && spanX >= spanZ ? 0 : spanY >= spanZ ? 1 : 2;
488
+ triOrder.subarray(start, start + count).sort((a, b) => centroids[a * 3 + axis] - centroids[b * 3 + axis]);
489
+ const mid = start + (count >> 1);
490
+ nodeCount[node] = 0;
491
+ build(start, mid - start);
492
+ nodeStart[node] = build(mid, start + count - mid);
493
+ return node;
494
+ }
495
+ build(0, triangleCount);
496
+ return {
497
+ nodeBounds: nodeBounds.slice(0, usedNodes * 6),
498
+ nodeStart: nodeStart.slice(0, usedNodes),
499
+ nodeCount: nodeCount.slice(0, usedNodes),
500
+ triOrder,
501
+ };
502
+ }
503
+ const preparedCache = new WeakMap();
504
+ /**
505
+ * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
506
+ * a headless host preparing the same catalog entry share one build. Returns `null` (also memoized)
507
+ * when the payload is malformed.
508
+ */
509
+ export function prepareCollisionMesh(data) {
510
+ const cached = preparedCache.get(data);
511
+ if (cached !== undefined)
512
+ return cached;
513
+ const prepared = decodeAndBuild(data);
514
+ preparedCache.set(data, prepared);
515
+ return prepared;
516
+ }
517
+ function decodeAndBuild(data) {
518
+ const { vertexCount, triangleCount } = data;
519
+ if (!Number.isInteger(vertexCount) || vertexCount < 3)
520
+ return null;
521
+ if (!Number.isInteger(triangleCount) || triangleCount < 1)
522
+ return null;
523
+ const positionBytes = base64ToBytes(data.positions);
524
+ const indexBytes = base64ToBytes(data.indices);
525
+ if (positionBytes === null || indexBytes === null)
526
+ return null;
527
+ if (positionBytes.length !== vertexCount * 6)
528
+ return null;
529
+ const wide = vertexCount > 65536;
530
+ if (indexBytes.length !== triangleCount * 3 * (wide ? 4 : 2))
531
+ return null;
532
+ const minX = data.min[0];
533
+ const minY = data.min[1];
534
+ const minZ = data.min[2];
535
+ const spanX = data.max[0] - minX;
536
+ const spanY = data.max[1] - minY;
537
+ const spanZ = data.max[2] - minZ;
538
+ if (!Number.isFinite(minX) || !Number.isFinite(spanX) || spanX < 0 || spanY < 0 || spanZ < 0)
539
+ return null;
540
+ const positionView = new DataView(positionBytes.buffer, positionBytes.byteOffset, positionBytes.byteLength);
541
+ const positions = new Float32Array(vertexCount * 3);
542
+ for (let v = 0; v < vertexCount; v += 1) {
543
+ positions[v * 3] = minX + (positionView.getUint16(v * 6, true) / 65535) * spanX;
544
+ positions[v * 3 + 1] = minY + (positionView.getUint16(v * 6 + 2, true) / 65535) * spanY;
545
+ positions[v * 3 + 2] = minZ + (positionView.getUint16(v * 6 + 4, true) / 65535) * spanZ;
546
+ }
547
+ const indexView = new DataView(indexBytes.buffer, indexBytes.byteOffset, indexBytes.byteLength);
548
+ const indices = new Uint32Array(triangleCount * 3);
549
+ for (let i = 0; i < indices.length; i += 1) {
550
+ const value = wide ? indexView.getUint32(i * 4, true) : indexView.getUint16(i * 2, true);
551
+ if (value >= vertexCount)
552
+ return null;
553
+ indices[i] = value;
554
+ }
555
+ return { positions, indices, ...buildBvh(positions, indices) };
556
+ }
557
+ // Traversal scratch — module-level so the per-ray hot path allocates nothing. Safe because scene
558
+ // raycasts run on a single thread and traversal never re-enters.
559
+ const traversalStack = new Int32Array(64);
560
+ function nodeIntersects(bounds, node, ox, oy, oz, dx, dy, dz, tLimit) {
561
+ let tMin = 0;
562
+ let tMax = tLimit;
563
+ for (let axis = 0; axis < 3; axis += 1) {
564
+ const origin = axis === 0 ? ox : axis === 1 ? oy : oz;
565
+ const dir = axis === 0 ? dx : axis === 1 ? dy : dz;
566
+ const lo = bounds[node * 6 + axis];
567
+ const hi = bounds[node * 6 + 3 + axis];
568
+ if (Math.abs(dir) < 1e-12) {
569
+ if (origin < lo || origin > hi)
570
+ return false;
571
+ continue;
572
+ }
573
+ const inv = 1 / dir;
574
+ const t1 = (lo - origin) * inv;
575
+ const t2 = (hi - origin) * inv;
576
+ const near = t1 < t2 ? t1 : t2;
577
+ const far = t1 < t2 ? t2 : t1;
578
+ if (near > tMin)
579
+ tMin = near;
580
+ if (far < tMax)
581
+ tMax = far;
582
+ if (tMin > tMax)
583
+ return false;
584
+ }
585
+ return true;
586
+ }
587
+ /**
588
+ * Nearest triangle hit for a world-space ray against a prepared mesh placed like its rendered model:
589
+ * model space scaled uniformly by `scale`, translated by entity-local `translate`, yaw-rotated by
590
+ * `rotationY`, and moved to `position` — the same composition {@link "scene/colliders".ModelBodySource}
591
+ * fitting applies to the AABB. Allocation-free until the returned hit. `distance` is world distance
592
+ * along `direction` (callers pass `direction` normalized); `normal` is the world-space geometric
593
+ * triangle normal facing the ray.
594
+ */
595
+ export function raycastCollisionMesh(mesh, origin, direction, maxDistance, position, rotationY, scale, translate) {
596
+ if (!(scale > 0) || !Number.isFinite(scale))
597
+ return null;
598
+ const cos = Math.cos(rotationY);
599
+ const sin = Math.sin(rotationY);
600
+ // World → entity-local (inverse of colliders.worldOffset's yaw), then local → model space.
601
+ const wx = origin[0] - position[0];
602
+ const wy = origin[1] - position[1];
603
+ const wz = origin[2] - position[2];
604
+ const invScale = 1 / scale;
605
+ const ox = (wx * cos - wz * sin - translate[0]) * invScale;
606
+ const oy = (wy - translate[1]) * invScale;
607
+ const oz = (wx * sin + wz * cos - translate[2]) * invScale;
608
+ // Direction is not renormalized, so the parametric t below is world distance directly.
609
+ const dx = (direction[0] * cos - direction[2] * sin) * invScale;
610
+ const dy = direction[1] * invScale;
611
+ const dz = (direction[0] * sin + direction[2] * cos) * invScale;
612
+ const { positions, indices, nodeBounds, nodeStart, nodeCount, triOrder } = mesh;
613
+ let bestT = maxDistance;
614
+ let bestTriangle = -1;
615
+ let stackSize = 0;
616
+ traversalStack[stackSize] = 0;
617
+ stackSize += 1;
618
+ while (stackSize > 0) {
619
+ stackSize -= 1;
620
+ const node = traversalStack[stackSize];
621
+ if (!nodeIntersects(nodeBounds, node, ox, oy, oz, dx, dy, dz, bestT))
622
+ continue;
623
+ const count = nodeCount[node];
624
+ if (count === 0) {
625
+ traversalStack[stackSize] = nodeStart[node];
626
+ traversalStack[stackSize + 1] = node + 1;
627
+ stackSize += 2;
628
+ continue;
629
+ }
630
+ const start = nodeStart[node];
631
+ for (let i = start; i < start + count; i += 1) {
632
+ const triangle = triOrder[i];
633
+ const a = indices[triangle * 3] * 3;
634
+ const b = indices[triangle * 3 + 1] * 3;
635
+ const c = indices[triangle * 3 + 2] * 3;
636
+ const ax = positions[a];
637
+ const ay = positions[a + 1];
638
+ const az = positions[a + 2];
639
+ const e1x = positions[b] - ax;
640
+ const e1y = positions[b + 1] - ay;
641
+ const e1z = positions[b + 2] - az;
642
+ const e2x = positions[c] - ax;
643
+ const e2y = positions[c + 1] - ay;
644
+ const e2z = positions[c + 2] - az;
645
+ // Möller–Trumbore, double-sided.
646
+ const px = dy * e2z - dz * e2y;
647
+ const py = dz * e2x - dx * e2z;
648
+ const pz = dx * e2y - dy * e2x;
649
+ const det = e1x * px + e1y * py + e1z * pz;
650
+ if (Math.abs(det) < 1e-12)
651
+ continue;
652
+ const invDet = 1 / det;
653
+ const tx = ox - ax;
654
+ const ty = oy - ay;
655
+ const tz = oz - az;
656
+ const u = (tx * px + ty * py + tz * pz) * invDet;
657
+ if (u < 0 || u > 1)
658
+ continue;
659
+ const qx = ty * e1z - tz * e1y;
660
+ const qy = tz * e1x - tx * e1z;
661
+ const qz = tx * e1y - ty * e1x;
662
+ const v = (dx * qx + dy * qy + dz * qz) * invDet;
663
+ if (v < 0 || u + v > 1)
664
+ continue;
665
+ const t = (e2x * qx + e2y * qy + e2z * qz) * invDet;
666
+ if (t > 1e-6 && t < bestT) {
667
+ bestT = t;
668
+ bestTriangle = triangle;
669
+ }
670
+ }
671
+ }
672
+ if (bestTriangle < 0)
673
+ return null;
674
+ const a = indices[bestTriangle * 3] * 3;
675
+ const b = indices[bestTriangle * 3 + 1] * 3;
676
+ const c = indices[bestTriangle * 3 + 2] * 3;
677
+ const e1x = positions[b] - positions[a];
678
+ const e1y = positions[b + 1] - positions[a + 1];
679
+ const e1z = positions[b + 2] - positions[a + 2];
680
+ const e2x = positions[c] - positions[a];
681
+ const e2y = positions[c + 1] - positions[a + 1];
682
+ const e2z = positions[c + 2] - positions[a + 2];
683
+ let nx = e1y * e2z - e1z * e2y;
684
+ let ny = e1z * e2x - e1x * e2z;
685
+ let nz = e1x * e2y - e1y * e2x;
686
+ const length = Math.hypot(nx, ny, nz) || 1;
687
+ nx /= length;
688
+ ny /= length;
689
+ nz /= length;
690
+ if (nx * dx + ny * dy + nz * dz > 0) {
691
+ nx = -nx;
692
+ ny = -ny;
693
+ nz = -nz;
694
+ }
695
+ return {
696
+ distance: bestT,
697
+ normal: [nx * cos + nz * sin, ny, -nx * sin + nz * cos],
698
+ };
699
+ }