@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,606 @@
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
+ * The grid this uses when nobody names a resolution, exported because two other places used
32
+ * to carry their own copy of the number and disagreed with it.
33
+ *
34
+ * The baker printed 32 and the browser's converter passed nothing, so one path wrote an
35
+ * outline at 32 cells and the other at 40, and the same model taken through the two arrived
36
+ * differently for no stated reason. A default that callers restate is a default in name only.
37
+ */
38
+ export const DEFAULT_COARSE_CELLS = 40;
39
+ const DEFAULT_CELLS = DEFAULT_COARSE_CELLS;
40
+ const MIN_CELLS = 4;
41
+ const MAX_CELLS = 192;
42
+ const DEFAULT_INSET = 0.3;
43
+ /**
44
+ * How finely a triangle bigger than a cell is sampled when marking cells it passes through.
45
+ *
46
+ * Marking only the three corners is right for the overwhelming majority of a real model,
47
+ * whose triangles are centimetres across against a cell of twelve. It is wrong for the few
48
+ * that are not: a backdrop plane or a wall is two triangles the size of the asset, and a hull
49
+ * built from their corners alone would have a hole the shape of the wall. So a triangle is
50
+ * walked at a spacing below one cell, which costs nothing on the small ones because a spacing
51
+ * below one cell is one step.
52
+ */
53
+ const MAX_SAMPLE_STEPS = 96;
54
+ /**
55
+ * How much the cells around a node must agree about which way the surface faces before the node
56
+ * is moved at all. See where it is used: it is what keeps a thin feature from sliding off itself.
57
+ */
58
+ const DIRECTED = 0.5;
59
+ /**
60
+ * How closely a surface must face the direction of travel before its plane is honoured.
61
+ *
62
+ * A surface at more than about seventy degrees to it is one the node moves *along* rather than
63
+ * toward, and the arithmetic divides by that alignment, so at ninety degrees it asks for an
64
+ * infinite push to get behind something it can never get behind by moving this way.
65
+ */
66
+ const MIN_ALIGN = 0.35;
67
+ function clamp(value, low, high) {
68
+ return value < low ? low : value > high ? high : value;
69
+ }
70
+ /**
71
+ * What one cell of the grid knows about the surface inside it, as one flat accumulator.
72
+ *
73
+ * Position, normal, colour, emissive, then how many samples went in: `0..2`, `3..5`, `6..8`,
74
+ * `9`, `10`. One array rather than several because the cells are addressed by a slot and a
75
+ * second array indexed by the same slot is a second thing to keep in step.
76
+ */
77
+ const CELL_STRIDE = 11;
78
+ const AT_POSITION = 0;
79
+ const AT_NORMAL = 3;
80
+ const AT_COLOUR = 6;
81
+ const AT_EMISSIVE = 9;
82
+ const AT_COUNT = 10;
83
+ /**
84
+ * Build the coarse level, or `null` when there is nothing to build one from.
85
+ *
86
+ * `null` rather than an empty mesh: an asset with no extent, or one whose geometry all falls
87
+ * inside a single cell, has no outline worth a chunk in the file, and every caller would
88
+ * otherwise have to check the vertex count to find that out.
89
+ *
90
+ * Whether the result is *worth writing* is a separate question with a separate answer:
91
+ * `isOutlineWorthWriting`.
92
+ */
93
+ export function buildCoarseLevel(meshes, options = {}) {
94
+ const cells = clamp(Math.round(options.cells ?? DEFAULT_CELLS), MIN_CELLS, MAX_CELLS);
95
+ const inset = Math.max(0, options.inset ?? DEFAULT_INSET);
96
+ let minX = Infinity;
97
+ let minY = Infinity;
98
+ let minZ = Infinity;
99
+ let maxX = -Infinity;
100
+ let maxY = -Infinity;
101
+ let maxZ = -Infinity;
102
+ for (const mesh of meshes) {
103
+ for (let at = 0; at + 2 < mesh.positions.length; at += 3) {
104
+ const x = mesh.positions[at];
105
+ const y = mesh.positions[at + 1];
106
+ const z = mesh.positions[at + 2];
107
+ if (x < minX)
108
+ minX = x;
109
+ if (y < minY)
110
+ minY = y;
111
+ if (z < minZ)
112
+ minZ = z;
113
+ if (x > maxX)
114
+ maxX = x;
115
+ if (y > maxY)
116
+ maxY = y;
117
+ if (z > maxZ)
118
+ maxZ = z;
119
+ }
120
+ }
121
+ if (!Number.isFinite(minX))
122
+ return null;
123
+ const cell = Math.max(maxX - minX, maxY - minY, maxZ - minZ) / cells;
124
+ if (!(cell > 0))
125
+ return null;
126
+ /* At least one cell on every axis, so a flat asset has a grid rather than none. */
127
+ const nx = Math.max(1, Math.ceil((maxX - minX) / cell));
128
+ const ny = Math.max(1, Math.ceil((maxY - minY) / cell));
129
+ const nz = Math.max(1, Math.ceil((maxZ - minZ) / cell));
130
+ /**
131
+ * Which cells hold surface, and what that surface is: summed position, normal, colour and
132
+ * emissive, then the sample count, so a cell can report the average of what passed through it.
133
+ */
134
+ const occupied = new Map();
135
+ const cellSums = [];
136
+ const ixOf = (x) => clamp(Math.floor((x - minX) / cell), 0, nx - 1);
137
+ const iyOf = (y) => clamp(Math.floor((y - minY) / cell), 0, ny - 1);
138
+ const izOf = (z) => clamp(Math.floor((z - minZ) / cell), 0, nz - 1);
139
+ const cellId = (ix, iy, iz) => (iz * ny + iy) * nx + ix;
140
+ /** Mark the cell a point falls in, and fold the surface there into it. */
141
+ const mark = (x, y, z, nxs, nys, nzs, r, g, b, emissive) => {
142
+ const id = cellId(ixOf(x), iyOf(y), izOf(z));
143
+ let slot = occupied.get(id);
144
+ if (slot === undefined) {
145
+ slot = cellSums.length;
146
+ occupied.set(id, slot);
147
+ for (let i = 0; i < CELL_STRIDE; i++)
148
+ cellSums.push(0);
149
+ }
150
+ cellSums[slot + AT_POSITION] = cellSums[slot + AT_POSITION] + x;
151
+ cellSums[slot + AT_POSITION + 1] = cellSums[slot + AT_POSITION + 1] + y;
152
+ cellSums[slot + AT_POSITION + 2] = cellSums[slot + AT_POSITION + 2] + z;
153
+ cellSums[slot + AT_NORMAL] = cellSums[slot + AT_NORMAL] + nxs;
154
+ cellSums[slot + AT_NORMAL + 1] = cellSums[slot + AT_NORMAL + 1] + nys;
155
+ cellSums[slot + AT_NORMAL + 2] = cellSums[slot + AT_NORMAL + 2] + nzs;
156
+ cellSums[slot + AT_COLOUR] = cellSums[slot + AT_COLOUR] + r;
157
+ cellSums[slot + AT_COLOUR + 1] = cellSums[slot + AT_COLOUR + 1] + g;
158
+ cellSums[slot + AT_COLOUR + 2] = cellSums[slot + AT_COLOUR + 2] + b;
159
+ cellSums[slot + AT_EMISSIVE] = cellSums[slot + AT_EMISSIVE] + emissive;
160
+ cellSums[slot + AT_COUNT] = cellSums[slot + AT_COUNT] + 1;
161
+ };
162
+ for (const mesh of meshes) {
163
+ const p = mesh.positions;
164
+ const n = mesh.normals;
165
+ const c = mesh.colors;
166
+ const e = mesh.emissive;
167
+ for (let at = 0; at + 2 < mesh.indices.length; at += 3) {
168
+ const i0 = mesh.indices[at];
169
+ const i1 = mesh.indices[at + 1];
170
+ const i2 = mesh.indices[at + 2];
171
+ const ax = p[i0 * 3], ay = p[i0 * 3 + 1], az = p[i0 * 3 + 2];
172
+ const bx = p[i1 * 3], by = p[i1 * 3 + 1], bz = p[i1 * 3 + 2];
173
+ const cx = p[i2 * 3], cy = p[i2 * 3 + 1], cz = p[i2 * 3 + 2];
174
+ /* One step for anything smaller than a cell, which is nearly everything. */
175
+ const longest = Math.max(Math.hypot(bx - ax, by - ay, bz - az), Math.hypot(cx - bx, cy - by, cz - bz), Math.hypot(ax - cx, ay - cy, az - cz));
176
+ const steps = clamp(Math.ceil(longest / cell), 1, MAX_SAMPLE_STEPS);
177
+ for (let i = 0; i <= steps; i++) {
178
+ for (let j = 0; i + j <= steps; j++) {
179
+ const u = i / steps;
180
+ const v = j / steps;
181
+ const w = 1 - u - v;
182
+ mark(ax * w + bx * u + cx * v, ay * w + by * u + cy * v, az * w + bz * u + cz * v, n[i0 * 3] * w + n[i1 * 3] * u + n[i2 * 3] * v, n[i0 * 3 + 1] * w +
183
+ n[i1 * 3 + 1] * u +
184
+ n[i2 * 3 + 1] * v, n[i0 * 3 + 2] * w +
185
+ n[i1 * 3 + 2] * u +
186
+ n[i2 * 3 + 2] * v, c[i0 * 3] * w + c[i1 * 3] * u + c[i2 * 3] * v, c[i0 * 3 + 1] * w +
187
+ c[i1 * 3 + 1] * u +
188
+ c[i2 * 3 + 1] * v, c[i0 * 3 + 2] * w +
189
+ c[i1 * 3 + 2] * u +
190
+ c[i2 * 3 + 2] * v, e[i0] * w + e[i1] * u + e[i2] * v);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (occupied.size === 0)
196
+ return null;
197
+ return buildBoundary({ occupied, cellSums, nx, ny, nz, cell, minX, minY, minZ, inset });
198
+ }
199
+ /**
200
+ * Whether a built level is worth writing into a file as an outline.
201
+ *
202
+ * **A level of detail that is bigger than the model is not one.** The grid is a fixed number of
203
+ * cells across whatever it is given, so its cost follows the *shape* rather than the detail: four
204
+ * hand-written triangles come back as a 9,840-triangle shell, which is 308 KB of outline arriving
205
+ * ahead of a model measured in bytes. Everything an outline is for, being on screen before the
206
+ * geometry at a fraction of the download, is inverted.
207
+ *
208
+ * Found the day the default flipped from off to on, by a fixture. While an outline was asked for
209
+ * per asset by somebody who had looked, the case could not arise. A default has to hold for the
210
+ * assets nobody looks at, which is what makes it a default.
211
+ *
212
+ * **Separate from `buildCoarseLevel`, which stays a geometry function.** This is a budget, and a
213
+ * caller that wants the hull of a small mesh for some other purpose is entitled to it. It lives
214
+ * here rather than in the two callers because the baker and the browser's converter have to agree
215
+ * about what a file carries, and a rule stated twice is a rule until somebody edits one copy.
216
+ *
217
+ * Half rather than merely smaller: an outline that saves a few per cent is a chunk, an upload and
218
+ * a swap for no visible gain.
219
+ */
220
+ export function isOutlineWorthWriting(level, meshes) {
221
+ let sourceTriangles = 0;
222
+ for (const mesh of meshes)
223
+ sourceTriangles += mesh.indices.length / 3;
224
+ return level.indices.length / 3 <= sourceTriangles / 2;
225
+ }
226
+ /**
227
+ * The surface between the occupied cells and the empty ones, pulled onto the model.
228
+ *
229
+ * Every quad is a face an occupied cell shares with a cell that holds nothing, wound so it
230
+ * faces out of the solid. Its four corners are grid nodes shared with the neighbouring faces,
231
+ * so the result is one welded, closed shell rather than a pile of squares — and because the
232
+ * corners then move to the average of the real surface around them, the shell follows the
233
+ * model instead of stepping around it.
234
+ */
235
+ function buildBoundary(grid) {
236
+ const { occupied, cellSums, nx, ny, nz, cell, minX, minY, minZ, inset } = grid;
237
+ const cellId = (ix, iy, iz) => (iz * ny + iy) * nx + ix;
238
+ const holds = (ix, iy, iz) => ix >= 0 &&
239
+ iy >= 0 &&
240
+ iz >= 0 &&
241
+ ix < nx &&
242
+ iy < ny &&
243
+ iz < nz &&
244
+ occupied.has(cellId(ix, iy, iz));
245
+ /**
246
+ * Whether a point in space falls in a cell that holds surface.
247
+ *
248
+ * Unclamped, deliberately: clamping a point that is outside the grid into the nearest cell
249
+ * would report the asset as occupying places it does not, which is the opposite of what this
250
+ * is asked for.
251
+ */
252
+ const holdsPoint = (x, y, z) => holds(Math.floor((x - minX) / cell), Math.floor((y - minY) / cell), Math.floor((z - minZ) / cell));
253
+ /** Node ids run over the grid's corners, one more than the cells on every axis. */
254
+ const nodeId = (ix, iy, iz) => (iz * (ny + 1) + iy) * (nx + 1) + ix;
255
+ const nodes = new Map();
256
+ const nodeAt = [];
257
+ const vertexOf = (ix, iy, iz) => {
258
+ const id = nodeId(ix, iy, iz);
259
+ let found = nodes.get(id);
260
+ if (found === undefined) {
261
+ found = nodeAt.length / 3;
262
+ nodes.set(id, found);
263
+ nodeAt.push(ix, iy, iz);
264
+ }
265
+ return found;
266
+ };
267
+ /**
268
+ * The six faces, each as the four corner offsets in the order that faces outward.
269
+ *
270
+ * Written out rather than derived, because a face wound the wrong way is invisible in the
271
+ * source and shows up as a hole in the hull from one side only.
272
+ */
273
+ const FACES = [
274
+ [
275
+ 1,
276
+ 0,
277
+ 0,
278
+ [
279
+ [1, 0, 0],
280
+ [1, 1, 0],
281
+ [1, 1, 1],
282
+ [1, 0, 1],
283
+ ],
284
+ ],
285
+ [
286
+ -1,
287
+ 0,
288
+ 0,
289
+ [
290
+ [0, 0, 0],
291
+ [0, 0, 1],
292
+ [0, 1, 1],
293
+ [0, 1, 0],
294
+ ],
295
+ ],
296
+ [
297
+ 0,
298
+ 1,
299
+ 0,
300
+ [
301
+ [0, 1, 0],
302
+ [0, 1, 1],
303
+ [1, 1, 1],
304
+ [1, 1, 0],
305
+ ],
306
+ ],
307
+ [
308
+ 0,
309
+ -1,
310
+ 0,
311
+ [
312
+ [0, 0, 0],
313
+ [1, 0, 0],
314
+ [1, 0, 1],
315
+ [0, 0, 1],
316
+ ],
317
+ ],
318
+ [
319
+ 0,
320
+ 0,
321
+ 1,
322
+ [
323
+ [0, 0, 1],
324
+ [1, 0, 1],
325
+ [1, 1, 1],
326
+ [0, 1, 1],
327
+ ],
328
+ ],
329
+ [
330
+ 0,
331
+ 0,
332
+ -1,
333
+ [
334
+ [0, 0, 0],
335
+ [0, 1, 0],
336
+ [1, 1, 0],
337
+ [1, 0, 0],
338
+ ],
339
+ ],
340
+ ];
341
+ /**
342
+ * Which empty cells can be reached from outside the asset, found by flooding inward.
343
+ *
344
+ * **This is what keeps the outline to the outside of the model, and it halves it.** A car body
345
+ * is a shell, so the cells it occupies have empty space on both sides, and a boundary drawn
346
+ * against every empty neighbour produces two shells: the one a viewer sees and a second one
347
+ * lining the cabin. The inner one is invisible, doubles the triangle count, and where the
348
+ * shell is a single cell thick both sheets snap to the same surface and fight over the depth
349
+ * buffer, which reads as dark speckle across the model.
350
+ *
351
+ * A cavity is exactly an empty region the flood cannot reach, so this is the whole test.
352
+ */
353
+ const outside = new Uint8Array(nx * ny * nz);
354
+ const queue = [];
355
+ for (let iz = 0; iz < nz; iz++) {
356
+ for (let iy = 0; iy < ny; iy++) {
357
+ for (let ix = 0; ix < nx; ix++) {
358
+ const onRim = ix === 0 || iy === 0 || iz === 0 || ix === nx - 1 || iy === ny - 1 || iz === nz - 1;
359
+ if (!onRim)
360
+ continue;
361
+ const id = cellId(ix, iy, iz);
362
+ if (occupied.has(id) || outside[id] === 1)
363
+ continue;
364
+ outside[id] = 1;
365
+ queue.push(ix, iy, iz);
366
+ }
367
+ }
368
+ }
369
+ const STEPS = [
370
+ [1, 0, 0],
371
+ [-1, 0, 0],
372
+ [0, 1, 0],
373
+ [0, -1, 0],
374
+ [0, 0, 1],
375
+ [0, 0, -1],
376
+ ];
377
+ /* Breadth first over an explicit list rather than by recursion: a grid of a hundred thousand
378
+ cells would be a hundred thousand frames deep in the worst case. */
379
+ for (let head = 0; head + 2 < queue.length; head += 3) {
380
+ const ix = queue[head];
381
+ const iy = queue[head + 1];
382
+ const iz = queue[head + 2];
383
+ for (const [dx, dy, dz] of STEPS) {
384
+ const jx = ix + dx;
385
+ const jy = iy + dy;
386
+ const jz = iz + dz;
387
+ if (jx < 0 || jy < 0 || jz < 0 || jx >= nx || jy >= ny || jz >= nz)
388
+ continue;
389
+ const id = cellId(jx, jy, jz);
390
+ if (occupied.has(id) || outside[id] === 1)
391
+ continue;
392
+ outside[id] = 1;
393
+ queue.push(jx, jy, jz);
394
+ }
395
+ }
396
+ /** Whether this neighbour is open air a viewer could be standing in. */
397
+ const open = (ix, iy, iz) => {
398
+ if (ix < 0 || iy < 0 || iz < 0 || ix >= nx || iy >= ny || iz >= nz)
399
+ return true;
400
+ const id = cellId(ix, iy, iz);
401
+ return !occupied.has(id) && outside[id] === 1;
402
+ };
403
+ const triangles = [];
404
+ for (const id of occupied.keys()) {
405
+ const ix = id % nx;
406
+ const iy = Math.floor(id / nx) % ny;
407
+ const iz = Math.floor(id / (nx * ny));
408
+ for (const [dx, dy, dz, corners] of FACES) {
409
+ if (!open(ix + dx, iy + dy, iz + dz))
410
+ continue;
411
+ const a = vertexOf(ix + corners[0]?.[0], iy + corners[0]?.[1], iz + corners[0]?.[2]);
412
+ const b = vertexOf(ix + corners[1]?.[0], iy + corners[1]?.[1], iz + corners[1]?.[2]);
413
+ const c = vertexOf(ix + corners[2]?.[0], iy + corners[2]?.[1], iz + corners[2]?.[2]);
414
+ const d = vertexOf(ix + corners[3]?.[0], iy + corners[3]?.[1], iz + corners[3]?.[2]);
415
+ triangles.push(a, b, c, a, c, d);
416
+ }
417
+ }
418
+ const count = nodeAt.length / 3;
419
+ if (count < 3 || triangles.length === 0)
420
+ return null;
421
+ const positions = new Float32Array(count * 3);
422
+ const normals = new Float32Array(count * 3);
423
+ const colors = new Float32Array(count * 3);
424
+ const emissive = new Float32Array(count);
425
+ for (let at = 0; at < count; at++) {
426
+ const ix = nodeAt[at * 3];
427
+ const iy = nodeAt[at * 3 + 1];
428
+ const iz = nodeAt[at * 3 + 2];
429
+ /*
430
+ * The average of the real surface in the cells touching this node, which is what turns a
431
+ * staircase of cubes into the shape of the thing. Up to eight cells meet at a node and only
432
+ * the occupied ones have anything to say.
433
+ */
434
+ let px = 0, py = 0, pz = 0;
435
+ let nxs = 0, nys = 0, nzs = 0;
436
+ let r = 0, g = 0, b = 0, glow = 0;
437
+ let found = 0;
438
+ /** The planes this node has to end up behind: one per occupied cell around it. */
439
+ const planes = [];
440
+ for (let ox = -1; ox <= 0; ox++) {
441
+ for (let oy = -1; oy <= 0; oy++) {
442
+ for (let oz = -1; oz <= 0; oz++) {
443
+ const cx = ix + ox;
444
+ const cy = iy + oy;
445
+ const cz = iz + oz;
446
+ if (cx < 0 || cy < 0 || cz < 0 || cx >= nx || cy >= ny || cz >= nz)
447
+ continue;
448
+ const slot = occupied.get(cellId(cx, cy, cz));
449
+ if (slot === undefined)
450
+ continue;
451
+ const samples = cellSums[slot + AT_COUNT];
452
+ if (samples <= 0)
453
+ continue;
454
+ const qx = cellSums[slot + AT_POSITION] / samples;
455
+ const qy = cellSums[slot + AT_POSITION + 1] / samples;
456
+ const qz = cellSums[slot + AT_POSITION + 2] / samples;
457
+ px += qx;
458
+ py += qy;
459
+ pz += qz;
460
+ /*
461
+ * Each cell's facing, as a unit vector, so the node's own facing is an average of
462
+ * directions rather than of magnitudes. A cell whose samples cancel has no facing to
463
+ * offer and is left out of both the average and the planes: it is a fold or a doubled
464
+ * face, and it has no side for anything to be behind.
465
+ */
466
+ let mx = cellSums[slot + AT_NORMAL];
467
+ let my = cellSums[slot + AT_NORMAL + 1];
468
+ let mz = cellSums[slot + AT_NORMAL + 2];
469
+ const facing = Math.hypot(mx, my, mz);
470
+ if (facing > 1e-6) {
471
+ mx /= facing;
472
+ my /= facing;
473
+ mz /= facing;
474
+ nxs += mx;
475
+ nys += my;
476
+ nzs += mz;
477
+ planes.push(qx, qy, qz, mx, my, mz);
478
+ }
479
+ r += cellSums[slot + AT_COLOUR] / samples;
480
+ g += cellSums[slot + AT_COLOUR + 1] / samples;
481
+ b += cellSums[slot + AT_COLOUR + 2] / samples;
482
+ glow += cellSums[slot + AT_EMISSIVE] / samples;
483
+ found++;
484
+ }
485
+ }
486
+ }
487
+ if (found === 0) {
488
+ /* Cannot happen for a node of a boundary face, and if it ever does, the node's own grid
489
+ position is the honest answer rather than a division by zero. */
490
+ positions[at * 3] = minX + ix * cell;
491
+ positions[at * 3 + 1] = minY + iy * cell;
492
+ positions[at * 3 + 2] = minZ + iz * cell;
493
+ normals[at * 3 + 1] = 1;
494
+ continue;
495
+ }
496
+ px /= found;
497
+ py /= found;
498
+ pz /= found;
499
+ /*
500
+ * How much the cells around this node agree about which way the surface faces, which is the
501
+ * sum of unit facings over their count: one when they all agree, zero when they cancel.
502
+ *
503
+ * **A node that has no agreed facing is left exactly where it is**, and that is what keeps a
504
+ * thin feature intact. A wing mirror or a spoiler thinner than a cell has both of its faces
505
+ * inside the same cells, so there is no side to be behind, and pushing along whichever
506
+ * direction the arithmetic happened to produce would slide the outline off the part it
507
+ * stands for.
508
+ */
509
+ const agreement = Math.hypot(nxs, nys, nzs) / found;
510
+ const length = Math.hypot(nxs, nys, nzs);
511
+ if (length > 1e-6) {
512
+ nxs /= length;
513
+ nys /= length;
514
+ nzs /= length;
515
+ }
516
+ else {
517
+ nxs = 0;
518
+ nys = 1;
519
+ nzs = 0;
520
+ }
521
+ /*
522
+ * **How far back this node has to sit, measured against the surfaces around it.**
523
+ *
524
+ * The outline stays on screen while the real parts land on top of it, so anywhere it ends up
525
+ * in *front* of the surface it stands for, it shows through the finished model. A constant
526
+ * cannot do this job: a smooth panel needs almost nothing and a concave crease needs most of
527
+ * a cell, because a node there averages two surfaces and lands in the air between them.
528
+ *
529
+ * So each surrounding cell states a plane and the node has to end up behind all of them. For
530
+ * a plane at `q` facing `m`, a node at `c - t·n` is behind it while
531
+ * `dot(c - q, m) <= t·dot(n, m)`, so the cell demands `t >= dot(c - q, m) / dot(n, m)` and
532
+ * the node takes the largest demand made of it. A cell facing nearly across the direction of
533
+ * travel is skipped rather than honoured: dividing by an alignment approaching zero asks for
534
+ * an unbounded push to clear a surface this node is sliding along rather than facing.
535
+ */
536
+ let behind = 0;
537
+ if (agreement >= DIRECTED) {
538
+ for (let plane = 0; plane + 5 < planes.length; plane += 6) {
539
+ const mx = planes[plane + 3];
540
+ const my = planes[plane + 4];
541
+ const mz = planes[plane + 5];
542
+ const align = nxs * mx + nys * my + nzs * mz;
543
+ if (align < MIN_ALIGN)
544
+ continue;
545
+ const overshoot = (px - planes[plane]) * mx +
546
+ (py - planes[plane + 1]) * my +
547
+ (pz - planes[plane + 2]) * mz;
548
+ if (overshoot <= 0)
549
+ continue;
550
+ const need = overshoot / align;
551
+ if (need > behind)
552
+ behind = need;
553
+ }
554
+ /* Capped at a cell: a larger demand comes from a fold narrower than the grid, where no
555
+ position clears every surface at once, and honouring it would tear the hull. */
556
+ behind = Math.min(behind, cell);
557
+ /*
558
+ * **And bounded by the cells the asset actually occupies, which is the half that had to be
559
+ * measured to be found.** A demand comes from whichever surfaces happen to sit in the cells
560
+ * around a node, and a cell whose only surface is an interior lining facing downward
561
+ * demands a push *upward* — so the outline ended up standing 0.14 m above the roof of a car
562
+ * 1.49 m tall, which is 0.92 of a cell and was visible as a shape floating over the model.
563
+ *
564
+ * A push that leaves the occupied region is not a push behind anything, so it is halved
565
+ * until it lands inside or given up. The margin below is applied regardless, because at 0.3
566
+ * of a cell it is small enough to be worth the certainty of never coinciding with the
567
+ * surface it hides under.
568
+ */
569
+ for (let tries = 0; tries < 4 && behind > 0; tries++) {
570
+ if (holdsPoint(px - nxs * behind, py - nys * behind, pz - nzs * behind))
571
+ break;
572
+ behind = tries === 3 ? 0 : behind / 2;
573
+ }
574
+ /*
575
+ * The **demand** is bounded by occupancy and the margin is not, which is a deliberate split
576
+ * and the only one that serves both shapes this has to handle. A car is a shell inside a
577
+ * volume of parts, where a large push that leaves the occupied cells is going the wrong way.
578
+ * A bare floor meeting a bare wall is a shell and nothing else, where the push that clears
579
+ * both surfaces *has* to leave the two cells holding them — there is no solid to stay in.
580
+ * Bounding the demand fixes the first and bounding the margin as well would break the
581
+ * second, so the margin stays free at 0.3 of a cell.
582
+ */
583
+ behind += inset * cell;
584
+ }
585
+ positions[at * 3] = px - nxs * behind;
586
+ positions[at * 3 + 1] = py - nys * behind;
587
+ positions[at * 3 + 2] = pz - nzs * behind;
588
+ normals[at * 3] = nxs;
589
+ normals[at * 3 + 1] = nys;
590
+ normals[at * 3 + 2] = nzs;
591
+ colors[at * 3] = r / found;
592
+ colors[at * 3 + 1] = g / found;
593
+ colors[at * 3 + 2] = b / found;
594
+ emissive[at] = glow / found;
595
+ }
596
+ /*
597
+ * The four mandatory attributes and nothing else, deliberately.
598
+ *
599
+ * Colour is carried because a grey outline of a painted model is a worse first frame than no
600
+ * outline at all, and emissive because a lamp that glows in the model should glow in its
601
+ * outline. Everything optional is left absent, so the engine's own constants apply and the
602
+ * chunk stays small: a level of detail is a *silhouette*, and spending bytes on the roughness
603
+ * of a surface nobody will look at for half a second is the opposite of the point.
604
+ */
605
+ return { positions, normals, colors, emissive, indices: new Uint32Array(triangles) };
606
+ }
package/dist/dds.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * DDS surfaces to RGBA: BC1, BC2, BC3 and BC5 in either header spelling, and the uncompressed
3
+ * layouts, by the masks their own header carries.
4
+ *
5
+ * **It exists because a third of a shipped vehicle's textures are in it.** The car this was built
6
+ * against carries 81 textures and 34 of them are DDS — 25 DXT5 and 9 DXT1 — which are its paint,
7
+ * its interior and most of its normal maps. `imageInfo.ts` identifies PNG, JPEG and WEBP, so
8
+ * without this the model imports correctly shaped and visibly half-painted.
9
+ *
10
+ * **It runs in the baker and never in a frame.** RGBA is larger than the compressed source it came
11
+ * from, and the bake re-encodes to whatever the texture pipeline wants; nothing ships a block
12
+ * decoder into a running game. That is what makes the cost acceptable and it is the only reason
13
+ * decoding to RGBA rather than uploading the blocks directly is the right call here.
14
+ *
15
+ * **Only the mips are dropped, deliberately.** A DDS carries a full chain and the container
16
+ * generates its own, so decoding level 0 and discarding the rest is not a loss — it is declining
17
+ * to carry two answers to the same question.
18
+ *
19
+ * **Both header spellings, as of 3.30.2.** A `DX10` header names the format in a 20-byte extension
20
+ * rather than in the four characters at byte 84, and the blocks behind it are the same blocks. This
21
+ * reader took the classic spelling and refused the other, which cost one consumer 20 textures of a
22
+ * single car — its paint, its wheels, its lamps, its plate and seven interior maps — and cost them
23
+ * as a *lighting* bug rather than as a missing texture: their baker substitutes a 1x1 white pixel,
24
+ * and white in an ORM map is roughness 1 **and** metallic 1, which in the flat shader leaves no
25
+ * diffuse and no sun term at all. The bodywork became a blurred mirror of the field it was parked
26
+ * in, and the report said so in those words.
27
+ *
28
+ * What it refuses, by name: BC7, BC6H, BC4, signed BC5, cube maps, arrays and volumes. A format
29
+ * identified and declined is worth more than one half-decoded, which is `recognise.ts`'s principle
30
+ * applied one level down — and the last three of those are refused *because* a classic header has
31
+ * no way to state them, so accepting one would answer with a picture instead of an error.
32
+ */
33
+ /** Whether these bytes are a DDS at all. The magic, and nothing inferred from a file name. */
34
+ export declare function isDds(bytes: Uint8Array): boolean;
35
+ /** What a decoded surface is: level 0, RGBA, eight bits a channel. */
36
+ export interface DdsImage {
37
+ readonly width: number;
38
+ readonly height: number;
39
+ readonly rgba: Uint8Array;
40
+ }
41
+ export declare function ddsToRgba(bytes: Uint8Array): DdsImage;