@driftengine/physics 3.61.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 (136) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +503 -0
  4. package/dist/bodies.d.ts +121 -0
  5. package/dist/bodies.js +298 -0
  6. package/dist/buoyancy.d.ts +46 -0
  7. package/dist/buoyancy.js +45 -0
  8. package/dist/cloth.d.ts +226 -0
  9. package/dist/cloth.js +587 -0
  10. package/dist/collide/aabb.d.ts +10 -0
  11. package/dist/collide/aabb.js +11 -0
  12. package/dist/collide/body.d.ts +202 -0
  13. package/dist/collide/body.js +142 -0
  14. package/dist/collide/eject.d.ts +39 -0
  15. package/dist/collide/eject.js +142 -0
  16. package/dist/collide/index.d.ts +18 -0
  17. package/dist/collide/index.js +5 -0
  18. package/dist/collide/scratch.d.ts +8 -0
  19. package/dist/collide/scratch.js +9 -0
  20. package/dist/collide/segment.d.ts +12 -0
  21. package/dist/collide/segment.js +109 -0
  22. package/dist/collide/sweep.d.ts +3 -0
  23. package/dist/collide/sweep.js +1036 -0
  24. package/dist/colliderSet.d.ts +239 -0
  25. package/dist/colliderSet.js +546 -0
  26. package/dist/compoundContact.d.ts +70 -0
  27. package/dist/compoundContact.js +221 -0
  28. package/dist/controller.d.ts +351 -0
  29. package/dist/controller.js +641 -0
  30. package/dist/cylinder.d.ts +78 -0
  31. package/dist/cylinder.js +294 -0
  32. package/dist/cylinderContact.d.ts +7 -0
  33. package/dist/cylinderContact.js +704 -0
  34. package/dist/decompose.d.ts +105 -0
  35. package/dist/decompose.js +979 -0
  36. package/dist/events.d.ts +43 -0
  37. package/dist/events.js +95 -0
  38. package/dist/executor.d.ts +147 -0
  39. package/dist/executor.js +173 -0
  40. package/dist/faces.d.ts +26 -0
  41. package/dist/faces.js +34 -0
  42. package/dist/fingerprint.d.ts +61 -0
  43. package/dist/fingerprint.js +139 -0
  44. package/dist/groundMotor.d.ts +34 -0
  45. package/dist/groundMotor.js +41 -0
  46. package/dist/heightfieldShape.d.ts +128 -0
  47. package/dist/heightfieldShape.js +328 -0
  48. package/dist/index.d.ts +73 -0
  49. package/dist/index.js +47 -0
  50. package/dist/island.d.ts +55 -0
  51. package/dist/island.js +227 -0
  52. package/dist/islandSolve.d.ts +70 -0
  53. package/dist/islandSolve.js +171 -0
  54. package/dist/islandWorker.d.ts +18 -0
  55. package/dist/islandWorker.js +125 -0
  56. package/dist/joints.d.ts +180 -0
  57. package/dist/joints.js +1094 -0
  58. package/dist/manifold.d.ts +89 -0
  59. package/dist/manifold.js +608 -0
  60. package/dist/mass.d.ts +53 -0
  61. package/dist/mass.js +353 -0
  62. package/dist/meshContact.d.ts +57 -0
  63. package/dist/meshContact.js +393 -0
  64. package/dist/meshShape.d.ts +76 -0
  65. package/dist/meshShape.js +175 -0
  66. package/dist/pairs.d.ts +62 -0
  67. package/dist/pairs.js +154 -0
  68. package/dist/query.d.ts +71 -0
  69. package/dist/query.js +683 -0
  70. package/dist/ragdoll.d.ts +135 -0
  71. package/dist/ragdoll.js +489 -0
  72. package/dist/shape.d.ts +177 -0
  73. package/dist/shape.js +533 -0
  74. package/dist/solver.d.ts +136 -0
  75. package/dist/solver.js +601 -0
  76. package/dist/stage.d.ts +167 -0
  77. package/dist/stage.js +350 -0
  78. package/dist/stallEscape.d.ts +141 -0
  79. package/dist/stallEscape.js +254 -0
  80. package/dist/sweptStep.d.ts +173 -0
  81. package/dist/sweptStep.js +472 -0
  82. package/dist/tree.d.ts +61 -0
  83. package/dist/tree.js +348 -0
  84. package/dist/vehicle.d.ts +154 -0
  85. package/dist/vehicle.js +343 -0
  86. package/dist/workerPool.d.ts +107 -0
  87. package/dist/workerPool.js +239 -0
  88. package/dist/workers.d.ts +37 -0
  89. package/dist/workers.js +35 -0
  90. package/dist/world.d.ts +298 -0
  91. package/dist/world.js +712 -0
  92. package/package.json +55 -0
  93. package/src/bodies.ts +369 -0
  94. package/src/buoyancy.ts +78 -0
  95. package/src/cloth.ts +734 -0
  96. package/src/collide/aabb.ts +28 -0
  97. package/src/collide/body.ts +336 -0
  98. package/src/collide/eject.ts +156 -0
  99. package/src/collide/index.ts +26 -0
  100. package/src/collide/scratch.ts +10 -0
  101. package/src/collide/segment.ts +127 -0
  102. package/src/collide/sweep.ts +1132 -0
  103. package/src/colliderSet.ts +637 -0
  104. package/src/compoundContact.ts +284 -0
  105. package/src/controller.ts +817 -0
  106. package/src/cylinder.ts +340 -0
  107. package/src/cylinderContact.ts +825 -0
  108. package/src/decompose.ts +1198 -0
  109. package/src/events.ts +108 -0
  110. package/src/executor.ts +224 -0
  111. package/src/faces.ts +37 -0
  112. package/src/fingerprint.ts +146 -0
  113. package/src/groundMotor.ts +53 -0
  114. package/src/heightfieldShape.ts +395 -0
  115. package/src/index.ts +164 -0
  116. package/src/island.ts +219 -0
  117. package/src/islandSolve.ts +218 -0
  118. package/src/islandWorker.ts +141 -0
  119. package/src/joints.ts +1367 -0
  120. package/src/manifold.ts +798 -0
  121. package/src/mass.ts +424 -0
  122. package/src/meshContact.ts +443 -0
  123. package/src/meshShape.ts +261 -0
  124. package/src/pairs.ts +169 -0
  125. package/src/query.ts +844 -0
  126. package/src/ragdoll.ts +662 -0
  127. package/src/shape.ts +656 -0
  128. package/src/solver.ts +873 -0
  129. package/src/stage.ts +406 -0
  130. package/src/stallEscape.ts +362 -0
  131. package/src/sweptStep.ts +609 -0
  132. package/src/tree.ts +366 -0
  133. package/src/vehicle.ts +489 -0
  134. package/src/workerPool.ts +283 -0
  135. package/src/workers.ts +38 -0
  136. package/src/world.ts +961 -0
@@ -0,0 +1,1198 @@
1
+ import type { ConvexShape } from './shape.ts';
2
+ import { hullShape } from './shape.ts';
3
+ import { createMassProperties, shapeMassProperties } from './mass.ts';
4
+ import { MAX_BODY_PARTS } from './compoundContact.ts';
5
+
6
+ /**
7
+ * A triangle mesh, offline, into a handful of convex hulls the runtime can carry on one body.
8
+ *
9
+ * **This is the only function in this package that is not meant to run in a frame**, and that is a
10
+ * statement about what it costs rather than about where it lives. It allocates three grids, walks
11
+ * every cell of them several times, and calls `hullShape` once per part. A bake does that; a tick
12
+ * does not. The convex-decomposition design records the
13
+ * refusal and what would reverse it.
14
+ *
15
+ * **It lives here anyway, beside `hullShape`, because that is what it produces.** The fixtures
16
+ * `scripts/size-gate.test.mjs` measures import named symbols and this package is `sideEffects:
17
+ * false`, so a consumer who never calls this never bundles it — which is measured by `physics-only`
18
+ * staying where it is rather than asserted here.
19
+ *
20
+ * **Why a voxel grid and not the triangles.** A collision mesh is a triangle soup: it is routinely
21
+ * not watertight, routinely self-intersecting, and routinely has a hole where an artist deleted a
22
+ * face nobody would see. Every decomposition that reasons over the source triangles has to decide
23
+ * what the inside of such a thing is, and gets it wrong in a way that depends on the mesh. A grid
24
+ * with a flood fill from the outside answers that question once, the same way, for any input — and
25
+ * its cost is set by the resolution rather than by the triangle count, so a 200,000-triangle
26
+ * collision mesh decomposes in the time a 2,000-triangle one does.
27
+ *
28
+ * **What it gives up** is everything below the cell size: a plate thinner than one cell either
29
+ * disappears or becomes a cell thick. `resolution` is the control, and `Decomposition.resolution`
30
+ * reports what was actually used so a caller who passed nothing can see it.
31
+ */
32
+
33
+ /** How the interior of the mesh is decided. See `DecomposeOptions.fill`. */
34
+ export type DecomposeFill = 'solid' | 'surface';
35
+
36
+ export interface DecomposeOptions {
37
+ /**
38
+ * Cells along the longest axis of the mesh's bounds. Default 64, refused above 256.
39
+ *
40
+ * **The cap is stated because the alternative is discovering it as an allocation failure.** Two
41
+ * grids are held, a byte of state and four bytes of region index per cell, so 256 is 16.8 million
42
+ * cells and **83.9 MB**. 64 is 262,144 cells and 1.3 MB, which is the design point.
43
+ */
44
+ readonly resolution?: number;
45
+ /** How many hulls to stop at. Default 16, refused above `MAX_BODY_PARTS`. */
46
+ readonly maxHulls?: number;
47
+ /**
48
+ * How empty a merged region's bounding box may be before the merge stops being free. Default 0.05.
49
+ *
50
+ * It does not cap the part count: merging continues past this while more than `maxHulls` regions
51
+ * remain, because a caller asked for a body that a body can be made of. Below that count it is
52
+ * what decides whether two neighbours are worth keeping apart.
53
+ */
54
+ readonly concavity?: number;
55
+ /**
56
+ * `'solid'` fills the interior, `'surface'` keeps the shell alone. Default `'solid'`.
57
+ *
58
+ * **A mesh with a hole in it leaks under `'solid'`, and the fill has no way to know.** The flood
59
+ * escapes through the hole, nothing is left marked inside, and the result is the shell — which is
60
+ * very nearly harmless for collision, because contact only ever touches the boundary, and wrong
61
+ * for mass, because a hollow solid has the wrong inertia. `Decomposition.sourceVolume` is the
62
+ * voxelised volume, so the hollow is visible in the number; `'surface'` is how a caller asks for
63
+ * it deliberately.
64
+ */
65
+ readonly fill?: DecomposeFill;
66
+ }
67
+
68
+ /** One convex piece of the decomposition, ready for `hullShape`. */
69
+ export interface ConvexPart {
70
+ /** xyz-packed, at most `SUPPORT_DIRECTIONS` points, in the source mesh's own space. */
71
+ readonly points: Float32Array;
72
+ /** The hull's volume, exactly, by the tetrahedral decomposition `mass.ts` already computes. */
73
+ readonly volume: number;
74
+ /** Cells this part was grown from, as a fraction of its own hull volume. */
75
+ readonly fill: number;
76
+ }
77
+
78
+ export interface Decomposition {
79
+ readonly parts: readonly ConvexPart[];
80
+ /** The voxelised source's volume. What `bloat` is measured against. */
81
+ readonly sourceVolume: number;
82
+ /** The parts' volumes summed, which double-counts wherever two hulls overlap. */
83
+ readonly hullVolume: number;
84
+ /**
85
+ * `(hullVolume - sourceVolume) / sourceVolume`: the empty space the decomposition added.
86
+ *
87
+ * **The number that says whether the decomposition is worth having.** A single hull of a bowl has
88
+ * an enormous one; a decomposition that respects the cavity has a small one. It is not a bound on
89
+ * error in either direction — a hull can only add space, never remove it — which is why
90
+ * `coverage` is reported beside it.
91
+ */
92
+ readonly bloat: number;
93
+ /**
94
+ * The fraction of solid cells whose centre lies inside the part they were grown into.
95
+ *
96
+ * **Below one, and that is the support sampling of `SUPPORT_DIRECTIONS` showing up.** A support
97
+ * set is inscribed in the true hull of a region's corners, so a cell at a corner the direction set
98
+ * does not point at can fall outside by a little. It is the one place this function loses material
99
+ * rather than adding it.
100
+ */
101
+ readonly coverage: number;
102
+ /** The grid that was used, which a caller who passed no resolution wants to know. */
103
+ readonly resolution: number;
104
+ /** Cells in that grid, all three axes multiplied. */
105
+ readonly cells: number;
106
+ }
107
+
108
+ /** The default `maxHulls`, chosen so a decomposed body is well inside `MAX_BODY_PARTS`. */
109
+ export const DEFAULT_MAX_HULLS = 16;
110
+
111
+ /** The default and maximum grids. See `DecomposeOptions.resolution`. */
112
+ export const DEFAULT_RESOLUTION = 64;
113
+ export const MAX_RESOLUTION = 256;
114
+
115
+ const OUTSIDE = 0;
116
+ const SURFACE = 1;
117
+ const INSIDE = 2;
118
+
119
+ /**
120
+ * The directions a region's extreme corners are kept along, as integer vectors.
121
+ *
122
+ * **Fifty of them, and every one is integers**, so the table is built by arithmetic and nothing here
123
+ * reaches for a transcendental the determinism gate would refuse — `packages/physics/src` is a
124
+ * declared root of it and this file is inside the scan.
125
+ *
126
+ * They are an octahedron's own directions taken three ways: its six vertices, the twelve midpoints
127
+ * of its edges, the eight centres of its faces, and the twenty-four points halfway between a vertex
128
+ * and an edge midpoint. That is a 50-DOP, whose worst angular gap leaves a support hull within about
129
+ * two per cent of the true hull of the same corners — small against a region that is a fraction of
130
+ * the mesh, and measured by `Decomposition.coverage` rather than trusted.
131
+ *
132
+ * **Fifty and not sixty-four** because these four families are what an octahedron gives without
133
+ * inventing a direction; the next family would be twenty-four more and overrun `hullShape`'s cap of
134
+ * sixty-four points. Nothing is gained by padding the set to the cap.
135
+ */
136
+ const DIRECTIONS = buildDirections();
137
+
138
+ /** How many, which is also the most points any part can carry. */
139
+ export const SUPPORT_DIRECTIONS = DIRECTIONS.length / 3;
140
+
141
+ function buildDirections(): Int32Array {
142
+ const families: readonly (readonly [number, number, number])[] = [
143
+ [1, 0, 0],
144
+ [1, 1, 0],
145
+ [1, 1, 1],
146
+ [2, 1, 0],
147
+ ];
148
+ const seen = new Set<string>();
149
+ const out: number[] = [];
150
+ for (const [a, b, c] of families) {
151
+ /*
152
+ * Every distinct permutation of the family's magnitudes, with every sign on every non-zero
153
+ * component. A zero has one sign and not two, which is what keeps (1,0,0) from arriving twice.
154
+ */
155
+ for (const p of permutations(a, b, c)) {
156
+ for (let s = 0; s < 8; s++) {
157
+ const x = (p[0] as number) * (s & 1 ? -1 : 1);
158
+ const y = (p[1] as number) * (s & 2 ? -1 : 1);
159
+ const z = (p[2] as number) * (s & 4 ? -1 : 1);
160
+ const key = `${x},${y},${z}`;
161
+ if (seen.has(key)) continue;
162
+ seen.add(key);
163
+ out.push(x, y, z);
164
+ }
165
+ }
166
+ }
167
+ return new Int32Array(out);
168
+ }
169
+
170
+ function permutations(a: number, b: number, c: number): readonly (readonly number[])[] {
171
+ const all = [
172
+ [a, b, c],
173
+ [a, c, b],
174
+ [b, a, c],
175
+ [b, c, a],
176
+ [c, a, b],
177
+ [c, b, a],
178
+ ];
179
+ const seen = new Set<string>();
180
+ const out: number[][] = [];
181
+ for (const p of all) {
182
+ const key = p.join(',');
183
+ if (seen.has(key)) continue;
184
+ seen.add(key);
185
+ out.push(p);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ export function decomposeConvex(
191
+ positions: ArrayLike<number>,
192
+ indices: ArrayLike<number>,
193
+ options: DecomposeOptions = {},
194
+ ): Decomposition {
195
+ const resolution = Math.trunc(options.resolution ?? DEFAULT_RESOLUTION);
196
+ const maxHulls = Math.trunc(options.maxHulls ?? DEFAULT_MAX_HULLS);
197
+ const concavity = options.concavity ?? 0.05;
198
+ const fill = options.fill ?? 'solid';
199
+
200
+ if (resolution < 4 || resolution > MAX_RESOLUTION) {
201
+ throw new Error(
202
+ `decomposeConvex: resolution must be between 4 and ${MAX_RESOLUTION}, got ${resolution}`,
203
+ );
204
+ }
205
+ if (maxHulls < 1 || maxHulls > MAX_BODY_PARTS) {
206
+ throw new Error(
207
+ `decomposeConvex: maxHulls must be between 1 and ${MAX_BODY_PARTS}, got ${maxHulls}`,
208
+ );
209
+ }
210
+ if (!(concavity >= 0) || concavity >= 1) {
211
+ throw new Error(`decomposeConvex: concavity must be at least 0 and below 1, got ${concavity}`);
212
+ }
213
+ if (indices.length % 3 !== 0) {
214
+ throw new Error(
215
+ `decomposeConvex: ${indices.length} indices is not a whole number of triangles`,
216
+ );
217
+ }
218
+ if (indices.length === 0) throw new Error('decomposeConvex: no triangles');
219
+
220
+ const grid = buildGrid(positions, resolution);
221
+ const state = new Uint8Array(grid.nx * grid.ny * grid.nz);
222
+ voxelise(positions, indices, grid, state);
223
+ if (fill === 'solid') fillOutside(grid, state);
224
+
225
+ /*
226
+ * `fillOutside` marks what the flood reached; everything it did not reach and did not already own
227
+ * is interior. Under `'surface'` the pass never ran, so nothing is interior and the shell stands
228
+ * alone — which is the whole difference between the two settings.
229
+ */
230
+ const solid = new Uint8Array(state.length);
231
+ let solidCells = 0;
232
+ for (let i = 0; i < state.length; i++) {
233
+ const s = state[i] ?? OUTSIDE;
234
+ if (s === SURFACE || (fill === 'solid' && s === INSIDE)) {
235
+ solid[i] = 1;
236
+ solidCells++;
237
+ }
238
+ }
239
+ if (solidCells === 0) {
240
+ throw new Error(
241
+ 'decomposeConvex: the mesh occupied no cells — it is thinner than one cell at this resolution',
242
+ );
243
+ }
244
+
245
+ const regions = growRegions(grid, solid, concavity);
246
+ mergeRegions(regions, maxHulls, concavity, grid.cell * grid.cell * grid.cell);
247
+ const parts = buildParts(grid, solid, regions);
248
+
249
+ const cellVolume = grid.cell * grid.cell * grid.cell;
250
+ const sourceVolume = solidCells * cellVolume;
251
+ let hullVolume = 0;
252
+ for (const part of parts.parts) hullVolume += part.volume;
253
+
254
+ return {
255
+ parts: parts.parts,
256
+ sourceVolume,
257
+ hullVolume,
258
+ bloat: (hullVolume - sourceVolume) / sourceVolume,
259
+ coverage: parts.covered / solidCells,
260
+ resolution,
261
+ cells: state.length,
262
+ };
263
+ }
264
+
265
+ /** The grid a mesh is rasterised into: where its origin is, how wide a cell is, and how many. */
266
+ interface Grid {
267
+ readonly originX: number;
268
+ readonly originY: number;
269
+ readonly originZ: number;
270
+ readonly cell: number;
271
+ readonly nx: number;
272
+ readonly ny: number;
273
+ readonly nz: number;
274
+ }
275
+
276
+ /**
277
+ * Cubic cells, sized by the longest axis, with one cell of padding on every side.
278
+ *
279
+ * **The padding is what makes the flood fill correct rather than nearly correct.** A mesh whose face
280
+ * sits exactly on the grid's boundary would have no outside cell on that side to flood from, and the
281
+ * fill would leave the whole slab beyond it marked interior — a solid wall where the model has a
282
+ * flat back. One ring of empty cells removes the case entirely.
283
+ */
284
+ function buildGrid(positions: ArrayLike<number>, resolution: number): Grid {
285
+ let minX = Infinity;
286
+ let minY = Infinity;
287
+ let minZ = Infinity;
288
+ let maxX = -Infinity;
289
+ let maxY = -Infinity;
290
+ let maxZ = -Infinity;
291
+ for (let i = 0; i + 2 < positions.length; i += 3) {
292
+ const x = positions[i] as number;
293
+ const y = positions[i + 1] as number;
294
+ const z = positions[i + 2] as number;
295
+ if (x < minX) minX = x;
296
+ if (y < minY) minY = y;
297
+ if (z < minZ) minZ = z;
298
+ if (x > maxX) maxX = x;
299
+ if (y > maxY) maxY = y;
300
+ if (z > maxZ) maxZ = z;
301
+ }
302
+ if (!(maxX >= minX)) throw new Error('decomposeConvex: no positions');
303
+
304
+ const spanX = maxX - minX;
305
+ const spanY = maxY - minY;
306
+ const spanZ = maxZ - minZ;
307
+ const longest = Math.max(spanX, Math.max(spanY, spanZ));
308
+ if (!(longest > 0)) throw new Error('decomposeConvex: the mesh has no extent');
309
+
310
+ const cell = longest / resolution;
311
+ /* Two cells of padding in each axis, one at each end, hence the +2 on every count. */
312
+ const nx = Math.max(1, Math.ceil(spanX / cell)) + 2;
313
+ const ny = Math.max(1, Math.ceil(spanY / cell)) + 2;
314
+ const nz = Math.max(1, Math.ceil(spanZ / cell)) + 2;
315
+ return {
316
+ originX: minX - cell,
317
+ originY: minY - cell,
318
+ originZ: minZ - cell,
319
+ cell,
320
+ nx,
321
+ ny,
322
+ nz,
323
+ };
324
+ }
325
+
326
+ /** Cell index from coordinates. x fastest, then y, then z — the order every walk here uses. */
327
+ function at(grid: Grid, x: number, y: number, z: number): number {
328
+ return x + grid.nx * (y + grid.ny * z);
329
+ }
330
+
331
+ /**
332
+ * Every cell a triangle touches, marked `SURFACE`.
333
+ *
334
+ * The overlap test is the standard thirteen-axis separating-axis test between a triangle and an
335
+ * axis-aligned box: the box's three face normals, the triangle's own normal, and the nine cross
336
+ * products of a triangle edge with a box axis. It uses multiplication, addition, `Math.abs`,
337
+ * `Math.min` and `Math.max` and nothing else, which is what keeps it inside the determinism gate.
338
+ *
339
+ * **Conservative on purpose.** A cell that the triangle only grazes is marked, so a surface is never
340
+ * one cell thin where it should be closed — a hole in the shell is what lets the flood fill escape
341
+ * and turn a solid into a hollow one, and it is much cheaper to over-mark than to detect that later.
342
+ */
343
+ function voxelise(
344
+ positions: ArrayLike<number>,
345
+ indices: ArrayLike<number>,
346
+ grid: Grid,
347
+ state: Uint8Array,
348
+ ): void {
349
+ /*
350
+ * **The cell box is grown by one part in a million, and that is the difference between a shell
351
+ * that holds and one with a hole straight through it.**
352
+ *
353
+ * A face lying exactly in a cell boundary plane is not an edge case in this domain — it is what
354
+ * every floor, wall, lid and cap in every model does, and the fans that close a prism are two of
355
+ * them. Such a face is exactly tangent to the boxes on both sides, so the plane test compares two
356
+ * quantities that are equal in exact arithmetic and differ by a rounding step in this one. When it
357
+ * falls the wrong way *neither* neighbour is marked, the flood walks straight through the gap, and
358
+ * the whole interior comes back empty.
359
+ *
360
+ * Measured on a closed twenty-four-sided prism of true volume 3.106: the fill answered 3.57, 0.56,
361
+ * 0.47, 3.37, 3.33, 3.31, 0.20 and 0.17 at resolutions 32 through 96 — correct at four of them and
362
+ * the bare shell at the other four, with nothing in the shape to explain which. The escape path
363
+ * ran down the axis and out through the base, whose disc sits exactly on the boundary between two
364
+ * cell rows.
365
+ *
366
+ * **Growing the box is the right direction to be wrong in.** This marking is conservative by
367
+ * design: a cell marked that need not be costs a little accuracy at the surface, and a cell missed
368
+ * that should be marked is a hole. The epsilon is ten orders of magnitude above the rounding it
369
+ * covers and six below a cell, so it can decide a tangency and can decide nothing else.
370
+ */
371
+ const TANGENCY = 1e-6;
372
+ const half = grid.cell * (0.5 + TANGENCY);
373
+ for (let t = 0; t + 2 < indices.length; t += 3) {
374
+ const i0 = (indices[t] as number) * 3;
375
+ const i1 = (indices[t + 1] as number) * 3;
376
+ const i2 = (indices[t + 2] as number) * 3;
377
+ const ax = positions[i0] as number;
378
+ const ay = positions[i0 + 1] as number;
379
+ const az = positions[i0 + 2] as number;
380
+ const bx = positions[i1] as number;
381
+ const by = positions[i1 + 1] as number;
382
+ const bz = positions[i1 + 2] as number;
383
+ const cx = positions[i2] as number;
384
+ const cy = positions[i2 + 1] as number;
385
+ const cz = positions[i2 + 2] as number;
386
+
387
+ const loX = cellOf(Math.min(ax, Math.min(bx, cx)), grid.originX, grid.cell, grid.nx);
388
+ const hiX = cellOf(Math.max(ax, Math.max(bx, cx)), grid.originX, grid.cell, grid.nx);
389
+ const loY = cellOf(Math.min(ay, Math.min(by, cy)), grid.originY, grid.cell, grid.ny);
390
+ const hiY = cellOf(Math.max(ay, Math.max(by, cy)), grid.originY, grid.cell, grid.ny);
391
+ const loZ = cellOf(Math.min(az, Math.min(bz, cz)), grid.originZ, grid.cell, grid.nz);
392
+ const hiZ = cellOf(Math.max(az, Math.max(bz, cz)), grid.originZ, grid.cell, grid.nz);
393
+
394
+ for (let z = loZ; z <= hiZ; z++) {
395
+ const centreZ = grid.originZ + (z + 0.5) * grid.cell;
396
+ for (let y = loY; y <= hiY; y++) {
397
+ const centreY = grid.originY + (y + 0.5) * grid.cell;
398
+ for (let x = loX; x <= hiX; x++) {
399
+ const index = at(grid, x, y, z);
400
+ if (state[index] === SURFACE) continue;
401
+ const centreX = grid.originX + (x + 0.5) * grid.cell;
402
+ if (
403
+ triangleTouchesBox(
404
+ ax - centreX,
405
+ ay - centreY,
406
+ az - centreZ,
407
+ bx - centreX,
408
+ by - centreY,
409
+ bz - centreZ,
410
+ cx - centreX,
411
+ cy - centreY,
412
+ cz - centreZ,
413
+ half,
414
+ )
415
+ ) {
416
+ state[index] = SURFACE;
417
+ }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ /** Which cell a coordinate falls in, clamped into the grid. */
425
+ function cellOf(value: number, origin: number, cell: number, count: number): number {
426
+ const raw = Math.floor((value - origin) / cell);
427
+ return Math.min(count - 1, Math.max(0, raw));
428
+ }
429
+
430
+ /**
431
+ * The thirteen-axis test, with the triangle already translated so the box is at the origin.
432
+ *
433
+ * `half` is the box's half-extent, the same on all three axes because the cells are cubes.
434
+ */
435
+ function triangleTouchesBox(
436
+ ax: number,
437
+ ay: number,
438
+ az: number,
439
+ bx: number,
440
+ by: number,
441
+ bz: number,
442
+ cx: number,
443
+ cy: number,
444
+ cz: number,
445
+ half: number,
446
+ ): boolean {
447
+ /* The box's own three axes first: they reject the great majority of candidates for one compare. */
448
+ if (Math.min(ax, Math.min(bx, cx)) > half || Math.max(ax, Math.max(bx, cx)) < -half) return false;
449
+ if (Math.min(ay, Math.min(by, cy)) > half || Math.max(ay, Math.max(by, cy)) < -half) return false;
450
+ if (Math.min(az, Math.min(bz, cz)) > half || Math.max(az, Math.max(bz, cz)) < -half) return false;
451
+
452
+ const e0x = bx - ax;
453
+ const e0y = by - ay;
454
+ const e0z = bz - az;
455
+ const e1x = cx - bx;
456
+ const e1y = cy - by;
457
+ const e1z = cz - bz;
458
+ const e2x = ax - cx;
459
+ const e2y = ay - cy;
460
+ const e2z = az - cz;
461
+
462
+ /* The triangle's own plane against the box. */
463
+ const nx = e0y * e1z - e0z * e1y;
464
+ const ny = e0z * e1x - e0x * e1z;
465
+ const nz = e0x * e1y - e0y * e1x;
466
+ const d = nx * ax + ny * ay + nz * az;
467
+ const reach = half * (Math.abs(nx) + Math.abs(ny) + Math.abs(nz));
468
+ if (d > reach || d < -reach) return false;
469
+
470
+ /* And the nine edge-by-axis crosses. */
471
+ if (!axisTest(e0z, -e0y, ay, az, by, bz, cy, cz, half)) return false;
472
+ if (!axisTest(-e0z, e0x, ax, az, bx, bz, cx, cz, half)) return false;
473
+ if (!axisTest(e0y, -e0x, ax, ay, bx, by, cx, cy, half)) return false;
474
+ if (!axisTest(e1z, -e1y, ay, az, by, bz, cy, cz, half)) return false;
475
+ if (!axisTest(-e1z, e1x, ax, az, bx, bz, cx, cz, half)) return false;
476
+ if (!axisTest(e1y, -e1x, ax, ay, bx, by, cx, cy, half)) return false;
477
+ if (!axisTest(e2z, -e2y, ay, az, by, bz, cy, cz, half)) return false;
478
+ if (!axisTest(-e2z, e2x, ax, az, bx, bz, cx, cz, half)) return false;
479
+ if (!axisTest(e2y, -e2x, ax, ay, bx, by, cx, cy, half)) return false;
480
+ return true;
481
+ }
482
+
483
+ /**
484
+ * One separating-axis test, in the plane the axis lives in.
485
+ *
486
+ * Every one of the nine crosses has a zero component, so each reduces to a two-dimensional dot
487
+ * product against the two triangle corners that are not on the edge — which is why this takes two
488
+ * multipliers and three pairs of coordinates instead of three vectors.
489
+ */
490
+ function axisTest(
491
+ u: number,
492
+ v: number,
493
+ a0: number,
494
+ a1: number,
495
+ b0: number,
496
+ b1: number,
497
+ c0: number,
498
+ c1: number,
499
+ half: number,
500
+ ): boolean {
501
+ const pa = u * a0 + v * a1;
502
+ const pb = u * b0 + v * b1;
503
+ const pc = u * c0 + v * c1;
504
+ const lo = Math.min(pa, Math.min(pb, pc));
505
+ const hi = Math.max(pa, Math.max(pb, pc));
506
+ const reach = half * (Math.abs(u) + Math.abs(v));
507
+ return !(lo > reach || hi < -reach);
508
+ }
509
+
510
+ /**
511
+ * Everything the outside can reach, marked `OUTSIDE`; everything else becomes `INSIDE`.
512
+ *
513
+ * A breadth-first flood from every cell of the grid's boundary. The padding `buildGrid` added
514
+ * guarantees that boundary is empty, so the flood always has somewhere to start.
515
+ */
516
+ function fillOutside(grid: Grid, state: Uint8Array): void {
517
+ const visited = new Uint8Array(state.length);
518
+ const queue = new Int32Array(state.length);
519
+ let head = 0;
520
+ let tail = 0;
521
+
522
+ const push = (index: number): void => {
523
+ if (visited[index] === 1 || state[index] === SURFACE) return;
524
+ visited[index] = 1;
525
+ queue[tail++] = index;
526
+ };
527
+
528
+ for (let z = 0; z < grid.nz; z++) {
529
+ for (let y = 0; y < grid.ny; y++) {
530
+ for (let x = 0; x < grid.nx; x++) {
531
+ const onBoundary =
532
+ x === 0 ||
533
+ y === 0 ||
534
+ z === 0 ||
535
+ x === grid.nx - 1 ||
536
+ y === grid.ny - 1 ||
537
+ z === grid.nz - 1;
538
+ if (onBoundary) push(at(grid, x, y, z));
539
+ }
540
+ }
541
+ }
542
+
543
+ while (head < tail) {
544
+ const index = queue[head++] as number;
545
+ const x = index % grid.nx;
546
+ const y = Math.floor(index / grid.nx) % grid.ny;
547
+ const z = Math.floor(index / (grid.nx * grid.ny));
548
+ if (x > 0) push(index - 1);
549
+ if (x < grid.nx - 1) push(index + 1);
550
+ if (y > 0) push(index - grid.nx);
551
+ if (y < grid.ny - 1) push(index + grid.nx);
552
+ if (z > 0) push(index - grid.nx * grid.ny);
553
+ if (z < grid.nz - 1) push(index + grid.nx * grid.ny);
554
+ }
555
+
556
+ for (let i = 0; i < state.length; i++) {
557
+ if (state[i] === SURFACE) continue;
558
+ state[i] = visited[i] === 1 ? OUTSIDE : INSIDE;
559
+ }
560
+ }
561
+
562
+ /** The regions a grid was grown into, and the bookkeeping merging needs. */
563
+ interface Regions {
564
+ /** Region id per cell, or -1 where the cell is not solid. */
565
+ readonly of: Int32Array;
566
+ /** Cells in each region. */
567
+ count: Int32Array;
568
+ /** Each region's cell-space bounds, six entries a region. */
569
+ bounds: Int32Array;
570
+ /** Union-find, so a merged region keeps answering to the ids that fed it. */
571
+ parent: Int32Array;
572
+ /** Each live region's neighbours, by id. */
573
+ neighbours: Set<number>[];
574
+ /**
575
+ * Each region's furthest point along every direction of `DIRECTIONS`, and how far.
576
+ *
577
+ * **Exact, and cheap, because a region is always a union of boxes.** A box's extreme point along
578
+ * any direction is one of its eight corners, so a seed's whole support set comes from eight
579
+ * points, and a merged region's is the better of its two parents' — no walk over cells, at any
580
+ * stage. This is what makes a true hull affordable where the merge needs one.
581
+ */
582
+ supports: Float64Array;
583
+ bestDot: Float64Array;
584
+ live: number;
585
+ total: number;
586
+ }
587
+
588
+ /**
589
+ * Every solid cell claimed by a maximal box of solid cells.
590
+ *
591
+ * **Seeds are exact boxes, and that is what makes the result stable.** The first version of this
592
+ * grew a region by breadth-first search while its own bounding box stayed 95% full, which sounds
593
+ * like the same thing and is not: on a curved wall it produced regions that filled their box well
594
+ * enough to be accepted while wrapping far enough around the ring that the hull over them enclosed
595
+ * the cavity. The consequence was a decomposition that was excellent at one resolution and useless
596
+ * at the next — measured on a twenty-four-sided cup, **0.2% bloat at resolution 64 and 118% at 80**,
597
+ * with nothing between them to explain it. A box is convex, entirely, always, so a seed's hull is
598
+ * the seed and no resolution can make it otherwise. Every instability above came out of the seeding
599
+ * rather than out of the merge that follows it.
600
+ *
601
+ * **Cells are visited in index order, x fastest**, and each unclaimed one starts a box that extends
602
+ * a whole layer at a time in a fixed direction order while every cell of that layer is solid and
603
+ * unclaimed. A layer is accepted or it is not; a partial one is never taken, which is the property
604
+ * that keeps the box solid.
605
+ */
606
+ function growRegions(grid: Grid, solid: Uint8Array, concavity: number): Regions {
607
+ void concavity;
608
+ const of = new Int32Array(solid.length).fill(-1);
609
+ const count: number[] = [];
610
+ const bounds: number[] = [];
611
+
612
+ /** Whether every cell of an inclusive box is solid and unclaimed. */
613
+ const layerFree = (
614
+ x0: number,
615
+ y0: number,
616
+ z0: number,
617
+ x1: number,
618
+ y1: number,
619
+ z1: number,
620
+ ): boolean => {
621
+ if (x0 < 0 || y0 < 0 || z0 < 0) return false;
622
+ if (x1 >= grid.nx || y1 >= grid.ny || z1 >= grid.nz) return false;
623
+ for (let z = z0; z <= z1; z++) {
624
+ for (let y = y0; y <= y1; y++) {
625
+ for (let x = x0; x <= x1; x++) {
626
+ const index = at(grid, x, y, z);
627
+ if (solid[index] !== 1 || of[index] !== -1) return false;
628
+ }
629
+ }
630
+ }
631
+ return true;
632
+ };
633
+
634
+ for (let seed = 0; seed < solid.length; seed++) {
635
+ if (solid[seed] !== 1 || of[seed] !== -1) continue;
636
+
637
+ const region = count.length;
638
+ let minX = seed % grid.nx;
639
+ let minY = Math.floor(seed / grid.nx) % grid.ny;
640
+ let minZ = Math.floor(seed / (grid.nx * grid.ny));
641
+ let maxX = minX;
642
+ let maxY = minY;
643
+ let maxZ = minZ;
644
+
645
+ /*
646
+ * Six directions, in a fixed order, repeated until a whole pass adds nothing. Fixed order and
647
+ * whole layers are together what make two machines produce the same boxes from the same grid.
648
+ */
649
+ for (let growing = true; growing;) {
650
+ growing = false;
651
+ if (layerFree(minX - 1, minY, minZ, minX - 1, maxY, maxZ)) {
652
+ minX--;
653
+ growing = true;
654
+ }
655
+ if (layerFree(maxX + 1, minY, minZ, maxX + 1, maxY, maxZ)) {
656
+ maxX++;
657
+ growing = true;
658
+ }
659
+ if (layerFree(minX, minY - 1, minZ, maxX, minY - 1, maxZ)) {
660
+ minY--;
661
+ growing = true;
662
+ }
663
+ if (layerFree(minX, maxY + 1, minZ, maxX, maxY + 1, maxZ)) {
664
+ maxY++;
665
+ growing = true;
666
+ }
667
+ if (layerFree(minX, minY, minZ - 1, maxX, maxY, minZ - 1)) {
668
+ minZ--;
669
+ growing = true;
670
+ }
671
+ if (layerFree(minX, minY, maxZ + 1, maxX, maxY, maxZ + 1)) {
672
+ maxZ++;
673
+ growing = true;
674
+ }
675
+ }
676
+
677
+ for (let z = minZ; z <= maxZ; z++) {
678
+ for (let y = minY; y <= maxY; y++) {
679
+ for (let x = minX; x <= maxX; x++) of[at(grid, x, y, z)] = region;
680
+ }
681
+ }
682
+ count.push((maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1));
683
+ bounds.push(minX, minY, minZ, maxX, maxY, maxZ);
684
+ }
685
+
686
+ const total = count.length;
687
+ const directions = SUPPORT_DIRECTIONS;
688
+ const supports = new Float64Array(total * directions * 3);
689
+ const bestDot = new Float64Array(total * directions).fill(-Infinity);
690
+ for (let region = 0; region < total; region++) {
691
+ const b = region * 6;
692
+ for (let corner = 0; corner < 8; corner++) {
693
+ const cx = (bounds[b + (corner & 1 ? 3 : 0)] as number) + (corner & 1 ? 1 : 0);
694
+ const cy = (bounds[b + (corner & 2 ? 4 : 1)] as number) + (corner & 2 ? 1 : 0);
695
+ const cz = (bounds[b + (corner & 4 ? 5 : 2)] as number) + (corner & 4 ? 1 : 0);
696
+ const px = grid.originX + cx * grid.cell;
697
+ const py = grid.originY + cy * grid.cell;
698
+ const pz = grid.originZ + cz * grid.cell;
699
+ offerSupport(supports, bestDot, region, px, py, pz);
700
+ }
701
+ }
702
+
703
+ const regions: Regions = {
704
+ of,
705
+ count: Int32Array.from(count),
706
+ bounds: Int32Array.from(bounds),
707
+ parent: new Int32Array(total).map((_, i) => i),
708
+ neighbours: [],
709
+ supports,
710
+ bestDot,
711
+ live: total,
712
+ total,
713
+ };
714
+ for (let i = 0; i < total; i++) regions.neighbours.push(new Set<number>());
715
+
716
+ /*
717
+ * Adjacency, from one walk over the grid looking only forward. Looking both ways would find every
718
+ * pair twice and the set would answer the same, so the walk is halved.
719
+ */
720
+ for (let z = 0; z < grid.nz; z++) {
721
+ for (let y = 0; y < grid.ny; y++) {
722
+ for (let x = 0; x < grid.nx; x++) {
723
+ const index = at(grid, x, y, z);
724
+ const a = of[index] as number;
725
+ if (a < 0) continue;
726
+ if (x + 1 < grid.nx) link(regions, a, of[index + 1] as number);
727
+ if (y + 1 < grid.ny) link(regions, a, of[index + grid.nx] as number);
728
+ if (z + 1 < grid.nz) link(regions, a, of[index + grid.nx * grid.ny] as number);
729
+ }
730
+ }
731
+ }
732
+ return regions;
733
+ }
734
+
735
+ /** Keep a point if it reaches further along any direction than what the region already had. */
736
+ function offerSupport(
737
+ supports: Float64Array,
738
+ bestDot: Float64Array,
739
+ region: number,
740
+ x: number,
741
+ y: number,
742
+ z: number,
743
+ ): void {
744
+ const directions = SUPPORT_DIRECTIONS;
745
+ for (let d = 0; d < directions; d++) {
746
+ const dot =
747
+ (DIRECTIONS[d * 3] as number) * x +
748
+ (DIRECTIONS[d * 3 + 1] as number) * y +
749
+ (DIRECTIONS[d * 3 + 2] as number) * z;
750
+ const k = region * directions + d;
751
+ if (dot <= (bestDot[k] as number)) continue;
752
+ bestDot[k] = dot;
753
+ supports[k * 3] = x;
754
+ supports[k * 3 + 1] = y;
755
+ supports[k * 3 + 2] = z;
756
+ }
757
+ }
758
+
759
+ function link(regions: Regions, a: number, b: number): void {
760
+ if (b < 0 || a === b) return;
761
+ (regions.neighbours[a] as Set<number>).add(b);
762
+ (regions.neighbours[b] as Set<number>).add(a);
763
+ }
764
+
765
+ function find(regions: Regions, id: number): number {
766
+ let root = id;
767
+ while ((regions.parent[root] as number) !== root) root = regions.parent[root] as number;
768
+ let walk = id;
769
+ while ((regions.parent[walk] as number) !== walk) {
770
+ const next = regions.parent[walk] as number;
771
+ regions.parent[walk] = root;
772
+ walk = next;
773
+ }
774
+ return root;
775
+ }
776
+
777
+ /**
778
+ * Neighbouring regions merged until the count is one a body can hold, then while it is free.
779
+ *
780
+ * **Two phases, measured two ways, and the split is the whole design.** Reaching the part budget
781
+ * takes as many merges as there are regions, which is thousands: those are ordered by the cheap box
782
+ * measure, which is good at saying *which* merge is least bad and useless at saying whether any
783
+ * merge is worth making. Going below the budget takes at most a handful, and each of those asks the
784
+ * real question — how much of the merged hull would not be solid — because that is the only measure
785
+ * that separates a convex solid, where merging costs nothing, from a cavity, where it costs the
786
+ * cavity.
787
+ *
788
+ * **The tie-break is the determinism.** Two candidates with the same cost are ordered by their lower
789
+ * region id and then their higher one. Without it a heap orders equal costs by insertion, and two
790
+ * runs that inserted in a different order decompose the same mesh differently.
791
+ */
792
+ function mergeRegions(
793
+ regions: Regions,
794
+ maxHulls: number,
795
+ concavity: number,
796
+ cellVolume: number,
797
+ ): void {
798
+ /*
799
+ * **Is the whole thing convex? Asked once, before anything is merged, because the pairwise merge
800
+ * below cannot answer it.** Greedy merging judges one pair at a time, and on a slanted solid every
801
+ * individual step looks bad while the end state is free: two boxes of a staircase have a notch
802
+ * between them, so their hull is a fifth empty and the merge is refused — even where merging *all*
803
+ * of them gives back the solid exactly.
804
+ *
805
+ * Measured on a tetrahedron, which is as convex as a shape can be: **sixteen parts and 20.3%
806
+ * bloat**, where the answer is one part and nothing. A crate, a wedge, a rock and a wheel are all
807
+ * this case, and it is much the most common thing a bake will meet.
808
+ *
809
+ * One hull over every region's supports answers it for the cost of a single `hullShape`.
810
+ */
811
+ if (regions.live > 1 && wholeConcavity(regions, cellVolume) <= concavity) {
812
+ for (let i = 1; i < regions.total; i++) {
813
+ const a = find(regions, 0);
814
+ const b = find(regions, i);
815
+ if (a !== b) merge(regions, a, b);
816
+ }
817
+ return;
818
+ }
819
+
820
+ const heap = new CandidateHeap();
821
+ for (let a = 0; a < regions.total; a++) {
822
+ for (const b of regions.neighbours[a] as Set<number>) {
823
+ if (b > a) heap.push(cost(regions, a, b), a, b);
824
+ }
825
+ }
826
+
827
+ while (regions.live > maxHulls) {
828
+ const top = heap.pop();
829
+ if (top === null) break;
830
+ const a = find(regions, top.a);
831
+ const b = find(regions, top.b);
832
+ if (a === b) continue;
833
+ /* A stale entry: one of the two has merged since, so its cost is no longer the pair's. */
834
+ const current = cost(regions, a, b);
835
+ if (current !== top.cost) {
836
+ heap.push(current, a, b);
837
+ continue;
838
+ }
839
+ merge(regions, a, b);
840
+ const root = find(regions, a);
841
+ for (const other of regions.neighbours[root] as Set<number>) {
842
+ heap.push(cost(regions, root, other), Math.min(root, other), Math.max(root, other));
843
+ }
844
+ }
845
+
846
+ /*
847
+ * The tail. At most `maxHulls` regions remain, so every adjacent pair can be measured properly and
848
+ * the answers cached; a merge invalidates only the pairs its region is part of.
849
+ */
850
+ const measured = new Map<number, number>();
851
+ const keyOf = (a: number, b: number): number =>
852
+ Math.min(a, b) * (regions.total + 1) + Math.max(a, b);
853
+
854
+ for (;;) {
855
+ let bestCost = Infinity;
856
+ let bestA = -1;
857
+ let bestB = -1;
858
+ for (let i = 0; i < regions.total; i++) {
859
+ const a = find(regions, i);
860
+ if (a !== i) continue;
861
+ for (const raw of regions.neighbours[a] as Set<number>) {
862
+ const b = find(regions, raw);
863
+ if (b === a) continue;
864
+ const lo = Math.min(a, b);
865
+ const hi = Math.max(a, b);
866
+ const key = keyOf(lo, hi);
867
+ let value = measured.get(key);
868
+ if (value === undefined) {
869
+ value = hullConcavity(regions, lo, hi, cellVolume);
870
+ measured.set(key, value);
871
+ }
872
+ /* Ties resolve by the pair's ids, for the reason the heap has a tie-break. */
873
+ if (
874
+ value < bestCost ||
875
+ (value === bestCost && (lo < bestA || (lo === bestA && hi < bestB)))
876
+ ) {
877
+ bestCost = value;
878
+ bestA = lo;
879
+ bestB = hi;
880
+ }
881
+ }
882
+ }
883
+ if (bestA < 0 || bestCost > concavity) break;
884
+ merge(regions, bestA, bestB);
885
+ if (regions.live <= 1) break;
886
+ const root = find(regions, bestA);
887
+ for (const key of [...measured.keys()]) {
888
+ const lo = Math.floor(key / (regions.total + 1));
889
+ const hi = key % (regions.total + 1);
890
+ if (find(regions, lo) === root || find(regions, hi) === root) measured.delete(key);
891
+ }
892
+ }
893
+ }
894
+
895
+ /**
896
+ * The fraction of the hull over two regions that would not be solid.
897
+ *
898
+ * **The real measure, and the reason the support sets exist.** A region is a union of boxes, so its
899
+ * extreme point along any direction is a corner of one of them, and the hull over those points is
900
+ * the hull over the region. Comparing that hull's volume against the cells actually held says
901
+ * exactly what merging would cost: nothing for two halves of a cylinder, and the whole cavity for
902
+ * two walls facing each other across one.
903
+ *
904
+ * Measured at 1.09 ms for a fifty-point hull, which is why `mergeRegions` asks it about a hundred
905
+ * and fifty times and not a hundred and fifty thousand.
906
+ */
907
+ function hullConcavity(regions: Regions, a: number, b: number, cellVolume: number): number {
908
+ const directions = SUPPORT_DIRECTIONS;
909
+ const points = new Float32Array(directions * 3);
910
+ for (let d = 0; d < directions; d++) {
911
+ const ka = a * directions + d;
912
+ const kb = b * directions + d;
913
+ const from = (regions.bestDot[kb] as number) > (regions.bestDot[ka] as number) ? kb : ka;
914
+ points[d * 3] = regions.supports[from * 3] as number;
915
+ points[d * 3 + 1] = regions.supports[from * 3 + 1] as number;
916
+ points[d * 3 + 2] = regions.supports[from * 3 + 2] as number;
917
+ }
918
+ const volume = shapeMassProperties(hullShape(points), 1, CONCAVITY_MASS).volume;
919
+ if (!(volume > 0)) return 1;
920
+ const solid = ((regions.count[a] as number) + (regions.count[b] as number)) * cellVolume;
921
+ return Math.max(0, (volume - solid) / volume);
922
+ }
923
+
924
+ /** The same question asked of every live region at once: is this solid already convex? */
925
+ function wholeConcavity(regions: Regions, cellVolume: number): number {
926
+ const directions = SUPPORT_DIRECTIONS;
927
+ const points = new Float32Array(directions * 3);
928
+ const best = new Float64Array(directions).fill(-Infinity);
929
+ let cells = 0;
930
+ for (let i = 0; i < regions.total; i++) {
931
+ if (find(regions, i) !== i) continue;
932
+ cells += regions.count[i] as number;
933
+ for (let d = 0; d < directions; d++) {
934
+ const k = i * directions + d;
935
+ if ((regions.bestDot[k] as number) <= (best[d] as number)) continue;
936
+ best[d] = regions.bestDot[k] as number;
937
+ points[d * 3] = regions.supports[k * 3] as number;
938
+ points[d * 3 + 1] = regions.supports[k * 3 + 1] as number;
939
+ points[d * 3 + 2] = regions.supports[k * 3 + 2] as number;
940
+ }
941
+ }
942
+ const volume = shapeMassProperties(hullShape(points), 1, CONCAVITY_MASS).volume;
943
+ if (!(volume > 0)) return 1;
944
+ return Math.max(0, (volume - cells * cellVolume) / volume);
945
+ }
946
+
947
+ /** Reused by `hullConcavity`, which the tail calls once per candidate pair. */
948
+ const CONCAVITY_MASS = createMassProperties();
949
+
950
+ /**
951
+ * How empty the box around two regions would be if they became one.
952
+ *
953
+ * **The cheap half of a two-part rule, and it only ever orders merges the budget forces.** Six
954
+ * integer compares and a divide, against a hull whose volume costs a millisecond, so this is what
955
+ * can be asked thousands of times while a mesh's regions come down to the part budget.
956
+ *
957
+ * **What it cannot answer is whether a merge is *free*, and three attempts to make it were wrong in
958
+ * three different ways.** A fixed threshold on this number can never join the last two halves of a
959
+ * cylinder, whose box is 21.5% empty however it is cut — a convex prism came back as eight parts.
960
+ * Scoring the change against the emptier parent fixes that and then runs away: each merge licenses
961
+ * the next against a laxer number, and a cup's wall merged around its own ring until the vessel was
962
+ * one hull holding 57% more than the cup. Anchoring the allowance so it cannot rise stops the
963
+ * runaway and brings back the eight-part prism. **A box cannot tell a convex solid from a cavity**,
964
+ * and that is not a rule to be tuned into existence. `hullConcavity` is what answers it, and
965
+ * `mergeRegions` calls it only where the count is small enough to afford one.
966
+ */
967
+ function cost(regions: Regions, a: number, b: number): number {
968
+ const ab = a * 6;
969
+ const bb = b * 6;
970
+ const minX = Math.min(regions.bounds[ab] as number, regions.bounds[bb] as number);
971
+ const minY = Math.min(regions.bounds[ab + 1] as number, regions.bounds[bb + 1] as number);
972
+ const minZ = Math.min(regions.bounds[ab + 2] as number, regions.bounds[bb + 2] as number);
973
+ const maxX = Math.max(regions.bounds[ab + 3] as number, regions.bounds[bb + 3] as number);
974
+ const maxY = Math.max(regions.bounds[ab + 4] as number, regions.bounds[bb + 4] as number);
975
+ const maxZ = Math.max(regions.bounds[ab + 5] as number, regions.bounds[bb + 5] as number);
976
+ const union = (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1);
977
+ const taken = (regions.count[a] as number) + (regions.count[b] as number);
978
+ return (union - taken) / union;
979
+ }
980
+
981
+ function merge(regions: Regions, a: number, b: number): void {
982
+ /* The lower id survives, so a decomposition's part order follows the grid walk that made it. */
983
+ const keep = Math.min(a, b);
984
+ const gone = Math.max(a, b);
985
+ regions.parent[gone] = keep;
986
+ regions.count[keep] = (regions.count[keep] as number) + (regions.count[gone] as number);
987
+
988
+ const kb = keep * 6;
989
+ const gb = gone * 6;
990
+ for (let i = 0; i < 3; i++) {
991
+ regions.bounds[kb + i] = Math.min(
992
+ regions.bounds[kb + i] as number,
993
+ regions.bounds[gb + i] as number,
994
+ );
995
+ regions.bounds[kb + 3 + i] = Math.max(
996
+ regions.bounds[kb + 3 + i] as number,
997
+ regions.bounds[gb + 3 + i] as number,
998
+ );
999
+ }
1000
+
1001
+ const directions = SUPPORT_DIRECTIONS;
1002
+ for (let d = 0; d < directions; d++) {
1003
+ const kk = keep * directions + d;
1004
+ const kg = gone * directions + d;
1005
+ if ((regions.bestDot[kg] as number) <= (regions.bestDot[kk] as number)) continue;
1006
+ regions.bestDot[kk] = regions.bestDot[kg] as number;
1007
+ regions.supports[kk * 3] = regions.supports[kg * 3] as number;
1008
+ regions.supports[kk * 3 + 1] = regions.supports[kg * 3 + 1] as number;
1009
+ regions.supports[kk * 3 + 2] = regions.supports[kg * 3 + 2] as number;
1010
+ }
1011
+
1012
+ const into = regions.neighbours[keep] as Set<number>;
1013
+ for (const other of regions.neighbours[gone] as Set<number>) {
1014
+ const root = find(regions, other);
1015
+ if (root !== keep) into.add(root);
1016
+ }
1017
+ into.delete(gone);
1018
+ into.delete(keep);
1019
+ regions.neighbours[gone] = new Set<number>();
1020
+ regions.live--;
1021
+ }
1022
+
1023
+ /** A candidate merge, in a binary heap ordered by cost then by the pair's two ids. */
1024
+ class CandidateHeap {
1025
+ private costs: number[] = [];
1026
+ private as: number[] = [];
1027
+ private bs: number[] = [];
1028
+
1029
+ push(cost: number, a: number, b: number): void {
1030
+ this.costs.push(cost);
1031
+ this.as.push(a);
1032
+ this.bs.push(b);
1033
+ let i = this.costs.length - 1;
1034
+ while (i > 0) {
1035
+ const parent = (i - 1) >> 1;
1036
+ if (!this.less(i, parent)) break;
1037
+ this.swap(i, parent);
1038
+ i = parent;
1039
+ }
1040
+ }
1041
+
1042
+ pop(): { cost: number; a: number; b: number } | null {
1043
+ if (this.costs.length === 0) return null;
1044
+ const top = { cost: this.costs[0] as number, a: this.as[0] as number, b: this.bs[0] as number };
1045
+ const last = this.costs.length - 1;
1046
+ this.swap(0, last);
1047
+ this.costs.pop();
1048
+ this.as.pop();
1049
+ this.bs.pop();
1050
+ let i = 0;
1051
+ for (;;) {
1052
+ const left = i * 2 + 1;
1053
+ const right = left + 1;
1054
+ let best = i;
1055
+ if (left < this.costs.length && this.less(left, best)) best = left;
1056
+ if (right < this.costs.length && this.less(right, best)) best = right;
1057
+ if (best === i) break;
1058
+ this.swap(i, best);
1059
+ i = best;
1060
+ }
1061
+ return top;
1062
+ }
1063
+
1064
+ private less(i: number, j: number): boolean {
1065
+ const ci = this.costs[i] as number;
1066
+ const cj = this.costs[j] as number;
1067
+ if (ci !== cj) return ci < cj;
1068
+ const ai = this.as[i] as number;
1069
+ const aj = this.as[j] as number;
1070
+ if (ai !== aj) return ai < aj;
1071
+ return (this.bs[i] as number) < (this.bs[j] as number);
1072
+ }
1073
+
1074
+ private swap(i: number, j: number): void {
1075
+ const c = this.costs[i] as number;
1076
+ this.costs[i] = this.costs[j] as number;
1077
+ this.costs[j] = c;
1078
+ const a = this.as[i] as number;
1079
+ this.as[i] = this.as[j] as number;
1080
+ this.as[j] = a;
1081
+ const b = this.bs[i] as number;
1082
+ this.bs[i] = this.bs[j] as number;
1083
+ this.bs[j] = b;
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Each live region's extreme corners along `DIRECTIONS`, and the hull over them.
1089
+ *
1090
+ * One walk over the grid. A solid cell offers its eight corners to its region's support set, which
1091
+ * keeps whichever reach furthest along each direction; the set is then deduplicated by `hullShape`
1092
+ * itself, so a boxy region arrives as its eight corners and a rounded one as up to fifty.
1093
+ *
1094
+ * **Coverage is measured here rather than assumed**, by asking whether each cell's centre lies
1095
+ * inside the hull its own region produced. A support set is inscribed in the true hull of the
1096
+ * corners it was drawn from, so a cell at a corner no direction points at can fall outside — and
1097
+ * that is the one way this function loses material instead of adding it.
1098
+ */
1099
+ function buildParts(
1100
+ grid: Grid,
1101
+ solid: Uint8Array,
1102
+ regions: Regions,
1103
+ ): { parts: ConvexPart[]; covered: number } {
1104
+ const directions = SUPPORT_DIRECTIONS;
1105
+ const order: number[] = [];
1106
+ const slotOf = new Map<number, number>();
1107
+ for (let i = 0; i < regions.total; i++) {
1108
+ const root = find(regions, i);
1109
+ if (slotOf.has(root)) continue;
1110
+ slotOf.set(root, order.length);
1111
+ order.push(root);
1112
+ }
1113
+
1114
+ const best = new Float64Array(order.length * directions).fill(-Infinity);
1115
+ const point = new Float64Array(order.length * directions * 3);
1116
+
1117
+ for (let z = 0; z < grid.nz; z++) {
1118
+ for (let y = 0; y < grid.ny; y++) {
1119
+ for (let x = 0; x < grid.nx; x++) {
1120
+ const index = at(grid, x, y, z);
1121
+ if (solid[index] !== 1) continue;
1122
+ const slot = slotOf.get(find(regions, regions.of[index] as number)) as number;
1123
+ for (let corner = 0; corner < 8; corner++) {
1124
+ const cxCell = x + (corner & 1);
1125
+ const cyCell = y + (corner & 2 ? 1 : 0);
1126
+ const czCell = z + (corner & 4 ? 1 : 0);
1127
+ const px = grid.originX + cxCell * grid.cell;
1128
+ const py = grid.originY + cyCell * grid.cell;
1129
+ const pz = grid.originZ + czCell * grid.cell;
1130
+ for (let d = 0; d < directions; d++) {
1131
+ const dot =
1132
+ (DIRECTIONS[d * 3] as number) * px +
1133
+ (DIRECTIONS[d * 3 + 1] as number) * py +
1134
+ (DIRECTIONS[d * 3 + 2] as number) * pz;
1135
+ const k = slot * directions + d;
1136
+ if (dot <= (best[k] as number)) continue;
1137
+ best[k] = dot;
1138
+ point[k * 3] = px;
1139
+ point[k * 3 + 1] = py;
1140
+ point[k * 3 + 2] = pz;
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const cellVolume = grid.cell * grid.cell * grid.cell;
1148
+ /* A plane test wants a tolerance in the units the planes are in, which is the grid's own. */
1149
+ const slack = grid.cell * 1e-3;
1150
+ const mass = createMassProperties();
1151
+ const parts: ConvexPart[] = [];
1152
+ const shapes: ConvexShape[] = [];
1153
+ for (let slot = 0; slot < order.length; slot++) {
1154
+ const points = new Float32Array(directions * 3);
1155
+ for (let d = 0; d < directions; d++) {
1156
+ const k = (slot * directions + d) * 3;
1157
+ points[d * 3] = point[k] as number;
1158
+ points[d * 3 + 1] = point[k + 1] as number;
1159
+ points[d * 3 + 2] = point[k + 2] as number;
1160
+ }
1161
+ const shape = hullShape(points);
1162
+ shapes.push(shape);
1163
+ const volume = shapeMassProperties(shape, 1, mass).volume;
1164
+ const cells = regions.count[order[slot] as number] as number;
1165
+ parts.push({ points: shape.vertices, volume, fill: (cells * cellVolume) / volume });
1166
+ }
1167
+
1168
+ let covered = 0;
1169
+ for (let z = 0; z < grid.nz; z++) {
1170
+ for (let y = 0; y < grid.ny; y++) {
1171
+ for (let x = 0; x < grid.nx; x++) {
1172
+ const index = at(grid, x, y, z);
1173
+ if (solid[index] !== 1) continue;
1174
+ const slot = slotOf.get(find(regions, regions.of[index] as number)) as number;
1175
+ const px = grid.originX + (x + 0.5) * grid.cell;
1176
+ const py = grid.originY + (y + 0.5) * grid.cell;
1177
+ const pz = grid.originZ + (z + 0.5) * grid.cell;
1178
+ if (insideHull(shapes[slot] as ConvexShape, px, py, pz, slack)) covered++;
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ return { parts, covered };
1184
+ }
1185
+
1186
+ /** Whether a point is on the inner side of every one of a hull's face planes. */
1187
+ function insideHull(shape: ConvexShape, x: number, y: number, z: number, slack: number): boolean {
1188
+ const planes = shape.facePlanes;
1189
+ for (let i = 0; i + 3 < planes.length; i += 4) {
1190
+ const d =
1191
+ (planes[i] as number) * x +
1192
+ (planes[i + 1] as number) * y +
1193
+ (planes[i + 2] as number) * z -
1194
+ (planes[i + 3] as number);
1195
+ if (d > slack) return false;
1196
+ }
1197
+ return true;
1198
+ }