@driftengine/assets 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 (100) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +9 -0
  4. package/dist/assetPath.d.ts +51 -0
  5. package/dist/assetPath.js +59 -0
  6. package/dist/coarseLevel.d.ts +94 -0
  7. package/dist/coarseLevel.js +606 -0
  8. package/dist/dds.d.ts +41 -0
  9. package/dist/dds.js +425 -0
  10. package/dist/drawKey.d.ts +64 -0
  11. package/dist/drawKey.js +42 -0
  12. package/dist/drftLoader.d.ts +420 -0
  13. package/dist/drftLoader.js +996 -0
  14. package/dist/drftTextures.d.ts +87 -0
  15. package/dist/drftTextures.js +138 -0
  16. package/dist/fbx.d.ts +86 -0
  17. package/dist/fbx.js +792 -0
  18. package/dist/fbxHarness.d.ts +68 -0
  19. package/dist/fbxHarness.js +185 -0
  20. package/dist/fbxInflate.d.ts +60 -0
  21. package/dist/fbxInflate.js +204 -0
  22. package/dist/fbxMaterial.d.ts +87 -0
  23. package/dist/fbxMaterial.js +345 -0
  24. package/dist/fbxRig.d.ts +130 -0
  25. package/dist/fbxRig.js +601 -0
  26. package/dist/gltf.d.ts +242 -0
  27. package/dist/gltf.js +904 -0
  28. package/dist/gltfAccessor.d.ts +2 -0
  29. package/dist/gltfAccessor.js +106 -0
  30. package/dist/gltfSkin.d.ts +32 -0
  31. package/dist/gltfSkin.js +144 -0
  32. package/dist/iesPhotometry.d.ts +44 -0
  33. package/dist/iesPhotometry.js +94 -0
  34. package/dist/imageInfo.d.ts +31 -0
  35. package/dist/imageInfo.js +119 -0
  36. package/dist/index.d.ts +58 -0
  37. package/dist/index.js +132 -0
  38. package/dist/kn5.d.ts +158 -0
  39. package/dist/kn5.js +557 -0
  40. package/dist/loadProgress.d.ts +118 -0
  41. package/dist/loadProgress.js +1 -0
  42. package/dist/localise.d.ts +35 -0
  43. package/dist/localise.js +253 -0
  44. package/dist/obj.d.ts +54 -0
  45. package/dist/obj.js +335 -0
  46. package/dist/orient.d.ts +104 -0
  47. package/dist/orient.js +241 -0
  48. package/dist/radianceHdr.d.ts +36 -0
  49. package/dist/radianceHdr.js +172 -0
  50. package/dist/readModel.d.ts +153 -0
  51. package/dist/readModel.js +386 -0
  52. package/dist/recognise.d.ts +38 -0
  53. package/dist/recognise.js +97 -0
  54. package/dist/stl.d.ts +22 -0
  55. package/dist/stl.js +131 -0
  56. package/dist/tangentOrder.d.ts +24 -0
  57. package/dist/tangentOrder.js +31 -0
  58. package/dist/threemf.d.ts +46 -0
  59. package/dist/threemf.js +254 -0
  60. package/dist/uploadBudget.d.ts +82 -0
  61. package/dist/uploadBudget.js +86 -0
  62. package/dist/usd.d.ts +49 -0
  63. package/dist/usd.js +542 -0
  64. package/dist/weld.d.ts +44 -0
  65. package/dist/weld.js +237 -0
  66. package/dist/zip.d.ts +35 -0
  67. package/dist/zip.js +110 -0
  68. package/package.json +61 -0
  69. package/src/assetPath.ts +79 -0
  70. package/src/coarseLevel.ts +720 -0
  71. package/src/dds.ts +507 -0
  72. package/src/drawKey.ts +91 -0
  73. package/src/drftLoader.ts +1197 -0
  74. package/src/drftTextures.ts +153 -0
  75. package/src/fbx.ts +934 -0
  76. package/src/fbxHarness.ts +215 -0
  77. package/src/fbxInflate.ts +258 -0
  78. package/src/fbxMaterial.ts +410 -0
  79. package/src/fbxRig.ts +652 -0
  80. package/src/gltf.ts +1161 -0
  81. package/src/gltfAccessor.ts +127 -0
  82. package/src/gltfSkin.ts +210 -0
  83. package/src/iesPhotometry.ts +136 -0
  84. package/src/imageInfo.ts +130 -0
  85. package/src/index.ts +162 -0
  86. package/src/kn5.ts +705 -0
  87. package/src/loadProgress.ts +131 -0
  88. package/src/localise.ts +275 -0
  89. package/src/obj.ts +386 -0
  90. package/src/orient.ts +262 -0
  91. package/src/radianceHdr.ts +195 -0
  92. package/src/readModel.ts +515 -0
  93. package/src/recognise.ts +124 -0
  94. package/src/stl.ts +147 -0
  95. package/src/tangentOrder.ts +32 -0
  96. package/src/threemf.ts +303 -0
  97. package/src/uploadBudget.ts +90 -0
  98. package/src/usd.ts +642 -0
  99. package/src/weld.ts +264 -0
  100. package/src/zip.ts +130 -0
@@ -0,0 +1,720 @@
1
+ /**
2
+ * One coarse mesh standing in for a whole asset, so a load can start with an outline.
3
+ *
4
+ * docs/FORMAT.md §4.6 argues for this and states the two things that make it worth building:
5
+ * a coarse whole model has to *exist as geometry* before it can be drawn, so it is baked
6
+ * rather than derived at load; and **level 0 for the whole asset is worth more than level 0
7
+ * for each of 187 parts**, because a single decimated body reads as a car while 187 coarse
8
+ * fragments read as a mess. So this takes every mesh and returns exactly one.
9
+ *
10
+ * **The surface of an occupancy grid, not a cluster of vertices.** The first version of this
11
+ * clustered vertices onto a grid and connected three clusters wherever a source triangle
12
+ * spanned three cells, which is the classic Rossignac-Borrel decimation and was wrong for
13
+ * this job in a way that only showed on screen: nothing stops the three clusters of a
14
+ * triangle from lying on *different surfaces* of the model, so a car whose seats and engine
15
+ * bay share cells with its shell grew triangles straight through the bodywork. It was
16
+ * reported, correctly, as white cliffs.
17
+ *
18
+ * This marks every cell any surface passes through, then emits the boundary between the
19
+ * occupied cells and the empty ones. That boundary is **closed by construction** and cannot
20
+ * contain a triangle joining two sides of the model, because it is the outside of a solid
21
+ * rather than a decimation of a surface. Interior geometry disappears into cells that are
22
+ * already occupied, which is exactly what should happen to it in an outline.
23
+ *
24
+ * The blocky result is then pulled onto the model: each corner of the boundary sits at the
25
+ * average of the real surface inside the cells around it. So a cube grid becomes a shape
26
+ * that follows the car, at the resolution the caller asked for.
27
+ *
28
+ * Offline work in the baker, so it may allocate freely. Nothing here runs per frame.
29
+ */
30
+
31
+ import type { MeshData } from '@driftengine/drft';
32
+
33
+ export interface CoarseLevelOptions {
34
+ /**
35
+ * Cells along the asset's longest axis. Higher is finer and larger.
36
+ *
37
+ * The whole point of the level is that it arrives before the model, so it is measured in
38
+ * kilobytes rather than in fidelity: at 40 a five-metre car resolves to about twelve
39
+ * centimetres, which is a recognisable car for a few hundred kilobytes against seventy
40
+ * megabytes of file.
41
+ */
42
+ readonly cells?: number;
43
+ /**
44
+ * How far the outline is held below the surface, as a fraction of a cell.
45
+ *
46
+ * **This is what lets it stay on screen while the real parts land on top of it**, which is
47
+ * the reveal §4.6 describes: an outline, then its elements. A corner of the boundary sits at
48
+ * the average of the surface around it, so it is already close; this is the margin that
49
+ * keeps it from landing in the same plane as the part covering it and showing through in
50
+ * patches. Being a little inside the model costs nothing, because the model is about to
51
+ * cover it.
52
+ */
53
+ readonly inset?: number;
54
+ }
55
+
56
+ /**
57
+ * The grid this uses when nobody names a resolution, exported because two other places used
58
+ * to carry their own copy of the number and disagreed with it.
59
+ *
60
+ * The baker printed 32 and the browser's converter passed nothing, so one path wrote an
61
+ * outline at 32 cells and the other at 40, and the same model taken through the two arrived
62
+ * differently for no stated reason. A default that callers restate is a default in name only.
63
+ */
64
+ export const DEFAULT_COARSE_CELLS = 40;
65
+ const DEFAULT_CELLS = DEFAULT_COARSE_CELLS;
66
+ const MIN_CELLS = 4;
67
+ const MAX_CELLS = 192;
68
+ const DEFAULT_INSET = 0.3;
69
+
70
+ /**
71
+ * How finely a triangle bigger than a cell is sampled when marking cells it passes through.
72
+ *
73
+ * Marking only the three corners is right for the overwhelming majority of a real model,
74
+ * whose triangles are centimetres across against a cell of twelve. It is wrong for the few
75
+ * that are not: a backdrop plane or a wall is two triangles the size of the asset, and a hull
76
+ * built from their corners alone would have a hole the shape of the wall. So a triangle is
77
+ * walked at a spacing below one cell, which costs nothing on the small ones because a spacing
78
+ * below one cell is one step.
79
+ */
80
+ const MAX_SAMPLE_STEPS = 96;
81
+
82
+ /**
83
+ * How much the cells around a node must agree about which way the surface faces before the node
84
+ * is moved at all. See where it is used: it is what keeps a thin feature from sliding off itself.
85
+ */
86
+ const DIRECTED = 0.5;
87
+
88
+ /**
89
+ * How closely a surface must face the direction of travel before its plane is honoured.
90
+ *
91
+ * A surface at more than about seventy degrees to it is one the node moves *along* rather than
92
+ * toward, and the arithmetic divides by that alignment, so at ninety degrees it asks for an
93
+ * infinite push to get behind something it can never get behind by moving this way.
94
+ */
95
+ const MIN_ALIGN = 0.35;
96
+
97
+ function clamp(value: number, low: number, high: number): number {
98
+ return value < low ? low : value > high ? high : value;
99
+ }
100
+
101
+ /**
102
+ * What one cell of the grid knows about the surface inside it, as one flat accumulator.
103
+ *
104
+ * Position, normal, colour, emissive, then how many samples went in: `0..2`, `3..5`, `6..8`,
105
+ * `9`, `10`. One array rather than several because the cells are addressed by a slot and a
106
+ * second array indexed by the same slot is a second thing to keep in step.
107
+ */
108
+ const CELL_STRIDE = 11;
109
+ const AT_POSITION = 0;
110
+ const AT_NORMAL = 3;
111
+ const AT_COLOUR = 6;
112
+ const AT_EMISSIVE = 9;
113
+ const AT_COUNT = 10;
114
+
115
+ /**
116
+ * Build the coarse level, or `null` when there is nothing to build one from.
117
+ *
118
+ * `null` rather than an empty mesh: an asset with no extent, or one whose geometry all falls
119
+ * inside a single cell, has no outline worth a chunk in the file, and every caller would
120
+ * otherwise have to check the vertex count to find that out.
121
+ *
122
+ * Whether the result is *worth writing* is a separate question with a separate answer:
123
+ * `isOutlineWorthWriting`.
124
+ */
125
+ export function buildCoarseLevel(
126
+ meshes: readonly MeshData[],
127
+ options: CoarseLevelOptions = {},
128
+ ): MeshData | null {
129
+ const cells = clamp(Math.round(options.cells ?? DEFAULT_CELLS), MIN_CELLS, MAX_CELLS);
130
+ const inset = Math.max(0, options.inset ?? DEFAULT_INSET);
131
+
132
+ let minX = Infinity;
133
+ let minY = Infinity;
134
+ let minZ = Infinity;
135
+ let maxX = -Infinity;
136
+ let maxY = -Infinity;
137
+ let maxZ = -Infinity;
138
+ for (const mesh of meshes) {
139
+ for (let at = 0; at + 2 < mesh.positions.length; at += 3) {
140
+ const x = mesh.positions[at] as number;
141
+ const y = mesh.positions[at + 1] as number;
142
+ const z = mesh.positions[at + 2] as number;
143
+ if (x < minX) minX = x;
144
+ if (y < minY) minY = y;
145
+ if (z < minZ) minZ = z;
146
+ if (x > maxX) maxX = x;
147
+ if (y > maxY) maxY = y;
148
+ if (z > maxZ) maxZ = z;
149
+ }
150
+ }
151
+ if (!Number.isFinite(minX)) return null;
152
+
153
+ const cell = Math.max(maxX - minX, maxY - minY, maxZ - minZ) / cells;
154
+ if (!(cell > 0)) return null;
155
+
156
+ /* At least one cell on every axis, so a flat asset has a grid rather than none. */
157
+ const nx = Math.max(1, Math.ceil((maxX - minX) / cell));
158
+ const ny = Math.max(1, Math.ceil((maxY - minY) / cell));
159
+ const nz = Math.max(1, Math.ceil((maxZ - minZ) / cell));
160
+
161
+ /**
162
+ * Which cells hold surface, and what that surface is: summed position, normal, colour and
163
+ * emissive, then the sample count, so a cell can report the average of what passed through it.
164
+ */
165
+ const occupied = new Map<number, number>();
166
+ const cellSums: number[] = [];
167
+
168
+ const ixOf = (x: number): number => clamp(Math.floor((x - minX) / cell), 0, nx - 1);
169
+ const iyOf = (y: number): number => clamp(Math.floor((y - minY) / cell), 0, ny - 1);
170
+ const izOf = (z: number): number => clamp(Math.floor((z - minZ) / cell), 0, nz - 1);
171
+ const cellId = (ix: number, iy: number, iz: number): number => (iz * ny + iy) * nx + ix;
172
+
173
+ /** Mark the cell a point falls in, and fold the surface there into it. */
174
+ const mark = (
175
+ x: number,
176
+ y: number,
177
+ z: number,
178
+ nxs: number,
179
+ nys: number,
180
+ nzs: number,
181
+ r: number,
182
+ g: number,
183
+ b: number,
184
+ emissive: number,
185
+ ): void => {
186
+ const id = cellId(ixOf(x), iyOf(y), izOf(z));
187
+ let slot = occupied.get(id);
188
+ if (slot === undefined) {
189
+ slot = cellSums.length;
190
+ occupied.set(id, slot);
191
+ for (let i = 0; i < CELL_STRIDE; i++) cellSums.push(0);
192
+ }
193
+ cellSums[slot + AT_POSITION] = (cellSums[slot + AT_POSITION] as number) + x;
194
+ cellSums[slot + AT_POSITION + 1] = (cellSums[slot + AT_POSITION + 1] as number) + y;
195
+ cellSums[slot + AT_POSITION + 2] = (cellSums[slot + AT_POSITION + 2] as number) + z;
196
+ cellSums[slot + AT_NORMAL] = (cellSums[slot + AT_NORMAL] as number) + nxs;
197
+ cellSums[slot + AT_NORMAL + 1] = (cellSums[slot + AT_NORMAL + 1] as number) + nys;
198
+ cellSums[slot + AT_NORMAL + 2] = (cellSums[slot + AT_NORMAL + 2] as number) + nzs;
199
+ cellSums[slot + AT_COLOUR] = (cellSums[slot + AT_COLOUR] as number) + r;
200
+ cellSums[slot + AT_COLOUR + 1] = (cellSums[slot + AT_COLOUR + 1] as number) + g;
201
+ cellSums[slot + AT_COLOUR + 2] = (cellSums[slot + AT_COLOUR + 2] as number) + b;
202
+ cellSums[slot + AT_EMISSIVE] = (cellSums[slot + AT_EMISSIVE] as number) + emissive;
203
+ cellSums[slot + AT_COUNT] = (cellSums[slot + AT_COUNT] as number) + 1;
204
+ };
205
+
206
+ for (const mesh of meshes) {
207
+ const p = mesh.positions;
208
+ const n = mesh.normals;
209
+ const c = mesh.colors;
210
+ const e = mesh.emissive;
211
+ for (let at = 0; at + 2 < mesh.indices.length; at += 3) {
212
+ const i0 = mesh.indices[at] as number;
213
+ const i1 = mesh.indices[at + 1] as number;
214
+ const i2 = mesh.indices[at + 2] as number;
215
+ const ax = p[i0 * 3] as number,
216
+ ay = p[i0 * 3 + 1] as number,
217
+ az = p[i0 * 3 + 2] as number;
218
+ const bx = p[i1 * 3] as number,
219
+ by = p[i1 * 3 + 1] as number,
220
+ bz = p[i1 * 3 + 2] as number;
221
+ const cx = p[i2 * 3] as number,
222
+ cy = p[i2 * 3 + 1] as number,
223
+ cz = p[i2 * 3 + 2] as number;
224
+
225
+ /* One step for anything smaller than a cell, which is nearly everything. */
226
+ const longest = Math.max(
227
+ Math.hypot(bx - ax, by - ay, bz - az),
228
+ Math.hypot(cx - bx, cy - by, cz - bz),
229
+ Math.hypot(ax - cx, ay - cy, az - cz),
230
+ );
231
+ const steps = clamp(Math.ceil(longest / cell), 1, MAX_SAMPLE_STEPS);
232
+
233
+ for (let i = 0; i <= steps; i++) {
234
+ for (let j = 0; i + j <= steps; j++) {
235
+ const u = i / steps;
236
+ const v = j / steps;
237
+ const w = 1 - u - v;
238
+ mark(
239
+ ax * w + bx * u + cx * v,
240
+ ay * w + by * u + cy * v,
241
+ az * w + bz * u + cz * v,
242
+ (n[i0 * 3] as number) * w + (n[i1 * 3] as number) * u + (n[i2 * 3] as number) * v,
243
+ (n[i0 * 3 + 1] as number) * w +
244
+ (n[i1 * 3 + 1] as number) * u +
245
+ (n[i2 * 3 + 1] as number) * v,
246
+ (n[i0 * 3 + 2] as number) * w +
247
+ (n[i1 * 3 + 2] as number) * u +
248
+ (n[i2 * 3 + 2] as number) * v,
249
+ (c[i0 * 3] as number) * w + (c[i1 * 3] as number) * u + (c[i2 * 3] as number) * v,
250
+ (c[i0 * 3 + 1] as number) * w +
251
+ (c[i1 * 3 + 1] as number) * u +
252
+ (c[i2 * 3 + 1] as number) * v,
253
+ (c[i0 * 3 + 2] as number) * w +
254
+ (c[i1 * 3 + 2] as number) * u +
255
+ (c[i2 * 3 + 2] as number) * v,
256
+ (e[i0] as number) * w + (e[i1] as number) * u + (e[i2] as number) * v,
257
+ );
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ if (occupied.size === 0) return null;
264
+
265
+ return buildBoundary({ occupied, cellSums, nx, ny, nz, cell, minX, minY, minZ, inset });
266
+ }
267
+
268
+ /**
269
+ * Whether a built level is worth writing into a file as an outline.
270
+ *
271
+ * **A level of detail that is bigger than the model is not one.** The grid is a fixed number of
272
+ * cells across whatever it is given, so its cost follows the *shape* rather than the detail: four
273
+ * hand-written triangles come back as a 9,840-triangle shell, which is 308 KB of outline arriving
274
+ * ahead of a model measured in bytes. Everything an outline is for, being on screen before the
275
+ * geometry at a fraction of the download, is inverted.
276
+ *
277
+ * Found the day the default flipped from off to on, by a fixture. While an outline was asked for
278
+ * per asset by somebody who had looked, the case could not arise. A default has to hold for the
279
+ * assets nobody looks at, which is what makes it a default.
280
+ *
281
+ * **Separate from `buildCoarseLevel`, which stays a geometry function.** This is a budget, and a
282
+ * caller that wants the hull of a small mesh for some other purpose is entitled to it. It lives
283
+ * here rather than in the two callers because the baker and the browser's converter have to agree
284
+ * about what a file carries, and a rule stated twice is a rule until somebody edits one copy.
285
+ *
286
+ * Half rather than merely smaller: an outline that saves a few per cent is a chunk, an upload and
287
+ * a swap for no visible gain.
288
+ */
289
+ export function isOutlineWorthWriting(level: MeshData, meshes: readonly MeshData[]): boolean {
290
+ let sourceTriangles = 0;
291
+ for (const mesh of meshes) sourceTriangles += mesh.indices.length / 3;
292
+ return level.indices.length / 3 <= sourceTriangles / 2;
293
+ }
294
+
295
+ /** Everything the boundary pass needs, gathered so the two halves stay separable. */
296
+ interface Grid {
297
+ readonly occupied: Map<number, number>;
298
+ readonly cellSums: readonly number[];
299
+ readonly nx: number;
300
+ readonly ny: number;
301
+ readonly nz: number;
302
+ readonly cell: number;
303
+ readonly minX: number;
304
+ readonly minY: number;
305
+ readonly minZ: number;
306
+ readonly inset: number;
307
+ }
308
+
309
+ /**
310
+ * The surface between the occupied cells and the empty ones, pulled onto the model.
311
+ *
312
+ * Every quad is a face an occupied cell shares with a cell that holds nothing, wound so it
313
+ * faces out of the solid. Its four corners are grid nodes shared with the neighbouring faces,
314
+ * so the result is one welded, closed shell rather than a pile of squares — and because the
315
+ * corners then move to the average of the real surface around them, the shell follows the
316
+ * model instead of stepping around it.
317
+ */
318
+ function buildBoundary(grid: Grid): MeshData | null {
319
+ const { occupied, cellSums, nx, ny, nz, cell, minX, minY, minZ, inset } = grid;
320
+ const cellId = (ix: number, iy: number, iz: number): number => (iz * ny + iy) * nx + ix;
321
+ const holds = (ix: number, iy: number, iz: number): boolean =>
322
+ ix >= 0 &&
323
+ iy >= 0 &&
324
+ iz >= 0 &&
325
+ ix < nx &&
326
+ iy < ny &&
327
+ iz < nz &&
328
+ occupied.has(cellId(ix, iy, iz));
329
+
330
+ /**
331
+ * Whether a point in space falls in a cell that holds surface.
332
+ *
333
+ * Unclamped, deliberately: clamping a point that is outside the grid into the nearest cell
334
+ * would report the asset as occupying places it does not, which is the opposite of what this
335
+ * is asked for.
336
+ */
337
+ const holdsPoint = (x: number, y: number, z: number): boolean =>
338
+ holds(
339
+ Math.floor((x - minX) / cell),
340
+ Math.floor((y - minY) / cell),
341
+ Math.floor((z - minZ) / cell),
342
+ );
343
+
344
+ /** Node ids run over the grid's corners, one more than the cells on every axis. */
345
+ const nodeId = (ix: number, iy: number, iz: number): number =>
346
+ (iz * (ny + 1) + iy) * (nx + 1) + ix;
347
+ const nodes = new Map<number, number>();
348
+ const nodeAt: number[] = [];
349
+ const vertexOf = (ix: number, iy: number, iz: number): number => {
350
+ const id = nodeId(ix, iy, iz);
351
+ let found = nodes.get(id);
352
+ if (found === undefined) {
353
+ found = nodeAt.length / 3;
354
+ nodes.set(id, found);
355
+ nodeAt.push(ix, iy, iz);
356
+ }
357
+ return found;
358
+ };
359
+
360
+ /**
361
+ * The six faces, each as the four corner offsets in the order that faces outward.
362
+ *
363
+ * Written out rather than derived, because a face wound the wrong way is invisible in the
364
+ * source and shows up as a hole in the hull from one side only.
365
+ */
366
+ const FACES: readonly (readonly [number, number, number, readonly number[][]])[] = [
367
+ [
368
+ 1,
369
+ 0,
370
+ 0,
371
+ [
372
+ [1, 0, 0],
373
+ [1, 1, 0],
374
+ [1, 1, 1],
375
+ [1, 0, 1],
376
+ ],
377
+ ],
378
+ [
379
+ -1,
380
+ 0,
381
+ 0,
382
+ [
383
+ [0, 0, 0],
384
+ [0, 0, 1],
385
+ [0, 1, 1],
386
+ [0, 1, 0],
387
+ ],
388
+ ],
389
+ [
390
+ 0,
391
+ 1,
392
+ 0,
393
+ [
394
+ [0, 1, 0],
395
+ [0, 1, 1],
396
+ [1, 1, 1],
397
+ [1, 1, 0],
398
+ ],
399
+ ],
400
+ [
401
+ 0,
402
+ -1,
403
+ 0,
404
+ [
405
+ [0, 0, 0],
406
+ [1, 0, 0],
407
+ [1, 0, 1],
408
+ [0, 0, 1],
409
+ ],
410
+ ],
411
+ [
412
+ 0,
413
+ 0,
414
+ 1,
415
+ [
416
+ [0, 0, 1],
417
+ [1, 0, 1],
418
+ [1, 1, 1],
419
+ [0, 1, 1],
420
+ ],
421
+ ],
422
+ [
423
+ 0,
424
+ 0,
425
+ -1,
426
+ [
427
+ [0, 0, 0],
428
+ [0, 1, 0],
429
+ [1, 1, 0],
430
+ [1, 0, 0],
431
+ ],
432
+ ],
433
+ ];
434
+
435
+ /**
436
+ * Which empty cells can be reached from outside the asset, found by flooding inward.
437
+ *
438
+ * **This is what keeps the outline to the outside of the model, and it halves it.** A car body
439
+ * is a shell, so the cells it occupies have empty space on both sides, and a boundary drawn
440
+ * against every empty neighbour produces two shells: the one a viewer sees and a second one
441
+ * lining the cabin. The inner one is invisible, doubles the triangle count, and where the
442
+ * shell is a single cell thick both sheets snap to the same surface and fight over the depth
443
+ * buffer, which reads as dark speckle across the model.
444
+ *
445
+ * A cavity is exactly an empty region the flood cannot reach, so this is the whole test.
446
+ */
447
+ const outside = new Uint8Array(nx * ny * nz);
448
+ const queue: number[] = [];
449
+ for (let iz = 0; iz < nz; iz++) {
450
+ for (let iy = 0; iy < ny; iy++) {
451
+ for (let ix = 0; ix < nx; ix++) {
452
+ const onRim =
453
+ ix === 0 || iy === 0 || iz === 0 || ix === nx - 1 || iy === ny - 1 || iz === nz - 1;
454
+ if (!onRim) continue;
455
+ const id = cellId(ix, iy, iz);
456
+ if (occupied.has(id) || outside[id] === 1) continue;
457
+ outside[id] = 1;
458
+ queue.push(ix, iy, iz);
459
+ }
460
+ }
461
+ }
462
+ const STEPS: readonly (readonly [number, number, number])[] = [
463
+ [1, 0, 0],
464
+ [-1, 0, 0],
465
+ [0, 1, 0],
466
+ [0, -1, 0],
467
+ [0, 0, 1],
468
+ [0, 0, -1],
469
+ ];
470
+ /* Breadth first over an explicit list rather than by recursion: a grid of a hundred thousand
471
+ cells would be a hundred thousand frames deep in the worst case. */
472
+ for (let head = 0; head + 2 < queue.length; head += 3) {
473
+ const ix = queue[head] as number;
474
+ const iy = queue[head + 1] as number;
475
+ const iz = queue[head + 2] as number;
476
+ for (const [dx, dy, dz] of STEPS) {
477
+ const jx = ix + dx;
478
+ const jy = iy + dy;
479
+ const jz = iz + dz;
480
+ if (jx < 0 || jy < 0 || jz < 0 || jx >= nx || jy >= ny || jz >= nz) continue;
481
+ const id = cellId(jx, jy, jz);
482
+ if (occupied.has(id) || outside[id] === 1) continue;
483
+ outside[id] = 1;
484
+ queue.push(jx, jy, jz);
485
+ }
486
+ }
487
+
488
+ /** Whether this neighbour is open air a viewer could be standing in. */
489
+ const open = (ix: number, iy: number, iz: number): boolean => {
490
+ if (ix < 0 || iy < 0 || iz < 0 || ix >= nx || iy >= ny || iz >= nz) return true;
491
+ const id = cellId(ix, iy, iz);
492
+ return !occupied.has(id) && outside[id] === 1;
493
+ };
494
+
495
+ const triangles: number[] = [];
496
+ for (const id of occupied.keys()) {
497
+ const ix = id % nx;
498
+ const iy = Math.floor(id / nx) % ny;
499
+ const iz = Math.floor(id / (nx * ny));
500
+ for (const [dx, dy, dz, corners] of FACES) {
501
+ if (!open(ix + dx, iy + dy, iz + dz)) continue;
502
+ const a = vertexOf(
503
+ ix + (corners[0]?.[0] as number),
504
+ iy + (corners[0]?.[1] as number),
505
+ iz + (corners[0]?.[2] as number),
506
+ );
507
+ const b = vertexOf(
508
+ ix + (corners[1]?.[0] as number),
509
+ iy + (corners[1]?.[1] as number),
510
+ iz + (corners[1]?.[2] as number),
511
+ );
512
+ const c = vertexOf(
513
+ ix + (corners[2]?.[0] as number),
514
+ iy + (corners[2]?.[1] as number),
515
+ iz + (corners[2]?.[2] as number),
516
+ );
517
+ const d = vertexOf(
518
+ ix + (corners[3]?.[0] as number),
519
+ iy + (corners[3]?.[1] as number),
520
+ iz + (corners[3]?.[2] as number),
521
+ );
522
+ triangles.push(a, b, c, a, c, d);
523
+ }
524
+ }
525
+
526
+ const count = nodeAt.length / 3;
527
+ if (count < 3 || triangles.length === 0) return null;
528
+
529
+ const positions = new Float32Array(count * 3);
530
+ const normals = new Float32Array(count * 3);
531
+ const colors = new Float32Array(count * 3);
532
+ const emissive = new Float32Array(count);
533
+
534
+ for (let at = 0; at < count; at++) {
535
+ const ix = nodeAt[at * 3] as number;
536
+ const iy = nodeAt[at * 3 + 1] as number;
537
+ const iz = nodeAt[at * 3 + 2] as number;
538
+ /*
539
+ * The average of the real surface in the cells touching this node, which is what turns a
540
+ * staircase of cubes into the shape of the thing. Up to eight cells meet at a node and only
541
+ * the occupied ones have anything to say.
542
+ */
543
+ let px = 0,
544
+ py = 0,
545
+ pz = 0;
546
+ let nxs = 0,
547
+ nys = 0,
548
+ nzs = 0;
549
+ let r = 0,
550
+ g = 0,
551
+ b = 0,
552
+ glow = 0;
553
+ let found = 0;
554
+ /** The planes this node has to end up behind: one per occupied cell around it. */
555
+ const planes: number[] = [];
556
+ for (let ox = -1; ox <= 0; ox++) {
557
+ for (let oy = -1; oy <= 0; oy++) {
558
+ for (let oz = -1; oz <= 0; oz++) {
559
+ const cx = ix + ox;
560
+ const cy = iy + oy;
561
+ const cz = iz + oz;
562
+ if (cx < 0 || cy < 0 || cz < 0 || cx >= nx || cy >= ny || cz >= nz) continue;
563
+ const slot = occupied.get(cellId(cx, cy, cz));
564
+ if (slot === undefined) continue;
565
+ const samples = cellSums[slot + AT_COUNT] as number;
566
+ if (samples <= 0) continue;
567
+ const qx = (cellSums[slot + AT_POSITION] as number) / samples;
568
+ const qy = (cellSums[slot + AT_POSITION + 1] as number) / samples;
569
+ const qz = (cellSums[slot + AT_POSITION + 2] as number) / samples;
570
+ px += qx;
571
+ py += qy;
572
+ pz += qz;
573
+ /*
574
+ * Each cell's facing, as a unit vector, so the node's own facing is an average of
575
+ * directions rather than of magnitudes. A cell whose samples cancel has no facing to
576
+ * offer and is left out of both the average and the planes: it is a fold or a doubled
577
+ * face, and it has no side for anything to be behind.
578
+ */
579
+ let mx = cellSums[slot + AT_NORMAL] as number;
580
+ let my = cellSums[slot + AT_NORMAL + 1] as number;
581
+ let mz = cellSums[slot + AT_NORMAL + 2] as number;
582
+ const facing = Math.hypot(mx, my, mz);
583
+ if (facing > 1e-6) {
584
+ mx /= facing;
585
+ my /= facing;
586
+ mz /= facing;
587
+ nxs += mx;
588
+ nys += my;
589
+ nzs += mz;
590
+ planes.push(qx, qy, qz, mx, my, mz);
591
+ }
592
+ r += (cellSums[slot + AT_COLOUR] as number) / samples;
593
+ g += (cellSums[slot + AT_COLOUR + 1] as number) / samples;
594
+ b += (cellSums[slot + AT_COLOUR + 2] as number) / samples;
595
+ glow += (cellSums[slot + AT_EMISSIVE] as number) / samples;
596
+ found++;
597
+ }
598
+ }
599
+ }
600
+
601
+ if (found === 0) {
602
+ /* Cannot happen for a node of a boundary face, and if it ever does, the node's own grid
603
+ position is the honest answer rather than a division by zero. */
604
+ positions[at * 3] = minX + ix * cell;
605
+ positions[at * 3 + 1] = minY + iy * cell;
606
+ positions[at * 3 + 2] = minZ + iz * cell;
607
+ normals[at * 3 + 1] = 1;
608
+ continue;
609
+ }
610
+
611
+ px /= found;
612
+ py /= found;
613
+ pz /= found;
614
+ /*
615
+ * How much the cells around this node agree about which way the surface faces, which is the
616
+ * sum of unit facings over their count: one when they all agree, zero when they cancel.
617
+ *
618
+ * **A node that has no agreed facing is left exactly where it is**, and that is what keeps a
619
+ * thin feature intact. A wing mirror or a spoiler thinner than a cell has both of its faces
620
+ * inside the same cells, so there is no side to be behind, and pushing along whichever
621
+ * direction the arithmetic happened to produce would slide the outline off the part it
622
+ * stands for.
623
+ */
624
+ const agreement = Math.hypot(nxs, nys, nzs) / found;
625
+ const length = Math.hypot(nxs, nys, nzs);
626
+ if (length > 1e-6) {
627
+ nxs /= length;
628
+ nys /= length;
629
+ nzs /= length;
630
+ } else {
631
+ nxs = 0;
632
+ nys = 1;
633
+ nzs = 0;
634
+ }
635
+
636
+ /*
637
+ * **How far back this node has to sit, measured against the surfaces around it.**
638
+ *
639
+ * The outline stays on screen while the real parts land on top of it, so anywhere it ends up
640
+ * in *front* of the surface it stands for, it shows through the finished model. A constant
641
+ * cannot do this job: a smooth panel needs almost nothing and a concave crease needs most of
642
+ * a cell, because a node there averages two surfaces and lands in the air between them.
643
+ *
644
+ * So each surrounding cell states a plane and the node has to end up behind all of them. For
645
+ * a plane at `q` facing `m`, a node at `c - t·n` is behind it while
646
+ * `dot(c - q, m) <= t·dot(n, m)`, so the cell demands `t >= dot(c - q, m) / dot(n, m)` and
647
+ * the node takes the largest demand made of it. A cell facing nearly across the direction of
648
+ * travel is skipped rather than honoured: dividing by an alignment approaching zero asks for
649
+ * an unbounded push to clear a surface this node is sliding along rather than facing.
650
+ */
651
+ let behind = 0;
652
+ if (agreement >= DIRECTED) {
653
+ for (let plane = 0; plane + 5 < planes.length; plane += 6) {
654
+ const mx = planes[plane + 3] as number;
655
+ const my = planes[plane + 4] as number;
656
+ const mz = planes[plane + 5] as number;
657
+ const align = nxs * mx + nys * my + nzs * mz;
658
+ if (align < MIN_ALIGN) continue;
659
+ const overshoot =
660
+ (px - (planes[plane] as number)) * mx +
661
+ (py - (planes[plane + 1] as number)) * my +
662
+ (pz - (planes[plane + 2] as number)) * mz;
663
+ if (overshoot <= 0) continue;
664
+ const need = overshoot / align;
665
+ if (need > behind) behind = need;
666
+ }
667
+ /* Capped at a cell: a larger demand comes from a fold narrower than the grid, where no
668
+ position clears every surface at once, and honouring it would tear the hull. */
669
+ behind = Math.min(behind, cell);
670
+ /*
671
+ * **And bounded by the cells the asset actually occupies, which is the half that had to be
672
+ * measured to be found.** A demand comes from whichever surfaces happen to sit in the cells
673
+ * around a node, and a cell whose only surface is an interior lining facing downward
674
+ * demands a push *upward* — so the outline ended up standing 0.14 m above the roof of a car
675
+ * 1.49 m tall, which is 0.92 of a cell and was visible as a shape floating over the model.
676
+ *
677
+ * A push that leaves the occupied region is not a push behind anything, so it is halved
678
+ * until it lands inside or given up. The margin below is applied regardless, because at 0.3
679
+ * of a cell it is small enough to be worth the certainty of never coinciding with the
680
+ * surface it hides under.
681
+ */
682
+ for (let tries = 0; tries < 4 && behind > 0; tries++) {
683
+ if (holdsPoint(px - nxs * behind, py - nys * behind, pz - nzs * behind)) break;
684
+ behind = tries === 3 ? 0 : behind / 2;
685
+ }
686
+ /*
687
+ * The **demand** is bounded by occupancy and the margin is not, which is a deliberate split
688
+ * and the only one that serves both shapes this has to handle. A car is a shell inside a
689
+ * volume of parts, where a large push that leaves the occupied cells is going the wrong way.
690
+ * A bare floor meeting a bare wall is a shell and nothing else, where the push that clears
691
+ * both surfaces *has* to leave the two cells holding them — there is no solid to stay in.
692
+ * Bounding the demand fixes the first and bounding the margin as well would break the
693
+ * second, so the margin stays free at 0.3 of a cell.
694
+ */
695
+ behind += inset * cell;
696
+ }
697
+
698
+ positions[at * 3] = px - nxs * behind;
699
+ positions[at * 3 + 1] = py - nys * behind;
700
+ positions[at * 3 + 2] = pz - nzs * behind;
701
+ normals[at * 3] = nxs;
702
+ normals[at * 3 + 1] = nys;
703
+ normals[at * 3 + 2] = nzs;
704
+ colors[at * 3] = r / found;
705
+ colors[at * 3 + 1] = g / found;
706
+ colors[at * 3 + 2] = b / found;
707
+ emissive[at] = glow / found;
708
+ }
709
+
710
+ /*
711
+ * The four mandatory attributes and nothing else, deliberately.
712
+ *
713
+ * Colour is carried because a grey outline of a painted model is a worse first frame than no
714
+ * outline at all, and emissive because a lamp that glows in the model should glow in its
715
+ * outline. Everything optional is left absent, so the engine's own constants apply and the
716
+ * chunk stays small: a level of detail is a *silhouette*, and spending bytes on the roughness
717
+ * of a surface nobody will look at for half a second is the opposite of the point.
718
+ */
719
+ return { positions, normals, colors, emissive, indices: new Uint32Array(triangles) };
720
+ }