@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.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.
- package/README.md +53 -12
- package/dist/authority-room.d.ts +11 -0
- package/dist/authority-room.js +43 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +5 -0
- package/dist/checksum-basis.d.ts +4 -0
- package/dist/checksum-basis.js +17 -0
- package/dist/cjs/authority-room.js +43 -0
- package/dist/cjs/browser.js +18 -2
- package/dist/cjs/checksum-basis.js +22 -0
- package/dist/cjs/collider-cooking.js +834 -0
- package/dist/cjs/deterministic-session.js +2 -0
- package/dist/cjs/engine-complete.js +1 -4
- package/dist/cjs/engine-parity-matrix.js +41 -9
- package/dist/cjs/engine-parity.js +2 -2
- package/dist/cjs/index.js +24 -2
- package/dist/cjs/input-authority.js +34 -2
- package/dist/cjs/instant-replay.js +4 -9
- package/dist/cjs/local-authority-server-runner.js +3 -1
- package/dist/cjs/local-authority-wire.js +20 -1
- package/dist/cjs/match-log.js +27 -0
- package/dist/cjs/movement3d.js +17 -1
- package/dist/cjs/networked-client.js +70 -20
- package/dist/cjs/node.js +23 -1
- package/dist/cjs/offline-session.js +52 -24
- package/dist/cjs/physics-cert.js +8 -3
- package/dist/cjs/physics3d-heightfield.js +51 -0
- package/dist/cjs/physics3d-hull.js +381 -0
- package/dist/cjs/physics3d-joints.js +255 -49
- package/dist/cjs/physics3d-mass.js +131 -0
- package/dist/cjs/physics3d-recipes.js +197 -0
- package/dist/cjs/physics3d-shared.js +11 -1
- package/dist/cjs/physics3d-solver.js +845 -98
- package/dist/cjs/physics3d-stacking.js +154 -0
- package/dist/cjs/physics3d-toi.js +274 -0
- package/dist/cjs/physics3d-vehicle.js +35 -10
- package/dist/cjs/physics3d.js +601 -38
- package/dist/cjs/replay.js +25 -1
- package/dist/cjs/runtime-support.js +71 -0
- package/dist/cjs/sdk-offline.js +9 -1
- package/dist/cjs/sdk-session.js +8 -0
- package/dist/cjs/secret-authority.js +647 -0
- package/dist/cjs/secret-client.js +615 -0
- package/dist/cjs/secret-command-classification.js +8 -0
- package/dist/cjs/secret-config.js +211 -0
- package/dist/cjs/secret-crypto.js +208 -0
- package/dist/cjs/secret-protocol.js +238 -0
- package/dist/cjs/session-build.js +7 -4
- package/dist/cjs/session-promotion.js +5 -5
- package/dist/cjs/synctest.js +49 -0
- package/dist/cjs/testing.js +4 -1
- package/dist/cjs/tools/static-checker.js +61 -13
- package/dist/cli.js +193 -48
- package/dist/collider-cooking.d.ts +141 -0
- package/dist/collider-cooking.js +829 -0
- package/dist/deterministic-primitives.d.ts +1 -0
- package/dist/deterministic-session.js +2 -0
- package/dist/engine-complete.js +1 -4
- package/dist/engine-parity-matrix.js +41 -9
- package/dist/engine-parity.js +2 -2
- package/dist/index.d.ts +12 -1
- package/dist/index.js +6 -0
- package/dist/input-authority.d.ts +3 -0
- package/dist/input-authority.js +35 -3
- package/dist/instant-replay.d.ts +2 -2
- package/dist/instant-replay.js +4 -9
- package/dist/local-authority-server-runner.d.ts +1 -0
- package/dist/local-authority-server-runner.js +2 -1
- package/dist/local-authority-wire.js +20 -1
- package/dist/match-log.d.ts +4 -1
- package/dist/match-log.js +27 -0
- package/dist/movement3d.js +18 -2
- package/dist/networked-client.d.ts +8 -0
- package/dist/networked-client.js +70 -20
- package/dist/node.d.ts +7 -0
- package/dist/node.js +6 -0
- package/dist/offline-session.js +53 -25
- package/dist/physics-cert.js +8 -3
- package/dist/physics3d-heightfield.d.ts +17 -0
- package/dist/physics3d-heightfield.js +47 -0
- package/dist/physics3d-hull.d.ts +53 -0
- package/dist/physics3d-hull.js +376 -0
- package/dist/physics3d-joints.d.ts +13 -1
- package/dist/physics3d-joints.js +256 -50
- package/dist/physics3d-mass.d.ts +11 -0
- package/dist/physics3d-mass.js +126 -0
- package/dist/physics3d-recipes.d.ts +29 -0
- package/dist/physics3d-recipes.js +193 -0
- package/dist/physics3d-shared.d.ts +1 -0
- package/dist/physics3d-shared.js +10 -1
- package/dist/physics3d-solver.d.ts +78 -2
- package/dist/physics3d-solver.js +842 -98
- package/dist/physics3d-stacking.d.ts +26 -0
- package/dist/physics3d-stacking.js +147 -0
- package/dist/physics3d-toi.d.ts +79 -0
- package/dist/physics3d-toi.js +267 -0
- package/dist/physics3d-vehicle.js +35 -10
- package/dist/physics3d.d.ts +28 -3
- package/dist/physics3d.js +601 -40
- package/dist/replay.d.ts +5 -0
- package/dist/replay.js +25 -1
- package/dist/reports.d.ts +6 -1
- package/dist/runtime-support.d.ts +60 -0
- package/dist/runtime-support.js +66 -0
- package/dist/sdk-offline.d.ts +4 -0
- package/dist/sdk-offline.js +9 -1
- package/dist/sdk-session.d.ts +4 -0
- package/dist/sdk-session.js +8 -0
- package/dist/secret-authority.d.ts +90 -0
- package/dist/secret-authority.js +643 -0
- package/dist/secret-client.d.ts +89 -0
- package/dist/secret-client.js +611 -0
- package/dist/secret-command-classification.d.ts +1 -0
- package/dist/secret-command-classification.js +5 -0
- package/dist/secret-config.d.ts +45 -0
- package/dist/secret-config.js +206 -0
- package/dist/secret-crypto.d.ts +29 -0
- package/dist/secret-crypto.js +190 -0
- package/dist/secret-protocol.d.ts +181 -0
- package/dist/secret-protocol.js +230 -0
- package/dist/session-build.d.ts +16 -0
- package/dist/session-build.js +7 -4
- package/dist/session-promotion.js +5 -5
- package/dist/synctest.d.ts +1 -1
- package/dist/synctest.js +49 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/dist/tools/static-checker.d.ts +11 -0
- package/dist/tools/static-checker.js +60 -13
- package/dist/types.d.ts +21 -2
- package/package.json +8 -3
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.__colliderCookingInternals = void 0;
|
|
4
|
+
exports.cookConvexDecomposition = cookConvexDecomposition;
|
|
5
|
+
exports.assertCookedColliderIsRuntimeValid = assertCookedColliderIsRuntimeValid;
|
|
6
|
+
const canonical_js_1 = require("./canonical.js");
|
|
7
|
+
const physics3d_hull_js_1 = require("./physics3d-hull.js");
|
|
8
|
+
/**
|
|
9
|
+
* Offline convex decomposition (V-HACD shaped): turn a concave triangle-soup
|
|
10
|
+
* collider into a `compound` of convex `hull` children the deterministic runtime
|
|
11
|
+
* can collide.
|
|
12
|
+
*
|
|
13
|
+
* This module is BUILD-TIME ONLY. It never runs inside a simulation step, so it
|
|
14
|
+
* is free to be expensive; what ships is its output — a fixed set of hull
|
|
15
|
+
* vertices — and *that* is what the runtime must reproduce bit-for-bit. The
|
|
16
|
+
* cook is nonetheless made reproducible on purpose: the source is canonicalised
|
|
17
|
+
* before any work happens, every interior computation runs on an integer voxel
|
|
18
|
+
* lattice (so the convex-hull orientation predicate is exact, never an epsilon
|
|
19
|
+
* vote), and every tie-break is lexicographic. Cooking the same mesh twice — or
|
|
20
|
+
* a permutation of its triangles — yields byte-identical output.
|
|
21
|
+
*
|
|
22
|
+
* Pipeline: canonicalise → voxelize (surface rasterisation + exterior flood
|
|
23
|
+
* fill) → recursively split the voxel set on the axis-aligned plane that best
|
|
24
|
+
* reduces concavity, worst part first, until every part is convex enough or the
|
|
25
|
+
* hull budget is spent → hull each part, reduce it to the runtime's vertex cap
|
|
26
|
+
* while inflating it to still contain the part, and quantize.
|
|
27
|
+
*/
|
|
28
|
+
/** Runtime hard cap: `buildSolverHull` accepts 4..16 vertices. */
|
|
29
|
+
const RUNTIME_MAX_HULL_VERTICES = 16;
|
|
30
|
+
const RUNTIME_MIN_HULL_VERTICES = 4;
|
|
31
|
+
/**
|
|
32
|
+
* Lowest cap a cook can honour: the voxel-AABB fallback is an 8-corner box, so
|
|
33
|
+
* asking for fewer would make a fallback unsatisfiable.
|
|
34
|
+
*/
|
|
35
|
+
const MIN_COOKED_HULL_VERTICES = 8;
|
|
36
|
+
const DEFAULTS = {
|
|
37
|
+
resolution: 24,
|
|
38
|
+
maxHulls: 8,
|
|
39
|
+
maxHullVertices: RUNTIME_MAX_HULL_VERTICES,
|
|
40
|
+
concavityTolerance: 0.05,
|
|
41
|
+
quantum: 1e-4,
|
|
42
|
+
splitCandidates: 7,
|
|
43
|
+
};
|
|
44
|
+
// ---------- entry point ----------
|
|
45
|
+
function cookConvexDecomposition(source, options = {}) {
|
|
46
|
+
const resolution = requireIntegerInRange(options.resolution ?? DEFAULTS.resolution, 4, 96, 'resolution');
|
|
47
|
+
const maxHulls = requireIntegerInRange(options.maxHulls ?? DEFAULTS.maxHulls, 1, 64, 'maxHulls');
|
|
48
|
+
const maxHullVertices = requireIntegerInRange(options.maxHullVertices ?? DEFAULTS.maxHullVertices, MIN_COOKED_HULL_VERTICES, RUNTIME_MAX_HULL_VERTICES, 'maxHullVertices');
|
|
49
|
+
const splitCandidates = requireIntegerInRange(options.splitCandidates ?? DEFAULTS.splitCandidates, 1, 32, 'splitCandidates');
|
|
50
|
+
const concavityTolerance = options.concavityTolerance ?? DEFAULTS.concavityTolerance;
|
|
51
|
+
if (!(concavityTolerance >= 0 && concavityTolerance < 1)) {
|
|
52
|
+
throw new Error('cookConvexDecomposition: concavityTolerance must be in [0, 1)');
|
|
53
|
+
}
|
|
54
|
+
const quantum = options.quantum ?? DEFAULTS.quantum;
|
|
55
|
+
if (!(quantum > 0)) {
|
|
56
|
+
throw new Error('cookConvexDecomposition: quantum must be positive');
|
|
57
|
+
}
|
|
58
|
+
const triangles = canonicalTriangles(source.vertices);
|
|
59
|
+
const lattice = voxelize(triangles, resolution);
|
|
60
|
+
if (quantum > lattice.voxelSize / 8) {
|
|
61
|
+
throw new Error(`cookConvexDecomposition: quantum ${quantum} is too coarse for voxel size ${lattice.voxelSize} `
|
|
62
|
+
+ '— it would collapse hull faces; use a finer quantum or a lower resolution');
|
|
63
|
+
}
|
|
64
|
+
const parts = decompose(lattice, maxHulls, concavityTolerance, splitCandidates);
|
|
65
|
+
const children = [];
|
|
66
|
+
const hulls = [];
|
|
67
|
+
let totalVolume = 0;
|
|
68
|
+
let weightedConcavity = 0;
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
const vertices = finalizeHull(part, lattice, maxHullVertices, quantum);
|
|
71
|
+
const centroid = quantizePoint(meanPoint(vertices), quantum);
|
|
72
|
+
children.push({
|
|
73
|
+
shape: { type: 'hull', vertices: vertices.map((v) => quantizePoint(subtract(v, centroid), quantum)) },
|
|
74
|
+
offset: centroid,
|
|
75
|
+
});
|
|
76
|
+
hulls.push({ vertexCount: vertices.length, concavity: part.concavity });
|
|
77
|
+
totalVolume += part.voxels.length;
|
|
78
|
+
weightedConcavity += part.concavity * part.voxels.length;
|
|
79
|
+
}
|
|
80
|
+
const shape = { type: 'compound', children };
|
|
81
|
+
return {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
kind: 'deterministic-convex-decomposition',
|
|
84
|
+
shape,
|
|
85
|
+
hulls,
|
|
86
|
+
sourceTriangles: triangles.length,
|
|
87
|
+
resolution,
|
|
88
|
+
/* istanbul ignore next -- voxelize throws on an empty volume, so `parts` always covers at least one voxel. */
|
|
89
|
+
concavity: totalVolume === 0 ? 0 : weightedConcavity / totalVolume,
|
|
90
|
+
hash: (0, canonical_js_1.defaultChecksum)(shape),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Re-run every runtime guard the cooked collider will meet at world creation,
|
|
95
|
+
* so a bad cook fails in the asset pipeline rather than in a game.
|
|
96
|
+
*/
|
|
97
|
+
function assertCookedColliderIsRuntimeValid(cooked) {
|
|
98
|
+
if (cooked.shape.children.length === 0) {
|
|
99
|
+
throw new Error('cooked collider: compound must have at least one child');
|
|
100
|
+
}
|
|
101
|
+
cooked.shape.children.forEach((child, index) => {
|
|
102
|
+
if (child.shape.type !== 'hull') {
|
|
103
|
+
throw new Error(`cooked collider: child ${index} must be a hull`);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
(0, physics3d_hull_js_1.buildSolverHull)(child.shape.vertices);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throw new Error(`cooked collider: child ${index} is not a valid runtime hull — ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (cooked.hash !== (0, canonical_js_1.defaultChecksum)(cooked.shape)) {
|
|
113
|
+
throw new Error('cooked collider: hash does not match its shape');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ---------- canonicalisation ----------
|
|
117
|
+
function canonicalTriangles(vertices) {
|
|
118
|
+
if (vertices.length < 3 || vertices.length % 3 !== 0) {
|
|
119
|
+
throw new Error('cookConvexDecomposition: source requires triangle-list vertices (a positive multiple of 3)');
|
|
120
|
+
}
|
|
121
|
+
for (const vertex of vertices) {
|
|
122
|
+
if (!Number.isFinite(vertex.x) || !Number.isFinite(vertex.y) || !Number.isFinite(vertex.z)) {
|
|
123
|
+
throw new Error('cookConvexDecomposition: source contains a non-finite vertex');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const triangles = [];
|
|
127
|
+
for (let index = 0; index + 2 < vertices.length; index += 3) {
|
|
128
|
+
triangles.push([vertices[index], vertices[index + 1], vertices[index + 2]]);
|
|
129
|
+
}
|
|
130
|
+
// Winding is irrelevant to voxelisation, so a triangle's canonical key is its
|
|
131
|
+
// lexicographically sorted vertex triple. Sorting by it makes the whole cook
|
|
132
|
+
// invariant to the authoring order of the source soup.
|
|
133
|
+
return [...triangles].sort((left, right) => comparePointLists(sortPoints(left), sortPoints(right)));
|
|
134
|
+
}
|
|
135
|
+
function sortPoints(triangle) {
|
|
136
|
+
return [...triangle].sort(comparePoints);
|
|
137
|
+
}
|
|
138
|
+
function comparePoints(left, right) {
|
|
139
|
+
return left.x - right.x || left.y - right.y || left.z - right.z;
|
|
140
|
+
}
|
|
141
|
+
function comparePointLists(left, right) {
|
|
142
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
143
|
+
const order = comparePoints(left[index], right[index]);
|
|
144
|
+
if (order !== 0) {
|
|
145
|
+
return order;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
// ---------- voxelisation ----------
|
|
151
|
+
function voxelize(triangles, resolution) {
|
|
152
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
153
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
154
|
+
for (const triangle of triangles) {
|
|
155
|
+
for (const vertex of triangle) {
|
|
156
|
+
minX = Math.min(minX, vertex.x);
|
|
157
|
+
maxX = Math.max(maxX, vertex.x);
|
|
158
|
+
minY = Math.min(minY, vertex.y);
|
|
159
|
+
maxY = Math.max(maxY, vertex.y);
|
|
160
|
+
minZ = Math.min(minZ, vertex.z);
|
|
161
|
+
maxZ = Math.max(maxZ, vertex.z);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
|
|
165
|
+
if (!(extent > 0)) {
|
|
166
|
+
throw new Error('cookConvexDecomposition: source mesh is degenerate (zero extent)');
|
|
167
|
+
}
|
|
168
|
+
const voxelSize = extent / resolution;
|
|
169
|
+
// One voxel of padding on every side gives the exterior flood fill a
|
|
170
|
+
// guaranteed connected shell to start from.
|
|
171
|
+
const originX = minX - voxelSize;
|
|
172
|
+
const originY = minY - voxelSize;
|
|
173
|
+
const originZ = minZ - voxelSize;
|
|
174
|
+
const nx = Math.max(1, Math.ceil((maxX - minX) / voxelSize)) + 2;
|
|
175
|
+
const ny = Math.max(1, Math.ceil((maxY - minY) / voxelSize)) + 2;
|
|
176
|
+
const nz = Math.max(1, Math.ceil((maxZ - minZ) / voxelSize)) + 2;
|
|
177
|
+
const SURFACE = 1;
|
|
178
|
+
const EXTERIOR = 2;
|
|
179
|
+
const cells = new Uint8Array(nx * ny * nz);
|
|
180
|
+
const at = (x, y, z) => (z * ny + y) * nx + x;
|
|
181
|
+
const half = voxelSize / 2;
|
|
182
|
+
for (const triangle of triangles) {
|
|
183
|
+
const lo = [
|
|
184
|
+
Math.min(triangle[0].x, triangle[1].x, triangle[2].x),
|
|
185
|
+
Math.min(triangle[0].y, triangle[1].y, triangle[2].y),
|
|
186
|
+
Math.min(triangle[0].z, triangle[1].z, triangle[2].z),
|
|
187
|
+
];
|
|
188
|
+
const hi = [
|
|
189
|
+
Math.max(triangle[0].x, triangle[1].x, triangle[2].x),
|
|
190
|
+
Math.max(triangle[0].y, triangle[1].y, triangle[2].y),
|
|
191
|
+
Math.max(triangle[0].z, triangle[1].z, triangle[2].z),
|
|
192
|
+
];
|
|
193
|
+
const x0 = clampIndex(Math.floor((lo[0] - originX) / voxelSize), nx);
|
|
194
|
+
const x1 = clampIndex(Math.floor((hi[0] - originX) / voxelSize), nx);
|
|
195
|
+
const y0 = clampIndex(Math.floor((lo[1] - originY) / voxelSize), ny);
|
|
196
|
+
const y1 = clampIndex(Math.floor((hi[1] - originY) / voxelSize), ny);
|
|
197
|
+
const z0 = clampIndex(Math.floor((lo[2] - originZ) / voxelSize), nz);
|
|
198
|
+
const z1 = clampIndex(Math.floor((hi[2] - originZ) / voxelSize), nz);
|
|
199
|
+
for (let z = z0; z <= z1; z += 1) {
|
|
200
|
+
for (let y = y0; y <= y1; y += 1) {
|
|
201
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
202
|
+
const center = {
|
|
203
|
+
x: originX + (x + 0.5) * voxelSize,
|
|
204
|
+
y: originY + (y + 0.5) * voxelSize,
|
|
205
|
+
z: originZ + (z + 0.5) * voxelSize,
|
|
206
|
+
};
|
|
207
|
+
if (triangleIntersectsBox(triangle, center, half)) {
|
|
208
|
+
cells[at(x, y, z)] = SURFACE;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Flood fill the exterior from the padded shell; everything left that is not
|
|
215
|
+
// surface is enclosed interior. Non-manifold or open meshes simply leak, and
|
|
216
|
+
// then the decomposition covers only the surface shell — which is still a
|
|
217
|
+
// sound (if hollow) collider, not a crash.
|
|
218
|
+
const stack = [];
|
|
219
|
+
const pushIfOpen = (x, y, z) => {
|
|
220
|
+
const index = at(x, y, z);
|
|
221
|
+
if (cells[index] === 0) {
|
|
222
|
+
cells[index] = EXTERIOR;
|
|
223
|
+
stack.push(index);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
for (let z = 0; z < nz; z += 1) {
|
|
227
|
+
for (let y = 0; y < ny; y += 1) {
|
|
228
|
+
for (let x = 0; x < nx; x += 1) {
|
|
229
|
+
if (x === 0 || y === 0 || z === 0 || x === nx - 1 || y === ny - 1 || z === nz - 1) {
|
|
230
|
+
pushIfOpen(x, y, z);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
while (stack.length > 0) {
|
|
236
|
+
const index = stack.pop();
|
|
237
|
+
const x = index % nx;
|
|
238
|
+
const y = Math.floor(index / nx) % ny;
|
|
239
|
+
const z = Math.floor(index / (nx * ny));
|
|
240
|
+
if (x > 0)
|
|
241
|
+
pushIfOpen(x - 1, y, z);
|
|
242
|
+
if (x < nx - 1)
|
|
243
|
+
pushIfOpen(x + 1, y, z);
|
|
244
|
+
if (y > 0)
|
|
245
|
+
pushIfOpen(x, y - 1, z);
|
|
246
|
+
if (y < ny - 1)
|
|
247
|
+
pushIfOpen(x, y + 1, z);
|
|
248
|
+
if (z > 0)
|
|
249
|
+
pushIfOpen(x, y, z - 1);
|
|
250
|
+
if (z < nz - 1)
|
|
251
|
+
pushIfOpen(x, y, z + 1);
|
|
252
|
+
}
|
|
253
|
+
const occupied = [];
|
|
254
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
255
|
+
if (cells[index] !== EXTERIOR) {
|
|
256
|
+
occupied.push(index);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/* istanbul ignore next -- a non-degenerate triangle always rasterises at least one surface voxel. */
|
|
260
|
+
if (occupied.length === 0) {
|
|
261
|
+
throw new Error('cookConvexDecomposition: source mesh voxelized to an empty volume');
|
|
262
|
+
}
|
|
263
|
+
return { nx, ny, nz, voxelSize, originX, originY, originZ, occupied };
|
|
264
|
+
}
|
|
265
|
+
function clampIndex(value, count) {
|
|
266
|
+
return Math.min(Math.max(value, 0), count - 1);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Akenine-Möller triangle/AABB overlap, box centred at `center` with half-width
|
|
270
|
+
* `half`, biased outward by `tol`.
|
|
271
|
+
*
|
|
272
|
+
* The bias is load-bearing, not a fudge. Axis-aligned source faces land exactly
|
|
273
|
+
* on voxel boundaries whenever the voxel size is not a binary fraction (`1/24`
|
|
274
|
+
* is not), so the overlap test is decided at exact tangency and rounding rejects
|
|
275
|
+
* roughly half the cells. That punches holes in the surface shell, the exterior
|
|
276
|
+
* flood fill leaks through them, and the mesh cooks hollow. A conservative
|
|
277
|
+
* rasteriser must resolve tangency as "overlapping".
|
|
278
|
+
*/
|
|
279
|
+
function triangleIntersectsBox(triangle, center, half) {
|
|
280
|
+
const tol = half * 1e-6;
|
|
281
|
+
const v0 = [triangle[0].x - center.x, triangle[0].y - center.y, triangle[0].z - center.z];
|
|
282
|
+
const v1 = [triangle[1].x - center.x, triangle[1].y - center.y, triangle[1].z - center.z];
|
|
283
|
+
const v2 = [triangle[2].x - center.x, triangle[2].y - center.y, triangle[2].z - center.z];
|
|
284
|
+
const edges = [
|
|
285
|
+
[v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]],
|
|
286
|
+
[v2[0] - v1[0], v2[1] - v1[1], v2[2] - v1[2]],
|
|
287
|
+
[v0[0] - v2[0], v0[1] - v2[1], v0[2] - v2[2]],
|
|
288
|
+
];
|
|
289
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
290
|
+
for (const edge of edges) {
|
|
291
|
+
// cross(unitAxis[axis], edge)
|
|
292
|
+
const a = [0, 0, 0];
|
|
293
|
+
a[(axis + 1) % 3] = -edge[(axis + 2) % 3];
|
|
294
|
+
a[(axis + 2) % 3] = edge[(axis + 1) % 3];
|
|
295
|
+
const p0 = a[0] * v0[0] + a[1] * v0[1] + a[2] * v0[2];
|
|
296
|
+
const p1 = a[0] * v1[0] + a[1] * v1[1] + a[2] * v1[2];
|
|
297
|
+
const p2 = a[0] * v2[0] + a[1] * v2[1] + a[2] * v2[2];
|
|
298
|
+
const radius = half * (Math.abs(a[0]) + Math.abs(a[1]) + Math.abs(a[2])) + tol;
|
|
299
|
+
if (Math.min(p0, p1, p2) > radius || Math.max(p0, p1, p2) < -radius) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
305
|
+
const lo = Math.min(v0[axis], v1[axis], v2[axis]);
|
|
306
|
+
const hi = Math.max(v0[axis], v1[axis], v2[axis]);
|
|
307
|
+
if (lo > half + tol || hi < -half - tol) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const normal = [
|
|
312
|
+
edges[0][1] * edges[1][2] - edges[0][2] * edges[1][1],
|
|
313
|
+
edges[0][2] * edges[1][0] - edges[0][0] * edges[1][2],
|
|
314
|
+
edges[0][0] * edges[1][1] - edges[0][1] * edges[1][0],
|
|
315
|
+
];
|
|
316
|
+
const offset = normal[0] * v0[0] + normal[1] * v0[1] + normal[2] * v0[2];
|
|
317
|
+
const radius = half * (Math.abs(normal[0]) + Math.abs(normal[1]) + Math.abs(normal[2])) + tol;
|
|
318
|
+
return Math.abs(offset) <= radius;
|
|
319
|
+
}
|
|
320
|
+
// ---------- decomposition ----------
|
|
321
|
+
function decompose(lattice, maxHulls, tolerance, splitCandidates) {
|
|
322
|
+
let parts = [makePart(lattice.occupied, lattice)];
|
|
323
|
+
while (parts.length < maxHulls) {
|
|
324
|
+
// Split the part that contributes the most absolute volume error. Ties fall
|
|
325
|
+
// to the lowest index, which is stable because parts stay sorted below.
|
|
326
|
+
let worst = -1;
|
|
327
|
+
let worstScore = 0;
|
|
328
|
+
parts.forEach((part, index) => {
|
|
329
|
+
const score = part.concavity * part.voxels.length;
|
|
330
|
+
if (part.concavity > tolerance && score > worstScore) {
|
|
331
|
+
worst = index;
|
|
332
|
+
worstScore = score;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
if (worst < 0) {
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
const split = bestSplit(parts[worst], lattice, splitCandidates);
|
|
339
|
+
/* istanbul ignore next -- only a single-voxel part is unsplittable, and its concavity is 0, so it is never selected as `worst` above. The guard stays because the selection rule and the split rule are separate. */
|
|
340
|
+
if (split === undefined) {
|
|
341
|
+
// Unsplittable (single voxel slab); accept it as a leaf by retiring it
|
|
342
|
+
// from consideration via an exact-convex marker.
|
|
343
|
+
parts[worst] = { ...parts[worst], concavity: 0 };
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
parts = [...parts.slice(0, worst), ...split, ...parts.slice(worst + 1)];
|
|
347
|
+
}
|
|
348
|
+
return parts;
|
|
349
|
+
}
|
|
350
|
+
function makePart(voxels, lattice) {
|
|
351
|
+
const hull = latticeHullOfVoxels(voxels, lattice);
|
|
352
|
+
/* istanbul ignore next -- a part always holds at least one voxel, whose eight corners span a unit-volume hull. */
|
|
353
|
+
const concavity = hull.volume <= 0 ? 0 : Math.max(0, 1 - voxels.length / hull.volume);
|
|
354
|
+
return { voxels, hull, concavity };
|
|
355
|
+
}
|
|
356
|
+
function bestSplit(part, lattice, splitCandidates) {
|
|
357
|
+
const coords = part.voxels.map((index) => voxelCoord(index, lattice));
|
|
358
|
+
let best;
|
|
359
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
360
|
+
let lo = Infinity;
|
|
361
|
+
let hi = -Infinity;
|
|
362
|
+
for (const coord of coords) {
|
|
363
|
+
lo = Math.min(lo, coord[axis]);
|
|
364
|
+
hi = Math.max(hi, coord[axis]);
|
|
365
|
+
}
|
|
366
|
+
if (hi <= lo) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
for (let candidate = 1; candidate <= splitCandidates; candidate += 1) {
|
|
370
|
+
const plane = lo + Math.floor(((hi - lo + 1) * candidate) / (splitCandidates + 1));
|
|
371
|
+
if (plane <= lo || plane > hi) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const left = [];
|
|
375
|
+
const right = [];
|
|
376
|
+
part.voxels.forEach((index, ordinal) => {
|
|
377
|
+
(coords[ordinal][axis] < plane ? left : right).push(index);
|
|
378
|
+
});
|
|
379
|
+
/* istanbul ignore next -- `plane` is clamped to (lo, hi], so the voxel at `lo` always lands left and the one at `hi` always lands right. */
|
|
380
|
+
if (left.length === 0 || right.length === 0) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const leftPart = makePart(left, lattice);
|
|
384
|
+
const rightPart = makePart(right, lattice);
|
|
385
|
+
// Volume-weighted concavity of the pair; lower is a cleaner cut.
|
|
386
|
+
const score = leftPart.concavity * left.length + rightPart.concavity * right.length;
|
|
387
|
+
if (best === undefined || score < best.score) {
|
|
388
|
+
best = { score, parts: [leftPart, rightPart] };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return best?.parts;
|
|
393
|
+
}
|
|
394
|
+
function voxelCoord(index, lattice) {
|
|
395
|
+
const x = index % lattice.nx;
|
|
396
|
+
const y = Math.floor(index / lattice.nx) % lattice.ny;
|
|
397
|
+
const z = Math.floor(index / (lattice.nx * lattice.ny));
|
|
398
|
+
return [x, y, z];
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Corner-lattice points that can possibly be convex-hull vertices. A corner with
|
|
402
|
+
* occupied corners both below and above it in its own column is a convex
|
|
403
|
+
* combination of them, so it can never be a hull vertex — keeping only each
|
|
404
|
+
* column's extremes is exact, not an approximation, and cuts the hull input from
|
|
405
|
+
* O(n³) to O(n²).
|
|
406
|
+
*/
|
|
407
|
+
function hullCandidateCorners(voxels, lattice) {
|
|
408
|
+
const corners = [];
|
|
409
|
+
for (const index of voxels) {
|
|
410
|
+
const [vx, vy, vz] = voxelCoord(index, lattice);
|
|
411
|
+
for (let dx = 0; dx <= 1; dx += 1) {
|
|
412
|
+
for (let dy = 0; dy <= 1; dy += 1) {
|
|
413
|
+
for (let dz = 0; dz <= 1; dz += 1) {
|
|
414
|
+
corners.push([vx + dx, vy + dy, vz + dz]);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Each axis pass is individually exact, so composing all three is too.
|
|
420
|
+
return columnExtremes(columnExtremes(columnExtremes(corners, 2), 1), 0);
|
|
421
|
+
}
|
|
422
|
+
/** Keep only each column's two extremes along `axis` (see hullCandidateCorners). */
|
|
423
|
+
function columnExtremes(points, axis) {
|
|
424
|
+
const u = (axis + 1) % 3;
|
|
425
|
+
const v = (axis + 2) % 3;
|
|
426
|
+
const columns = new Map();
|
|
427
|
+
for (const point of points) {
|
|
428
|
+
const key = `${point[u]}:${point[v]}`;
|
|
429
|
+
const column = columns.get(key);
|
|
430
|
+
if (column === undefined) {
|
|
431
|
+
columns.set(key, { min: point[axis], max: point[axis] });
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
column.min = Math.min(column.min, point[axis]);
|
|
435
|
+
column.max = Math.max(column.max, point[axis]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const kept = [];
|
|
439
|
+
for (const point of points) {
|
|
440
|
+
const column = columns.get(`${point[u]}:${point[v]}`);
|
|
441
|
+
if (point[axis] === column.min || point[axis] === column.max) {
|
|
442
|
+
kept.push(point);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return dedupeIVecs(kept);
|
|
446
|
+
}
|
|
447
|
+
function latticeHullOfVoxels(voxels, lattice) {
|
|
448
|
+
return convexHullLattice(hullCandidateCorners(voxels, lattice));
|
|
449
|
+
}
|
|
450
|
+
function orient3d(a, b, c, d) {
|
|
451
|
+
// Corner-lattice coordinates are small integers, so this determinant is exact
|
|
452
|
+
// in a double — the hull has no epsilon anywhere.
|
|
453
|
+
const ax = a[0] - d[0], ay = a[1] - d[1], az = a[2] - d[2];
|
|
454
|
+
const bx = b[0] - d[0], by = b[1] - d[1], bz = b[2] - d[2];
|
|
455
|
+
const cx = c[0] - d[0], cy = c[1] - d[1], cz = c[2] - d[2];
|
|
456
|
+
return ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
|
457
|
+
}
|
|
458
|
+
function latticeCentroid(vertices) {
|
|
459
|
+
let x = 0, y = 0, z = 0;
|
|
460
|
+
for (const vertex of vertices) {
|
|
461
|
+
x += vertex[0];
|
|
462
|
+
y += vertex[1];
|
|
463
|
+
z += vertex[2];
|
|
464
|
+
}
|
|
465
|
+
return [x / vertices.length, y / vertices.length, z / vertices.length];
|
|
466
|
+
}
|
|
467
|
+
/** orient3d against a non-lattice apex; used only to rank removal candidates. */
|
|
468
|
+
function orient3dFloat(a, b, c, d) {
|
|
469
|
+
const ax = a[0] - d[0], ay = a[1] - d[1], az = a[2] - d[2];
|
|
470
|
+
const bx = b[0] - d[0], by = b[1] - d[1], bz = b[2] - d[2];
|
|
471
|
+
const cx = c[0] - d[0], cy = c[1] - d[1], cz = c[2] - d[2];
|
|
472
|
+
return ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx);
|
|
473
|
+
}
|
|
474
|
+
function compareIVec(left, right) {
|
|
475
|
+
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Incremental convex hull over integer points. Degenerate inputs (collinear or
|
|
479
|
+
* coplanar) return an empty face set with zero volume; callers treat that as a
|
|
480
|
+
* flat part and fall back to its voxel AABB.
|
|
481
|
+
*/
|
|
482
|
+
function convexHullLattice(points) {
|
|
483
|
+
const unique = dedupeIVecs(points);
|
|
484
|
+
if (unique.length < 4) {
|
|
485
|
+
return { vertices: unique, faces: [], volume: 0 };
|
|
486
|
+
}
|
|
487
|
+
const seed = seedTetrahedron(unique);
|
|
488
|
+
if (seed === undefined) {
|
|
489
|
+
return { vertices: unique, faces: [], volume: 0 };
|
|
490
|
+
}
|
|
491
|
+
const faces = [];
|
|
492
|
+
const [i0, i1, i2, i3] = seed;
|
|
493
|
+
// Orient every face so an interior point scores positive; a point strictly
|
|
494
|
+
// outside a face then scores negative, which is the visibility test below.
|
|
495
|
+
const addSeedFace = (a, b, c, opposite) => {
|
|
496
|
+
faces.push(orient3d(unique[a], unique[b], unique[c], unique[opposite]) > 0 ? [a, b, c] : [a, c, b]);
|
|
497
|
+
};
|
|
498
|
+
addSeedFace(i0, i1, i2, i3);
|
|
499
|
+
addSeedFace(i0, i1, i3, i2);
|
|
500
|
+
addSeedFace(i0, i2, i3, i1);
|
|
501
|
+
addSeedFace(i1, i2, i3, i0);
|
|
502
|
+
const inSeed = new Set(seed);
|
|
503
|
+
let live = faces;
|
|
504
|
+
for (let index = 0; index < unique.length; index += 1) {
|
|
505
|
+
if (inSeed.has(index)) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const point = unique[index];
|
|
509
|
+
const visible = [];
|
|
510
|
+
const kept = [];
|
|
511
|
+
for (const face of live) {
|
|
512
|
+
// A face is visible when the point lies strictly on its outward side.
|
|
513
|
+
if (orient3d(unique[face[0]], unique[face[1]], unique[face[2]], point) < 0) {
|
|
514
|
+
visible.push(face);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
kept.push(face);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/* istanbul ignore next -- points are inserted in lexicographic order, so a point is never interior to the partial hull that already exists when it is added. */
|
|
521
|
+
if (visible.length === 0) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Horizon = edges owned by exactly one visible face.
|
|
525
|
+
const edgeUse = new Map();
|
|
526
|
+
for (const face of visible) {
|
|
527
|
+
for (const [a, b] of [[face[0], face[1]], [face[1], face[2]], [face[2], face[0]]]) {
|
|
528
|
+
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
529
|
+
if (edgeUse.has(key)) {
|
|
530
|
+
edgeUse.delete(key);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
edgeUse.set(key, [a, b]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const horizon = [...edgeUse.entries()].sort((left, right) => (left[0] < right[0] ? -1 : 1)).map((entry) => entry[1]);
|
|
538
|
+
for (const [a, b] of horizon) {
|
|
539
|
+
kept.push([a, b, index]);
|
|
540
|
+
}
|
|
541
|
+
live = kept;
|
|
542
|
+
}
|
|
543
|
+
const usedIndices = [...new Set(live.flat())].sort((left, right) => left - right);
|
|
544
|
+
const remap = new Map(usedIndices.map((original, next) => [original, next]));
|
|
545
|
+
const vertices = usedIndices.map((original) => unique[original]);
|
|
546
|
+
const hullFaces = live
|
|
547
|
+
.map((face) => [remap.get(face[0]), remap.get(face[1]), remap.get(face[2])])
|
|
548
|
+
/* istanbul ignore next -- consistent outward winding means no two faces share their first two vertex indices in the same order (3000-hull fuzz: never); the third operand stays as the determinism guarantee. */
|
|
549
|
+
.sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2]);
|
|
550
|
+
return { vertices, faces: hullFaces, volume: hullVolume(vertices, hullFaces) };
|
|
551
|
+
}
|
|
552
|
+
function dedupeIVecs(points) {
|
|
553
|
+
const sorted = [...points].sort(compareIVec);
|
|
554
|
+
const unique = [];
|
|
555
|
+
for (const point of sorted) {
|
|
556
|
+
const last = unique[unique.length - 1];
|
|
557
|
+
if (last === undefined || compareIVec(last, point) !== 0) {
|
|
558
|
+
unique.push(point);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return unique;
|
|
562
|
+
}
|
|
563
|
+
function seedTetrahedron(points) {
|
|
564
|
+
const i0 = 0;
|
|
565
|
+
let i1 = -1;
|
|
566
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
567
|
+
/* istanbul ignore else -- `points` is deduped, so points[1] always differs from points[0]. */
|
|
568
|
+
if (compareIVec(points[i0], points[index]) !== 0) {
|
|
569
|
+
i1 = index;
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/* istanbul ignore next -- unreachable: the deduped set has at least two distinct points here. */
|
|
574
|
+
if (i1 < 0) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
let i2 = -1;
|
|
578
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
579
|
+
if (index !== i1 && !collinear(points[i0], points[i1], points[index])) {
|
|
580
|
+
i2 = index;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (i2 < 0) {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
588
|
+
if (index !== i1 && index !== i2 && orient3d(points[i0], points[i1], points[i2], points[index]) !== 0) {
|
|
589
|
+
return [i0, i1, i2, index];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
function collinear(a, b, c) {
|
|
595
|
+
const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
|
|
596
|
+
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
597
|
+
return uy * vz - uz * vy === 0 && uz * vx - ux * vz === 0 && ux * vy - uy * vx === 0;
|
|
598
|
+
}
|
|
599
|
+
function hullVolume(vertices, faces) {
|
|
600
|
+
/* istanbul ignore next -- only called with the incremental hull's live face set, which always has at least four faces. */
|
|
601
|
+
if (faces.length === 0) {
|
|
602
|
+
return 0;
|
|
603
|
+
}
|
|
604
|
+
let sixVolume = 0;
|
|
605
|
+
const origin = vertices[0];
|
|
606
|
+
for (const face of faces) {
|
|
607
|
+
sixVolume += orient3d(vertices[face[0]], vertices[face[1]], vertices[face[2]], origin);
|
|
608
|
+
}
|
|
609
|
+
return Math.abs(sixVolume) / 6;
|
|
610
|
+
}
|
|
611
|
+
// ---------- hull finalisation ----------
|
|
612
|
+
function finalizeHull(part, lattice, maxHullVertices, quantum) {
|
|
613
|
+
// Every non-empty voxel set spans two corner layers on all three axes, so its
|
|
614
|
+
// lattice hull is always full-dimensional and `part.hull.faces` is non-empty.
|
|
615
|
+
// Reduce in integer lattice space, where the hull rebuild driving the greedy
|
|
616
|
+
// removal is exact and cheap, then inflate the survivor in world space until
|
|
617
|
+
// it contains every original hull vertex again.
|
|
618
|
+
const reducedLattice = reduceLatticeHull(part.hull, maxHullVertices);
|
|
619
|
+
const original = part.hull.vertices.map((corner) => cornerToWorld(corner, lattice));
|
|
620
|
+
const reduced = reducedLattice.length === part.hull.vertices.length
|
|
621
|
+
? original
|
|
622
|
+
: inflateToContain(reducedLattice.map((corner) => cornerToWorld(corner, lattice)), original);
|
|
623
|
+
// Round each vertex AWAY from the hull's centre, never to the nearest grid
|
|
624
|
+
// point. Nearest-rounding pushes roughly half the vertices inside the hull they
|
|
625
|
+
// define, `buildSolverHull` then rejects them as interior, and the part
|
|
626
|
+
// degrades. Outward rounding keeps every vertex extreme and preserves the
|
|
627
|
+
// containment guarantee inflation just established.
|
|
628
|
+
const centre = meanPoint(reduced);
|
|
629
|
+
const boundary = boundaryVertices(dedupePoints(reduced.map((point) => quantizeOutward(point, centre, quantum))));
|
|
630
|
+
// Silently shipping a looser box here would hide a bad cook in a game rather
|
|
631
|
+
// than in the asset pipeline; `quantum <= voxelSize / 8` is validated up front,
|
|
632
|
+
// so reaching either throw means the caller asked for something incoherent.
|
|
633
|
+
if (boundary.length < RUNTIME_MIN_HULL_VERTICES || boundary.length > maxHullVertices) {
|
|
634
|
+
throw new Error(`cookConvexDecomposition: a part reduced to ${boundary.length} boundary vertices, outside the runtime's `
|
|
635
|
+
+ `${RUNTIME_MIN_HULL_VERTICES}..${maxHullVertices} range — raise maxHullVertices or use a finer quantum`);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
(0, physics3d_hull_js_1.buildSolverHull)(boundary);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
/* istanbul ignore next -- quantum <= voxelSize / 8 is validated up front, and outward rounding keeps every reduced vertex extreme, so the boundary always builds. */
|
|
642
|
+
throw new Error(`cookConvexDecomposition: quantisation collapsed a part's hull (${error.message}) `
|
|
643
|
+
+ '— use a finer quantum or a coarser resolution');
|
|
644
|
+
}
|
|
645
|
+
return boundary;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Drop the least load-bearing vertex until the cap is met.
|
|
649
|
+
*
|
|
650
|
+
* A vertex's cost is the volume of the cone its incident faces subtend from the
|
|
651
|
+
* hull's centroid — the part of the hull only that vertex carries. Scoring from
|
|
652
|
+
* the current hull's faces costs one hull rebuild per removal rather than one
|
|
653
|
+
* per candidate, which is the difference between a cook that takes a second and
|
|
654
|
+
* one that takes a minute. It is a greedy proxy, and it is allowed to be: the
|
|
655
|
+
* caller re-inflates the survivor until it contains the original hull, so a
|
|
656
|
+
* suboptimal choice costs tightness, never correctness.
|
|
657
|
+
*/
|
|
658
|
+
function reduceLatticeHull(hull, maxHullVertices) {
|
|
659
|
+
let current = hull;
|
|
660
|
+
while (current.vertices.length > maxHullVertices && current.faces.length > 0) {
|
|
661
|
+
const cost = new Array(current.vertices.length).fill(0);
|
|
662
|
+
// Cone volumes are measured from the hull's centroid, never from one of its
|
|
663
|
+
// own vertices: every face incident to that vertex would subtend zero volume,
|
|
664
|
+
// so it would always score lowest and the greedy would just strip vertices in
|
|
665
|
+
// index order until the survivors went coplanar.
|
|
666
|
+
const apex = latticeCentroid(current.vertices);
|
|
667
|
+
for (const face of current.faces) {
|
|
668
|
+
const volume = Math.abs(orient3dFloat(current.vertices[face[0]], current.vertices[face[1]], current.vertices[face[2]], apex));
|
|
669
|
+
cost[face[0]] += volume;
|
|
670
|
+
cost[face[1]] += volume;
|
|
671
|
+
cost[face[2]] += volume;
|
|
672
|
+
}
|
|
673
|
+
let victim = 0;
|
|
674
|
+
for (let index = 1; index < cost.length; index += 1) {
|
|
675
|
+
if (cost[index] < cost[victim]) {
|
|
676
|
+
victim = index;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const kept = current.vertices.filter((_, index) => index !== victim);
|
|
680
|
+
const rebuilt = convexHullLattice(kept);
|
|
681
|
+
if (rebuilt.volume <= 0 || rebuilt.vertices.length >= current.vertices.length) {
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
current = rebuilt;
|
|
685
|
+
}
|
|
686
|
+
return current.vertices;
|
|
687
|
+
}
|
|
688
|
+
function cornerToWorld(corner, lattice) {
|
|
689
|
+
return {
|
|
690
|
+
x: lattice.originX + corner[0] * lattice.voxelSize,
|
|
691
|
+
y: lattice.originY + corner[1] * lattice.voxelSize,
|
|
692
|
+
z: lattice.originZ + corner[2] * lattice.voxelSize,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Inflate `hullPoints` about their centroid by the smallest factor that puts
|
|
697
|
+
* every point of `mustContain` back inside. A cooked collider is never smaller
|
|
698
|
+
* than the geometry it stands for.
|
|
699
|
+
*/
|
|
700
|
+
function inflateToContain(hullPoints, mustContain) {
|
|
701
|
+
const centroid = meanPoint(hullPoints);
|
|
702
|
+
const planes = supportingPlanes(hullPoints, centroid);
|
|
703
|
+
let scale = 1;
|
|
704
|
+
for (const point of mustContain) {
|
|
705
|
+
for (const plane of planes) {
|
|
706
|
+
const reach = dot(plane.normal, subtract(point, centroid));
|
|
707
|
+
if (reach > plane.offset * scale) {
|
|
708
|
+
scale = reach / plane.offset;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (scale <= 1) {
|
|
713
|
+
return hullPoints;
|
|
714
|
+
}
|
|
715
|
+
// Scaling the vertices about the centroid scales every supporting plane's
|
|
716
|
+
// centroid-relative offset by the same factor, so containment is exact.
|
|
717
|
+
return hullPoints.map((point) => add(centroid, multiply(subtract(point, centroid), scale)));
|
|
718
|
+
}
|
|
719
|
+
function supportingPlanes(points, centroid) {
|
|
720
|
+
const planes = [];
|
|
721
|
+
for (let a = 0; a < points.length; a += 1) {
|
|
722
|
+
for (let b = a + 1; b < points.length; b += 1) {
|
|
723
|
+
for (let c = b + 1; c < points.length; c += 1) {
|
|
724
|
+
const normal = cross(subtract(points[b], points[a]), subtract(points[c], points[a]));
|
|
725
|
+
const length = Math.sqrt(dot(normal, normal));
|
|
726
|
+
if (length === 0) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const unit = multiply(normal, 1 / length);
|
|
730
|
+
for (const oriented of [unit, multiply(unit, -1)]) {
|
|
731
|
+
const offset = dot(oriented, subtract(points[a], centroid));
|
|
732
|
+
if (offset <= 0) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (points.every((point) => dot(oriented, subtract(point, centroid)) <= offset + 1e-9)) {
|
|
736
|
+
planes.push({ normal: oriented, offset });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (planes.length === 0) {
|
|
743
|
+
throw new Error('cookConvexDecomposition: hull reduction produced a degenerate (flat) hull');
|
|
744
|
+
}
|
|
745
|
+
return planes;
|
|
746
|
+
}
|
|
747
|
+
/** Vertices of `points` that survive on the convex boundary, in canonical order. */
|
|
748
|
+
function boundaryVertices(points) {
|
|
749
|
+
if (points.length < RUNTIME_MIN_HULL_VERTICES) {
|
|
750
|
+
return points;
|
|
751
|
+
}
|
|
752
|
+
const centroid = meanPoint(points);
|
|
753
|
+
let planes;
|
|
754
|
+
try {
|
|
755
|
+
planes = supportingPlanes(points, centroid);
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
return points;
|
|
759
|
+
}
|
|
760
|
+
const kept = points.filter((point) => planes.some((plane) => Math.abs(dot(plane.normal, subtract(point, centroid)) - plane.offset) <= 1e-9));
|
|
761
|
+
return [...kept].sort(comparePoints);
|
|
762
|
+
}
|
|
763
|
+
// ---------- small vector helpers ----------
|
|
764
|
+
function add(a, b) {
|
|
765
|
+
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
|
766
|
+
}
|
|
767
|
+
function subtract(a, b) {
|
|
768
|
+
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
|
769
|
+
}
|
|
770
|
+
function multiply(a, scalar) {
|
|
771
|
+
return { x: a.x * scalar, y: a.y * scalar, z: a.z * scalar };
|
|
772
|
+
}
|
|
773
|
+
function dot(a, b) {
|
|
774
|
+
return a.x * b.x + a.y * b.y + a.z * b.z;
|
|
775
|
+
}
|
|
776
|
+
function cross(a, b) {
|
|
777
|
+
return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x };
|
|
778
|
+
}
|
|
779
|
+
function meanPoint(points) {
|
|
780
|
+
let sum = { x: 0, y: 0, z: 0 };
|
|
781
|
+
for (const point of points) {
|
|
782
|
+
sum = add(sum, point);
|
|
783
|
+
}
|
|
784
|
+
return multiply(sum, 1 / points.length);
|
|
785
|
+
}
|
|
786
|
+
/** Quantize each axis away from `from`, so the point never moves inward. */
|
|
787
|
+
function quantizeOutward(point, from, quantum) {
|
|
788
|
+
const axis = (value, origin) => (value >= origin ? Math.ceil(value / quantum) * quantum : Math.floor(value / quantum) * quantum);
|
|
789
|
+
return { x: axis(point.x, from.x), y: axis(point.y, from.y), z: axis(point.z, from.z) };
|
|
790
|
+
}
|
|
791
|
+
function quantizePoint(point, quantum) {
|
|
792
|
+
return {
|
|
793
|
+
x: Math.round(point.x / quantum) * quantum,
|
|
794
|
+
y: Math.round(point.y / quantum) * quantum,
|
|
795
|
+
z: Math.round(point.z / quantum) * quantum,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function dedupePoints(points) {
|
|
799
|
+
const sorted = [...points].sort(comparePoints);
|
|
800
|
+
const unique = [];
|
|
801
|
+
for (const point of sorted) {
|
|
802
|
+
const last = unique[unique.length - 1];
|
|
803
|
+
if (last === undefined || comparePoints(last, point) !== 0) {
|
|
804
|
+
unique.push(point);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return unique;
|
|
808
|
+
}
|
|
809
|
+
function requireIntegerInRange(value, low, high, name) {
|
|
810
|
+
if (!Number.isInteger(value) || value < low || value > high) {
|
|
811
|
+
throw new Error(`cookConvexDecomposition: ${name} must be an integer in [${low}, ${high}]`);
|
|
812
|
+
}
|
|
813
|
+
return value;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Internals exposed for direct testing. The cook's public entry only ever feeds
|
|
817
|
+
* these well-formed inputs, so their degenerate branches (collinear/coplanar
|
|
818
|
+
* point sets, unsplittable parts, the voxel-AABB fallback) are unreachable
|
|
819
|
+
* through `cookConvexDecomposition` alone.
|
|
820
|
+
*/
|
|
821
|
+
exports.__colliderCookingInternals = {
|
|
822
|
+
bestSplit,
|
|
823
|
+
boundaryVertices,
|
|
824
|
+
collinear,
|
|
825
|
+
convexHullLattice,
|
|
826
|
+
dedupePoints,
|
|
827
|
+
finalizeHull,
|
|
828
|
+
inflateToContain,
|
|
829
|
+
makePart,
|
|
830
|
+
reduceLatticeHull,
|
|
831
|
+
supportingPlanes,
|
|
832
|
+
triangleIntersectsBox,
|
|
833
|
+
voxelize,
|
|
834
|
+
};
|