@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
package/dist/kn5.js ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Assetto Corsa's `.kn5`. Tier 2: a real attempt, and explicitly not a guarantee.
3
+ *
4
+ * The format has no published specification. What is implemented here was verified against three
5
+ * files from a production car mod, each of which parses to *exact EOF* — a binary walk that
6
+ * consumes a 44 MB file to its last byte has no field misread anywhere in it, because a single
7
+ * wrong width desynchronises everything after it. That is the evidence this reader rests on, and
8
+ * `scripts/kn5-check.mjs` re-establishes it on demand.
9
+ *
10
+ * **It needs nothing from its host.** A `.kn5` embeds every texture it uses, so unlike a `.gltf`
11
+ * naming external buffers or an `.fbx` storing deflated arrays, there is no capability to inject
12
+ * and no sidecar to resolve. It is the one binary format here that reads from bytes alone.
13
+ *
14
+ * It reports what the file says and corrects none of it: the frame comes back as `declaredHand`
15
+ * and `orient.ts` is what would transform it, for the same reason every other reader here reports
16
+ * `declaredUp` instead of standing its own models up.
17
+ */
18
+ import { DrftError } from '@driftengine/drft';
19
+ /** `Kn5Material.blendMode` when the surface belongs in the blended pass. */
20
+ export const KN5_ALPHA_BLEND = 1;
21
+ /**
22
+ * A position in the file that refuses instead of running off the end.
23
+ *
24
+ * Every read goes through here so that a malformed file fails with an offset somebody can look
25
+ * at, which is the whole difference between a tier 2 refusal and a parser crash.
26
+ */
27
+ export class Cursor {
28
+ view;
29
+ bytes;
30
+ at = 0;
31
+ constructor(buffer) {
32
+ this.view = new DataView(buffer);
33
+ this.bytes = new Uint8Array(buffer);
34
+ }
35
+ /** Refuses when `count` bytes are not there, naming where it ran out. */
36
+ need(count, what) {
37
+ if (count < 0 || this.at + count > this.bytes.length) {
38
+ throw new DrftError(`kn5: ${what} runs past the end of the file at offset ${this.at} ` +
39
+ `(wanted ${count} bytes, ${this.bytes.length - this.at} remain)`);
40
+ }
41
+ }
42
+ i32(what = 'an integer') {
43
+ this.need(4, what);
44
+ const value = this.view.getInt32(this.at, true);
45
+ this.at += 4;
46
+ return value;
47
+ }
48
+ u32(what = 'an integer') {
49
+ this.need(4, what);
50
+ const value = this.view.getUint32(this.at, true);
51
+ this.at += 4;
52
+ return value;
53
+ }
54
+ f32(what = 'a float') {
55
+ this.need(4, what);
56
+ const value = this.view.getFloat32(this.at, true);
57
+ this.at += 4;
58
+ return value;
59
+ }
60
+ u16(what = 'a short') {
61
+ this.need(2, what);
62
+ const value = this.view.getUint16(this.at, true);
63
+ this.at += 2;
64
+ return value;
65
+ }
66
+ u8(what = 'a byte') {
67
+ this.need(1, what);
68
+ return this.bytes[this.at++];
69
+ }
70
+ skip(count, what) {
71
+ this.need(count, what);
72
+ this.at += count;
73
+ }
74
+ take(count, what) {
75
+ this.need(count, what);
76
+ const slice = this.bytes.subarray(this.at, this.at + count);
77
+ this.at += count;
78
+ return slice;
79
+ }
80
+ /** i32 length, then that many UTF-8 bytes. */
81
+ str(what = 'a name') {
82
+ const length = this.i32(`the length of ${what}`);
83
+ return new TextDecoder().decode(this.take(length, what));
84
+ }
85
+ }
86
+ /** Bytes each material property carries after its value, and does not explain. */
87
+ const PROPERTY_TRAILER = 36;
88
+ /**
89
+ * The magic, the version, the texture block and the material palette.
90
+ *
91
+ * Split from the node walk because it is the half that is pure data: no recursion, no transforms,
92
+ * and nothing that needs a `MeshData` to exist.
93
+ */
94
+ export function readKn5Header(buffer) {
95
+ const cursor = new Cursor(buffer);
96
+ cursor.need(10, 'the kn5 header');
97
+ const magic = new TextDecoder().decode(cursor.take(6, 'the magic'));
98
+ if (magic !== 'sc6969') {
99
+ throw new DrftError(`kn5: this is not a kn5 file — it begins "${magic}" and not "sc6969"`);
100
+ }
101
+ const version = cursor.i32('the version');
102
+ /*
103
+ * Version 6 carries one more word here than version 5 did. It reads 0 in every file measured,
104
+ * and it is skipped by name instead of interpreted: a field whose meaning is unknown is better
105
+ * passed over deliberately than guessed at.
106
+ */
107
+ if (version > 5)
108
+ cursor.i32('the version 6 extra word');
109
+ const textures = [];
110
+ const textureCount = cursor.i32('the texture count');
111
+ for (let i = 0; i < textureCount; i++) {
112
+ cursor.i32(`the type of texture ${i}`);
113
+ const name = cursor.str(`the name of texture ${i}`);
114
+ const size = cursor.i32(`the size of texture "${name}"`);
115
+ textures.push({ name, bytes: cursor.take(size, `the bytes of texture "${name}"`) });
116
+ }
117
+ const materials = [];
118
+ const materialCount = cursor.i32('the material count');
119
+ for (let i = 0; i < materialCount; i++) {
120
+ const name = cursor.str(`the name of material ${i}`);
121
+ const shader = cursor.str(`the shader of material "${name}"`);
122
+ const blendMode = cursor.u8(`the blend mode of material "${name}"`);
123
+ const alphaTested = cursor.u8(`the alpha test flag of material "${name}"`) !== 0;
124
+ if (version > 4)
125
+ cursor.i32(`the depth mode of material "${name}"`);
126
+ const props = new Map();
127
+ const propCount = cursor.i32(`the property count of material "${name}"`);
128
+ for (let p = 0; p < propCount; p++) {
129
+ const property = cursor.str(`a property name of material "${name}"`);
130
+ props.set(property, cursor.f32(`the value of "${property}"`));
131
+ cursor.skip(PROPERTY_TRAILER, `the trailer of property "${property}"`);
132
+ }
133
+ const textureSlots = new Map();
134
+ const slotCount = cursor.i32(`the sampler count of material "${name}"`);
135
+ for (let s = 0; s < slotCount; s++) {
136
+ const sampler = cursor.str(`a sampler name of material "${name}"`);
137
+ cursor.i32(`the slot of sampler "${sampler}"`);
138
+ textureSlots.set(sampler, cursor.str(`the texture bound to "${sampler}"`));
139
+ }
140
+ materials.push({ name, shader, blendMode, alphaTested, props, textures: textureSlots });
141
+ }
142
+ return { version, textures, materials, cursor };
143
+ }
144
+ /** Node types the format defines. Anything else is a refusal, not a skip. */
145
+ const NODE_DUMMY = 1;
146
+ const NODE_MESH = 2;
147
+ const NODE_ANIMATED = 3;
148
+ /** pos[3] normal[3] uv[2] tangent[3], all f32. */
149
+ const MESH_VERTEX_BYTES = 44;
150
+ /** The same, plus weights[4] and bone indices[4]. */
151
+ const ANIMATED_VERTEX_BYTES = 76;
152
+ /** layer:u32 lodIn:f32 lodOut:f32 sphere[3]:f32 radius:f32 renderable:u8. */
153
+ const MESH_TRAILER = 29;
154
+ /** An animated mesh states a layer and a lod window and stops there. */
155
+ const ANIMATED_TRAILER = 12;
156
+ const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
157
+ /** Row-vector multiply: `out = a * b`, matching how the format composes a hierarchy. */
158
+ function multiply(a, b) {
159
+ const out = new Float32Array(16);
160
+ for (let row = 0; row < 4; row++) {
161
+ for (let column = 0; column < 4; column++) {
162
+ let sum = 0;
163
+ for (let k = 0; k < 4; k++)
164
+ sum += a[row * 4 + k] * b[k * 4 + column];
165
+ out[row * 4 + column] = sum;
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ /** A point through a row-vector matrix, translation included. */
171
+ function transformPoint(m, x, y, z) {
172
+ return [
173
+ x * m[0] + y * m[4] + z * m[8] + m[12],
174
+ x * m[1] + y * m[5] + z * m[9] + m[13],
175
+ x * m[2] + y * m[6] + z * m[10] + m[14],
176
+ ];
177
+ }
178
+ /** A direction through the same matrix: no translation, so normals and tangents share it. */
179
+ function transformDirection(m, x, y, z) {
180
+ return [
181
+ x * m[0] + y * m[4] + z * m[8],
182
+ x * m[1] + y * m[5] + z * m[9],
183
+ x * m[2] + y * m[6] + z * m[10],
184
+ ];
185
+ }
186
+ /**
187
+ * Walk the node tree depth first, which is the order the file stores it in.
188
+ *
189
+ * **Every node is kept, including the `AC_`-prefixed ones.** The community converter skips them,
190
+ * correctly for its purpose, since they carry no geometry an OBJ can hold. They are placement
191
+ * anchors and convention markers, and this reader exists to preserve exactly that.
192
+ */
193
+ export function readKn5Nodes(header) {
194
+ const { cursor } = header;
195
+ const nodes = [];
196
+ const meshes = [];
197
+ const worlds = [];
198
+ const readNode = (parent) => {
199
+ const startedAt = cursor.at;
200
+ const type = cursor.i32('a node type');
201
+ const name = cursor.str('a node name');
202
+ const childCount = cursor.i32(`the child count of "${name}"`);
203
+ cursor.u8(`the active flag of "${name}"`);
204
+ let matrix = IDENTITY;
205
+ let mesh = -1;
206
+ let flags = { castShadows: false, isVisible: true, isTransparent: false };
207
+ if (type === NODE_DUMMY) {
208
+ const local = new Float32Array(16);
209
+ for (let i = 0; i < 16; i++)
210
+ local[i] = cursor.f32(`the transform of "${name}"`);
211
+ matrix = local;
212
+ }
213
+ else if (type === NODE_MESH || type === NODE_ANIMATED) {
214
+ flags = {
215
+ castShadows: cursor.u8(`the shadow flag of "${name}"`) !== 0,
216
+ isVisible: cursor.u8(`the visibility flag of "${name}"`) !== 0,
217
+ isTransparent: cursor.u8(`the transparency flag of "${name}"`) !== 0,
218
+ };
219
+ if (type === NODE_ANIMATED) {
220
+ /*
221
+ * Bones are read past by name and count, not interpreted. No file measured contains one,
222
+ * so a skin built from these would be a claim nothing here can support — the geometry is
223
+ * imported and the rig is left for a file that can prove it.
224
+ */
225
+ const boneCount = cursor.i32(`the bone count of "${name}"`);
226
+ for (let b = 0; b < boneCount; b++) {
227
+ const bone = cursor.str(`a bone name of "${name}"`);
228
+ cursor.skip(64, `the bind matrix of bone "${bone}"`);
229
+ }
230
+ }
231
+ mesh = meshes.length;
232
+ }
233
+ else {
234
+ throw new DrftError(`kn5: node type ${type} is not one this reader understands, at "${name}", ` +
235
+ `offset ${startedAt}. Known types are 1 (dummy), 2 (mesh) and 3 (animated mesh).`);
236
+ }
237
+ const index = nodes.length;
238
+ const world = parent < 0 ? matrix : multiply(matrix, worlds[parent]);
239
+ worlds.push(world);
240
+ nodes.push({ type, name, parent, matrix, mesh, ...flags, lodIn: 0, lodOut: 0 });
241
+ if (type === NODE_MESH || type === NODE_ANIMATED) {
242
+ const stride = type === NODE_MESH ? MESH_VERTEX_BYTES : ANIMATED_VERTEX_BYTES;
243
+ const vertexCount = cursor.i32(`the vertex count of "${name}"`);
244
+ cursor.need(vertexCount * stride, `the vertices of "${name}"`);
245
+ const positions = new Float32Array(vertexCount * 3);
246
+ const normals = new Float32Array(vertexCount * 3);
247
+ const uvs = new Float32Array(vertexCount * 2);
248
+ const tangents = new Float32Array(vertexCount * 4);
249
+ for (let v = 0; v < vertexCount; v++) {
250
+ const [px, py, pz] = transformPoint(world, cursor.f32(), cursor.f32(), cursor.f32());
251
+ positions[v * 3] = px;
252
+ positions[v * 3 + 1] = py;
253
+ positions[v * 3 + 2] = pz;
254
+ const [nx, ny, nz] = transformDirection(world, cursor.f32(), cursor.f32(), cursor.f32());
255
+ normals[v * 3] = nx;
256
+ normals[v * 3 + 1] = ny;
257
+ normals[v * 3 + 2] = nz;
258
+ uvs[v * 2] = cursor.f32();
259
+ /* kn5 stores v with the opposite origin from every other format here. */
260
+ uvs[v * 2 + 1] = 1 - cursor.f32();
261
+ const [tx, ty, tz] = transformDirection(world, cursor.f32(), cursor.f32(), cursor.f32());
262
+ tangents[v * 4] = tx;
263
+ tangents[v * 4 + 1] = ty;
264
+ tangents[v * 4 + 2] = tz;
265
+ /*
266
+ * The file states no bitangent sign. +1 is the convention every exporter in this pipeline
267
+ * writes, and `orient.ts` flips it where a mirror makes it wrong.
268
+ */
269
+ tangents[v * 4 + 3] = 1;
270
+ if (type === NODE_ANIMATED)
271
+ cursor.skip(32, `the skin weights of "${name}"`);
272
+ }
273
+ const indexCount = cursor.i32(`the index count of "${name}"`);
274
+ const indices = new Uint32Array(indexCount);
275
+ for (let i = 0; i < indexCount; i++)
276
+ indices[i] = cursor.u16(`an index of "${name}"`);
277
+ const material = cursor.i32(`the material of "${name}"`);
278
+ let lodIn = 0;
279
+ let lodOut = 0;
280
+ if (type === NODE_MESH) {
281
+ cursor.need(MESH_TRAILER, `the trailer of "${name}"`);
282
+ cursor.u32();
283
+ lodIn = cursor.f32();
284
+ lodOut = cursor.f32();
285
+ cursor.skip(16, `the bounding sphere of "${name}"`);
286
+ cursor.u8();
287
+ }
288
+ else {
289
+ cursor.need(ANIMATED_TRAILER, `the trailer of "${name}"`);
290
+ cursor.u32();
291
+ lodIn = cursor.f32();
292
+ lodOut = cursor.f32();
293
+ }
294
+ nodes[index] = { ...nodes[index], lodIn, lodOut };
295
+ meshes.push({ positions, normals, uvs, tangents, indices, material, node: index });
296
+ }
297
+ for (let c = 0; c < childCount; c++)
298
+ readNode(index);
299
+ };
300
+ readNode(-1);
301
+ return { nodes, meshes };
302
+ }
303
+ /**
304
+ * Shaders this reader maps deliberately. Anything else warns once, by name.
305
+ *
306
+ * **A list of names, and it decides nothing.** It used to carry a second list beside it — the
307
+ * shaders whose surfaces blend — and that list was wrong about a shipped car: all four of its
308
+ * tyres are `ksTyres`, which the list called transparent, while the file states `alphaBlendMode`
309
+ * 0 on the material and no transparency on the node. The file is right, it says so in two places,
310
+ * and a name is a guess where a field is a fact. So blending is read from the material now and
311
+ * this stays only for the warning, which is the thing a name genuinely can answer: a shader
312
+ * nobody here has seen is still geometry somebody wants.
313
+ */
314
+ const KNOWN_SHADERS = new Set([
315
+ 'ksPerPixelAlpha',
316
+ 'ksWindscreen',
317
+ 'ksTyres',
318
+ 'ksTree',
319
+ 'ksGrass',
320
+ 'ksFlags',
321
+ 'ksSkidMark',
322
+ 'ksPerPixel',
323
+ 'ksPerPixelNM',
324
+ 'ksPerPixelNM_UVMult',
325
+ 'ksPerPixelReflection',
326
+ 'ksPerPixelMultiMap',
327
+ 'ksPerPixelMultiMap_NMDetail',
328
+ 'ksPerPixelSimpleRefl',
329
+ 'ksMultilayer',
330
+ 'GL',
331
+ ]);
332
+ /**
333
+ * The opacity a blended surface takes, since `.kn5` states none of its own.
334
+ *
335
+ * `MATL` says a surface blends by carrying an opacity below 1, and that is the only channel the
336
+ * container has for the fact. A `.kn5` material states *whether* it blends and never by how much:
337
+ * the alpha is per texel, in the diffuse map, and the shader multiplies `uOpacity * coverage` —
338
+ * so a value just under 1 puts the surface in the blended pass and leaves the picture to the
339
+ * texture, which is what the file means. It is deliberately not 0.999 arrived at from anything in
340
+ * the file: the number is a flag, and this constant is where that is admitted.
341
+ */
342
+ const BLENDED_OPACITY = 0.999;
343
+ /** A Blinn-Phong exponent as a roughness, which is the standard inverse. */
344
+ function roughnessFromExponent(exponent) {
345
+ if (!(exponent > 0))
346
+ return 1;
347
+ return Math.min(1, Math.max(0, Math.sqrt(2 / (exponent + 2))));
348
+ }
349
+ /**
350
+ * Read a `.kn5` into meshes, materials and the hierarchy that placed them.
351
+ *
352
+ * Materials are duplicated per mesh because `MATL` is parallel to `MESH` by ordinal while `.kn5`
353
+ * addresses a shared palette — 70 entries against 172 meshes in the car this was built against.
354
+ * The palette entry keeps its name, so nothing is lost by the duplication.
355
+ */
356
+ export function kn5ToMeshes(buffer) {
357
+ const head = readKn5Header(buffer);
358
+ const { nodes, meshes: sources } = readKn5Nodes(head);
359
+ const warnings = [];
360
+ const textures = head.textures.map((texture) => ({
361
+ name: texture.name,
362
+ bytes: texture.bytes,
363
+ }));
364
+ const textureIndex = new Map(head.textures.map((texture, at) => [texture.name, at]));
365
+ /** A sampler's texture as an ordinal, or -1, warning once for a name the file did not carry. */
366
+ const bind = (material, sampler) => {
367
+ const named = material.textures.get(sampler);
368
+ if (named === undefined)
369
+ return -1;
370
+ const at = textureIndex.get(named);
371
+ if (at === undefined) {
372
+ warnings.push(`kn5: material "${material.name}" binds ${sampler} to "${named}", which the file does ` +
373
+ 'not carry. That surface loads without the map.');
374
+ return -1;
375
+ }
376
+ return at;
377
+ };
378
+ const warned = new Set();
379
+ const meshes = [];
380
+ const materials = [];
381
+ for (const source of sources) {
382
+ const node = nodes[source.node];
383
+ const material = head.materials[source.material];
384
+ if (material === undefined) {
385
+ throw new DrftError(`kn5: "${node.name}" names material ${source.material}, and the file carries ` +
386
+ `${head.materials.length}.`);
387
+ }
388
+ if (!KNOWN_SHADERS.has(material.shader) && !warned.has(material.shader)) {
389
+ warned.add(material.shader);
390
+ warnings.push(`kn5: shader "${material.shader}" is not one this reader maps, so surfaces using it take ` +
391
+ 'a default. Their geometry and textures are unaffected.');
392
+ }
393
+ if (material.textures.has('txDetail') && !warned.has('txDetail')) {
394
+ warned.add('txDetail');
395
+ warnings.push('kn5: this model uses detail maps, a second UV layer the container carries one of. ' +
396
+ 'Those surfaces lose their close-range texture.');
397
+ }
398
+ /*
399
+ * Said once, because a texture that is read past in silence is the failure this reader keeps
400
+ * making. `txMaps` is carried by nearly every AC material and none of it reaches the container.
401
+ */
402
+ if (material.textures.has('txMaps') && !warned.has('txMaps')) {
403
+ warned.add('txMaps');
404
+ warnings.push("kn5: this model's materials carry `txMaps`, which is not read: its channels are specular " +
405
+ 'intensity, reflection sharpness and reflection intensity, and the container has no ' +
406
+ 'packing for those. Each surface keeps the roughness its own `ksSpecularEXP` states.');
407
+ }
408
+ const property = (name, fallback) => material.props.get(name) ?? fallback;
409
+ /*
410
+ * Both statements are the file's own, so both are honoured: the material's blend mode is the
411
+ * surface's, the node's flag is this instance's, and they agree on 171 of 172 meshes in the
412
+ * car measured. Neither of them is `ksAlphaRef`, which is what this used to read.
413
+ */
414
+ const blended = material.blendMode === KN5_ALPHA_BLEND || node.isTransparent;
415
+ const diffuse = property('ksDiffuse', 0.6);
416
+ meshes.push({
417
+ positions: source.positions,
418
+ normals: source.normals,
419
+ uvs: source.uvs,
420
+ tangents: source.tangents,
421
+ indices: source.indices,
422
+ colors: new Float32Array(source.positions.length).fill(1),
423
+ emissive: new Float32Array(source.positions.length / 3).fill(property('ksEmissive', 0)),
424
+ });
425
+ materials.push({
426
+ name: material.name,
427
+ color: [diffuse, diffuse, diffuse],
428
+ specular: property('ksSpecular', 0.9),
429
+ roughness: roughnessFromExponent(property('ksSpecularEXP', 1)),
430
+ emissive: property('ksEmissive', 0),
431
+ emissiveColor: [-1, -1, -1],
432
+ opacity: blended ? BLENDED_OPACITY : 1,
433
+ /*
434
+ * **`ksAlphaRef` is an alpha test and was being read as an opacity**, so a transparent
435
+ * material stating the 0 that means "test nothing" came out invisible: nine meshes of a
436
+ * hundred and two on the car this was reported from, including all four tyres and all four
437
+ * rims. It is a threshold below which a fragment is discarded, which is what `cutout` is,
438
+ * and it only applies where the material says it is tested.
439
+ *
440
+ * **No file measured here sets that flag**, so this path is carried on the format's word
441
+ * rather than on evidence — stated plainly for the same reason the bone block is read past
442
+ * rather than interpreted. What the change fixes is not this line but the one above it.
443
+ */
444
+ cutout: material.alphaTested ? property('ksAlphaRef', 0) : 0,
445
+ albedo: bind(material, 'txDiffuse'),
446
+ normalMap: bind(material, 'txNormal'),
447
+ /*
448
+ * **`txMaps` is deliberately not bound here, and that is the correction of a real defect.**
449
+ *
450
+ * It was bound as `ormMap` for as long as this reader has existed, and the cost was measured
451
+ * from outside: every AC car's paint rendered at roughness 1.0 — the widest probe blur there
452
+ * is — while the file said 0.180. glTF's ORM is occlusion in R, roughness in G and metallic
453
+ * in B. Two independent references give AC's `txMaps` as specular intensity in R, reflection
454
+ * and specular *sharpness* in G, and reflection intensity in B. Sharpness is the inverse of
455
+ * roughness, so a G pinned at 255 — which is what three bundles' paint maps hold — is a file
456
+ * asking for the sharpest reflection available and was being read as the bluntest.
457
+ *
458
+ * **The half of that which needed no reference** is the line above: `roughness` is derived
459
+ * from `ksSpecularEXP` and is right, and the shader *replaces* it with the map's G rather
460
+ * than scaling it. So this reader computed the correct number and then discarded it for a
461
+ * channel whose meaning `occlusionStrength: 0` already said was not established — about R
462
+ * alone, while G and B were consumed as if the packing were known.
463
+ *
464
+ * Not binding it is not a claim about what `txMaps` means. It is declining to assert a
465
+ * packing that is not there, which is `recognise.ts`'s principle and this file's own.
466
+ *
467
+ * **What would reverse it:** a shader reference for `ksPerPixelMultiMap` settling the curve
468
+ * between sharpness and roughness, at which point the map's per-texel detail is worth
469
+ * carrying properly. `docs/IMPROVEMENTS.md` holds the measurements and both routes.
470
+ */
471
+ ormMap: -1,
472
+ emissiveMap: -1,
473
+ roughnessScale: 1,
474
+ metallicScale: 1,
475
+ /* Nothing is bound above, so there is no occlusion to scale either. */
476
+ occlusionStrength: 0,
477
+ reflectivity: property('fresnelMaxLevel', property('fresnelC', 0)),
478
+ });
479
+ }
480
+ /*
481
+ * **Right-handed, and this was measured rather than assumed.** The format is Assetto Corsa's and
482
+ * the simulator is a DirectX title, which is why this reader first declared the frame left-handed
483
+ * and mirrored every model it read. A consumer importing a second car found the mirror by eye —
484
+ * a badge reading backwards — and nothing numeric had caught it, because a mirrored car has the
485
+ * same bounds, the same triangle count and the same consistent winding as an unmirrored one.
486
+ *
487
+ * What settles it: the rear badge of a shipped car, rasterised out of the file with no conversion
488
+ * at all and viewed from behind, spells the model's name forwards, and spells it backwards
489
+ * through a negate-X. The rest of the file agrees. Up is `+y`; the front bumper, the headlights and the indicators
490
+ * are at `+z` and the tail lamps at `−z`; and the steering wheel, the driver's seat, the door
491
+ * the author named for the left side, the left mirror, and the driver's eye position that the
492
+ * simulator itself reads out of the car's own configuration are all at `+x`. In a right-handed
493
+ * frame with that up and that forward, `+x` is the car's left — so the file, read exactly as
494
+ * stored, is a left-hand-drive car.
495
+ */
496
+ return { meshes, materials, textures, nodes, warnings, declaredUp: '+y', declaredHand: 'right' };
497
+ }
498
+ /**
499
+ * Decompose each node's row-vector 4x4 into the translation, rotation and scale `DrftNode` holds.
500
+ *
501
+ * The container stores a hierarchy as TRS and not as a matrix, which is what lets a consumer
502
+ * animate one channel without disturbing the other two — a wheel spins about its own axis while
503
+ * its suspension travel writes only the translation.
504
+ */
505
+ export function toDrftNodes(nodes) {
506
+ return nodes.map((node) => {
507
+ const m = node.matrix;
508
+ const axis = (row) => [
509
+ m[row * 4],
510
+ m[row * 4 + 1],
511
+ m[row * 4 + 2],
512
+ ];
513
+ const length = (v) => Math.hypot(v[0], v[1], v[2]);
514
+ const scale = [length(axis(0)), length(axis(1)), length(axis(2))];
515
+ /*
516
+ * The rotation is read out of the scale-normalised basis. A zero-length axis leaves that row
517
+ * as identity: a degenerate basis has no rotation to recover, and inventing one would be
518
+ * worse than the identity it collapses to.
519
+ */
520
+ const basis = [0, 1, 2].map((row) => {
521
+ const v = axis(row);
522
+ const l = scale[row];
523
+ if (l === 0)
524
+ return [row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0];
525
+ return [v[0] / l, v[1] / l, v[2] / l];
526
+ });
527
+ const [x0, x1, x2] = basis[0];
528
+ const [y0, y1, y2] = basis[1];
529
+ const [z0, z1, z2] = basis[2];
530
+ const trace = x0 + y1 + z2;
531
+ let rotation;
532
+ if (trace > 0) {
533
+ const s = Math.sqrt(trace + 1) * 2;
534
+ rotation = [(y2 - z1) / s, (z0 - x2) / s, (x1 - y0) / s, s / 4];
535
+ }
536
+ else if (x0 > y1 && x0 > z2) {
537
+ const s = Math.sqrt(1 + x0 - y1 - z2) * 2;
538
+ rotation = [s / 4, (y0 + x1) / s, (z0 + x2) / s, (y2 - z1) / s];
539
+ }
540
+ else if (y1 > z2) {
541
+ const s = Math.sqrt(1 + y1 - x0 - z2) * 2;
542
+ rotation = [(y0 + x1) / s, s / 4, (z1 + y2) / s, (z0 - x2) / s];
543
+ }
544
+ else {
545
+ const s = Math.sqrt(1 + z2 - x0 - y1) * 2;
546
+ rotation = [(z0 + x2) / s, (z1 + y2) / s, s / 4, (x1 - y0) / s];
547
+ }
548
+ return {
549
+ parent: node.parent,
550
+ translation: [m[12], m[13], m[14]],
551
+ rotation,
552
+ scale,
553
+ mesh: node.mesh,
554
+ name: node.name,
555
+ };
556
+ });
557
+ }
@@ -0,0 +1,118 @@
1
+ import type { MeshHandle } from '@driftengine/core';
2
+ /**
3
+ * The vocabulary of a load in flight: which phase it is in, how far it has got, and what a
4
+ * finished part is.
5
+ *
6
+ * Separate from the loader because these three are what a consumer's progress bar is written
7
+ * against, and reading them should not mean paging through a thousand-line class.
8
+ */
9
+ /**
10
+ * Where a load has got to, in the order a file delivers it.
11
+ *
12
+ * These are the chunk kinds rather than invented steps, which is the argument docs/FORMAT.md
13
+ * §4.6 makes for the whole design: the format's own layout is the sequence a viewer sees.
14
+ */
15
+ export type DrftLoadPhase = 'idle' | 'connecting' | 'manifest'
16
+ /** A coarse whole model has landed: the asset is on screen, at the resolution it arrived at. */
17
+ | 'outline' | 'materials' | 'geometry' | 'textures' | 'ready' | 'absent' | 'failed';
18
+ /**
19
+ * How far a load has got, and every field here now answers about the *picture* rather than about
20
+ * the work.
21
+ *
22
+ * **This was not true until it was reported.** `imagesDone` counted an image when its bytes
23
+ * arrived, and the decode that follows is deliberately not awaited so a slow one never holds up
24
+ * the stream. So there was a window in which `fraction` was 1, every part was uploaded and
25
+ * `imagesDone` equalled `imagesTotal`, while the surfaces wearing those images still drew
26
+ * untextured. Measured by a consumer across a scenario switch: **one and three quarter seconds.**
27
+ *
28
+ * It cost more than a harness photographing the wrong frame, which is how it was found. The same
29
+ * consumer drives a loading bar off these counters, so the bar reached 100% and the model then
30
+ * visibly changed underneath somebody who had been told it was finished. Their argument for the
31
+ * trade is the one that decided it: a bar that sits still for a moment is honest about being
32
+ * busy, and a bar that finishes early and then lets the thing it loaded change is not, and nobody
33
+ * can tell the second one is happening.
34
+ *
35
+ * So an image counts when it is **on the GPU**. `fraction` reaches 1 when the picture is right,
36
+ * and the cost is that it pauses near the end while the last decodes finish. On a weighted bar
37
+ * that pause is not at 99%: parts and images carry separate shares, so it climbs to roughly two
38
+ * thirds on the uploads and waits there, and a bar that stops at two thirds reads as working
39
+ * where one that stops at 99% reads as stuck.
40
+ *
41
+ * `phase === 'ready'` remains the signal to *act* on, and is what anything judging the finished
42
+ * picture should gate on.
43
+ */
44
+ export interface DrftLoadProgress {
45
+ readonly phase: DrftLoadPhase;
46
+ /** 0 to 1, weighted by what the manifest says the file is actually made of. */
47
+ readonly fraction: number;
48
+ readonly partsDone: number;
49
+ readonly partsTotal: number;
50
+ /**
51
+ * Images that are **on the GPU**, not images whose bytes have arrived.
52
+ *
53
+ * A decode that fails counts too, because the alternative to counting it is a bar that can
54
+ * never finish as the price of one image that was never going to draw.
55
+ */
56
+ readonly imagesDone: number;
57
+ readonly imagesTotal: number;
58
+ readonly totalBytes: number;
59
+ /** Present only when the phase is `failed` or `absent`. */
60
+ readonly message: string;
61
+ }
62
+ /** One drawable piece of a loaded model. */
63
+ export interface DrftPart {
64
+ readonly mesh: MeshHandle;
65
+ /** Which of the asset's images this surface wears, or -1 for none. */
66
+ readonly albedo: number;
67
+ /** Which image carries its occlusion, roughness and metallic, or -1 for none. */
68
+ readonly orm: number;
69
+ /**
70
+ * Which image carries its surface-space normals, or -1 for none.
71
+ *
72
+ * **Carried by the container since `MATL` reached stride 72 and dropped here until 2026-08-23**,
73
+ * which is the whole of why a bought model looked smoother than the one its maker shipped: the
74
+ * baker wrote the index, the reader read it, the renderer has taken a normal map through
75
+ * `SurfaceMaterial.normal` since normal maps landed, and this was the one step that did not pass
76
+ * it on. Panel seams, stitching, tread and every other detail an exporter bakes into a normal
77
+ * map rather than into geometry was arriving and going nowhere.
78
+ */
79
+ readonly normal: number;
80
+ /**
81
+ * Which image carries its emission, or -1 for none.
82
+ *
83
+ * **The fourth and last index `MATL` carries, passed on from 2026-08-24.** It was written by the
84
+ * baker, read by the reader and dropped here for the same stretch the normal map was — but for a
85
+ * stated reason rather than by oversight: no renderer took an emissive map until now, so there
86
+ * was nowhere to pass it. `SurfaceMaterial.emissive` is that somewhere.
87
+ */
88
+ readonly emissive: number;
89
+ /**
90
+ * glTF's three factors, in the shape `SurfaceMaterial` takes them.
91
+ *
92
+ * Carried on the part rather than looked up from the material, because a caller building a
93
+ * `setMaterial` call has the part and not the index that produced it.
94
+ */
95
+ readonly roughnessScale: number;
96
+ readonly metallicScale: number;
97
+ readonly occlusionStrength: number;
98
+ readonly opacity: number;
99
+ readonly reflectivity: number;
100
+ /**
101
+ * Alpha below which this surface's fragments are discarded, 0 for one that discards nothing.
102
+ *
103
+ * The pair to `opacity` and not a weaker version of it: a cutout decides which texels of the
104
+ * surface exist, an opacity decides how much light passes through the ones that do. It is what
105
+ * `SurfaceMaterial.cutout` takes, and until `MATL` grew the field there was no way for a source
106
+ * format to say it — a masked grille arrived as a solid rectangle, and a vehicle format's alpha
107
+ * *test* was being spent on the opacity, which drew nine surfaces of a hundred and two as
108
+ * nothing at all.
109
+ */
110
+ readonly cutout: number;
111
+ /**
112
+ * 0 to 1 as this part arrives, for a caller that would rather it faded in than appeared.
113
+ *
114
+ * Advanced by `update`, so it follows the frame's own clock and holds when the frame holds.
115
+ * A caller that wants parts to pop in can ignore it; one that wants a build fades on it.
116
+ */
117
+ reveal: number;
118
+ }
@@ -0,0 +1 @@
1
+ export {};