@driftengine/assets 4.6.1 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/blend.d.ts +25 -0
  3. package/dist/blend.js +43 -0
  4. package/dist/blendAnimation.d.ts +45 -0
  5. package/dist/blendAnimation.js +294 -0
  6. package/dist/blendData.d.ts +114 -0
  7. package/dist/blendData.js +366 -0
  8. package/dist/blendFile.d.ts +69 -0
  9. package/dist/blendFile.js +182 -0
  10. package/dist/blendGeometry.d.ts +45 -0
  11. package/dist/blendGeometry.js +210 -0
  12. package/dist/blendGltf.d.ts +65 -0
  13. package/dist/blendGltf.js +545 -0
  14. package/dist/blendMaterial.d.ts +68 -0
  15. package/dist/blendMaterial.js +545 -0
  16. package/dist/blendMatrix.d.ts +28 -0
  17. package/dist/blendMatrix.js +245 -0
  18. package/dist/blendMesh.d.ts +70 -0
  19. package/dist/blendMesh.js +409 -0
  20. package/dist/blendModifiers.d.ts +25 -0
  21. package/dist/blendModifiers.js +83 -0
  22. package/dist/blendNormals.d.ts +44 -0
  23. package/dist/blendNormals.js +458 -0
  24. package/dist/blendPacked.d.ts +35 -0
  25. package/dist/blendPacked.js +82 -0
  26. package/dist/blendScene.d.ts +50 -0
  27. package/dist/blendScene.js +280 -0
  28. package/dist/gltf.js +8 -2
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +17 -0
  31. package/dist/readModel.d.ts +13 -1
  32. package/dist/readModel.js +43 -13
  33. package/package.json +4 -4
  34. package/src/blend.ts +57 -0
  35. package/src/blendAnimation.ts +328 -0
  36. package/src/blendData.ts +375 -0
  37. package/src/blendFile.ts +233 -0
  38. package/src/blendGeometry.ts +263 -0
  39. package/src/blendGltf.ts +621 -0
  40. package/src/blendMaterial.ts +676 -0
  41. package/src/blendMatrix.ts +257 -0
  42. package/src/blendMesh.ts +496 -0
  43. package/src/blendModifiers.ts +96 -0
  44. package/src/blendNormals.ts +481 -0
  45. package/src/blendPacked.ts +93 -0
  46. package/src/blendScene.ts +307 -0
  47. package/src/gltf.ts +14 -2
  48. package/src/index.ts +41 -0
  49. package/src/readModel.ts +58 -13
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Which objects of a `.blend` are drawn, and where: the scene walk, in Blender's coordinates.
3
+ *
4
+ * **"Drawn" means what a render of the open scene shows**, the same rule `scripts/blender/
5
+ * export_gltf.py` applies before it hands the scene to Blender's exporter, so both routes into
6
+ * the engine agree on what an asset contains. An object is drawn when it is linked into the scene
7
+ * through at least one collection that is neither excluded from the view layer nor disabled for
8
+ * rendering, and is not itself hidden from renders. Parenting does not hide: a child of a hidden
9
+ * object is drawn where its parent would have put it, which is what Blender does.
10
+ *
11
+ * **A collection instance is expanded** into its objects, placed by the instancer and shifted by
12
+ * the collection's instance offset. Before 2.8 there were no collections, and the scene's bases and
13
+ * layer bits say the same thing.
14
+ *
15
+ * **What has to be evaluated is refused rather than guessed.** A constraint, a vertex parent or a
16
+ * particle instancer changes where things are by running Blender, so the walk names it in
17
+ * `refusals` and the caller declines the whole file. Animation does not refuse: Blender writes the
18
+ * evaluated value of every animated property back to the datablock, so a saved file holds the
19
+ * pose of the frame it was saved on.
20
+ */
21
+
22
+ import type { BlendData, BlendStruct } from './blendData.ts';
23
+ import type { Mat4 } from './blendMatrix.ts';
24
+ import {
25
+ axisAngleMatrix,
26
+ compose,
27
+ eulerMatrix,
28
+ multiply,
29
+ multiply3,
30
+ quaternionMatrix,
31
+ translation,
32
+ } from './blendMatrix.ts';
33
+
34
+ /** One drawn object, or an empty that carries others, at its place in the world. */
35
+ export interface BlendPlaced {
36
+ readonly object: BlendStruct;
37
+ readonly name: string;
38
+ /** Blender's object type: 0 empty, 1 mesh, 10 light, 25 armature, and the rest. */
39
+ readonly kind: number;
40
+ /** World matrix, Blender space (Z up), column-major. */
41
+ readonly world: Mat4;
42
+ /** Index of the nearest placed ancestor, or -1. */
43
+ readonly parent: number;
44
+ }
45
+
46
+ export const OB_EMPTY = 0;
47
+ export const OB_MESH = 1;
48
+ export const OB_LAMP = 10;
49
+ export const OB_CAMERA = 11;
50
+ export const OB_ARMATURE = 25;
51
+
52
+ /** Object types that draw nothing and so need nothing evaluated: cameras, speakers, probes. */
53
+ const INERT = new Set([OB_CAMERA, 12, 13, 22]);
54
+ const NEEDS_EVALUATION: Record<number, string> = {
55
+ 2: 'a curve',
56
+ 3: 'a NURBS surface',
57
+ 4: 'a text object',
58
+ 5: 'a metaball',
59
+ 26: 'a grease pencil object',
60
+ 27: 'a hair curves object',
61
+ 28: 'a point cloud',
62
+ 29: 'a volume',
63
+ 30: 'a grease pencil object',
64
+ };
65
+
66
+ const OB_HIDE_RENDER = 1 << 2;
67
+ const COLLECTION_HIDE_RENDER = 1 << 3;
68
+ const LAYER_COLLECTION_EXCLUDE = 1 << 4;
69
+ const OB_DUPLICOLLECTION = 1 << 8;
70
+ const OB_DUPLIVERTS = 1 << 4;
71
+ const OB_DUPLIFACES = 1 << 9;
72
+ const PARTYPE = 15;
73
+ const PAROBJECT = 0;
74
+ const PARSKEL = 4;
75
+ const PARBONE = 7;
76
+
77
+ /** The scene the file was saved looking at. */
78
+ export function activeScene(blend: BlendData): BlendStruct | null {
79
+ const glob = blend.idsOf('GLOB')[0];
80
+ const scene = glob?.has('curscene') === true ? glob.deref('curscene') : null;
81
+ return scene ?? blend.idsOf('SC')[0] ?? null;
82
+ }
83
+
84
+ /**
85
+ * The object's transform relative to its parent: location, rotation and scale, deltas folded in.
86
+ *
87
+ * `values`, where given, overrides what the file stores with what an animation says at a frame,
88
+ * keyed by Blender's data path and index — `location[0]`, `rotation_quaternion[3]` — which is what
89
+ * `Animation.values` fills (`blendAnimation.ts`).
90
+ */
91
+ export function localMatrix(object: BlendStruct, values?: ReadonlyMap<string, number>): Mat4 {
92
+ const read = (field: string, path: string, count: number, offset = 0): number[] => {
93
+ const stored = object.has(field)
94
+ ? object.floats(field)
95
+ : new Array<number>(count).fill(field.includes('size') || field.includes('scale') ? 1 : 0);
96
+ if (values === undefined) return stored;
97
+ return stored.map((v, i) => values.get(`${path}[${i + offset}]`) ?? v);
98
+ };
99
+ const location = read('loc', 'location', 3);
100
+ const deltaLocation = read('dloc', 'delta_location', 3);
101
+ const scale = read('size', 'scale', 3);
102
+ const deltaScale = object.has('dscale')
103
+ ? read('dscale', 'delta_scale', 3)
104
+ : read('dsize', 'delta_scale', 3);
105
+ const mode = object.int('rotmode');
106
+ let rotation;
107
+ let delta;
108
+ if (mode === 0) {
109
+ rotation = quaternionMatrix(read('quat', 'rotation_quaternion', 4));
110
+ delta = quaternionMatrix(read('dquat', 'delta_rotation_quaternion', 4));
111
+ } else if (mode === -1) {
112
+ /* Blender keys an axis-angle as four values, the angle first. */
113
+ const angle = values?.get('rotation_axis_angle[0]') ?? object.float('rotAngle');
114
+ rotation = axisAngleMatrix(read('rotAxis', 'rotation_axis_angle', 3, 1), angle);
115
+ delta = axisAngleMatrix(object.floats('drotAxis'), object.float('drotAngle'));
116
+ } else {
117
+ rotation = eulerMatrix(read('rot', 'rotation_euler', 3), mode);
118
+ delta = eulerMatrix(read('drot', 'delta_rotation_euler', 3), mode);
119
+ }
120
+ return compose(
121
+ [0, 1, 2].map((i) => (location[i] as number) + (deltaLocation[i] as number)),
122
+ multiply3(delta, rotation),
123
+ [0, 1, 2].map((i) => (scale[i] as number) * (deltaScale[i] as number)),
124
+ );
125
+ }
126
+
127
+ /** Walk the scene into placed objects, naming in `refusals` anything that needs Blender to run. */
128
+ export function placeObjects(
129
+ blend: BlendData,
130
+ refusals: string[],
131
+ bonePose: (armature: BlendStruct, bone: string) => Mat4 | null,
132
+ ): BlendPlaced[] {
133
+ const scene = activeScene(blend);
134
+ if (scene === null) {
135
+ refusals.push('the file has no scene');
136
+ return [];
137
+ }
138
+ const worlds = new Map<number, Mat4>();
139
+ const world = (object: BlendStruct, depth = 0): Mat4 => {
140
+ const cached = worlds.get(object.offset);
141
+ if (cached !== undefined) return cached;
142
+ let matrix = localMatrix(object);
143
+ const parent = depth < 64 ? object.deref('parent') : null;
144
+ if (parent !== null) {
145
+ const type = object.int('partype') & PARTYPE;
146
+ let base = world(parent, depth + 1);
147
+ if (type === PARBONE) {
148
+ const pose = bonePose(parent, object.string('parsubstr'));
149
+ if (pose === null)
150
+ refusals.push(`"${object.idName()}" is parented to a bone that is not there`);
151
+ else base = multiply(base, pose);
152
+ } else if (type !== PAROBJECT && type !== PARSKEL) {
153
+ refusals.push(`"${object.idName()}" is parented to vertices, which Blender evaluates`);
154
+ }
155
+ matrix = multiply(base, multiply(object.floats('parentinv'), matrix));
156
+ }
157
+ if (object.list('constraints').length > 0) {
158
+ refusals.push(`"${object.idName()}" has constraints, which Blender evaluates`);
159
+ }
160
+ worlds.set(object.offset, matrix);
161
+ return matrix;
162
+ };
163
+
164
+ const placed: BlendPlaced[] = [];
165
+ const index = new Map<number, number>();
166
+
167
+ const place = (object: BlendStruct, outer: Mat4 | null, holder: number, depth: number): void => {
168
+ const kind = object.int('type');
169
+ if (INERT.has(kind)) return;
170
+ const reason = NEEDS_EVALUATION[kind];
171
+ if (reason !== undefined)
172
+ refusals.push(`"${object.idName()}" is ${reason}, which Blender evaluates`);
173
+ if (object.list('particlesystem').length > 0) {
174
+ refusals.push(`"${object.idName()}" has a particle system, which Blender evaluates`);
175
+ }
176
+ const transflag = object.int('transflag');
177
+ if (transflag & (OB_DUPLIVERTS | OB_DUPLIFACES)) {
178
+ refusals.push(
179
+ `"${object.idName()}" instances onto its vertices or faces, which Blender evaluates`,
180
+ );
181
+ }
182
+ const own = world(object);
183
+ const matrix = outer === null ? own : multiply(outer, own);
184
+ let parent = holder;
185
+ for (let up = object.deref('parent'), hops = 0; up !== null && hops < 64; hops++) {
186
+ const found = outer === null ? index.get(up.offset) : undefined;
187
+ if (found !== undefined) {
188
+ parent = found;
189
+ break;
190
+ }
191
+ up = up.deref('parent');
192
+ }
193
+ /*
194
+ * An instancer carries its collection's offset in its own matrix, so its members sit at their
195
+ * own world matrices beneath it. Blender's exporter writes the instancer the same way, which
196
+ * keeps the two node trees comparable; the empty draws nothing, so nothing moves on screen.
197
+ */
198
+ const collection = transflag & OB_DUPLICOLLECTION ? object.deref('dup_group') : null;
199
+ const offset = collection?.floats('dupli_ofs') ?? [0, 0, 0];
200
+ const held =
201
+ collection === null
202
+ ? matrix
203
+ : multiply(
204
+ matrix,
205
+ translation(-(offset[0] as number), -(offset[1] as number), -(offset[2] as number)),
206
+ );
207
+ const at = placed.length;
208
+ placed.push({ object, name: object.idName(), kind, world: held, parent });
209
+ if (outer === null) index.set(object.offset, at);
210
+ if (collection !== null && depth < 16) {
211
+ for (const member of collectionObjects(collection, true)) place(member, held, at, depth + 1);
212
+ }
213
+ };
214
+
215
+ const roots = sceneObjects(scene, blend);
216
+ /* Parents first, so a child finds its parent's index already assigned. */
217
+ const order = [...roots].sort((a, b) => depthOf(a) - depthOf(b));
218
+ for (const object of order) place(object, null, -1, 0);
219
+ return placed;
220
+ }
221
+
222
+ function depthOf(object: BlendStruct): number {
223
+ let depth = 0;
224
+ for (let up = object.deref('parent'); up !== null && depth < 64; up = up.deref('parent')) depth++;
225
+ return depth;
226
+ }
227
+
228
+ /** The objects a collection holds directly and through its children, those that render. */
229
+ function collectionObjects(collection: BlendStruct, nested: boolean): BlendStruct[] {
230
+ const out: BlendStruct[] = [];
231
+ const seen = new Set<number>();
232
+ const visit = (current: BlendStruct, depth: number): void => {
233
+ for (const link of current.list('gobject')) {
234
+ const object = link.deref('ob');
235
+ if (object === null || seen.has(object.offset)) continue;
236
+ seen.add(object.offset);
237
+ if ((object.int('restrictflag') & OB_HIDE_RENDER) === 0) out.push(object);
238
+ }
239
+ if (!nested || depth > 32) return;
240
+ for (const child of current.list('children')) {
241
+ const inner = child.deref('collection');
242
+ if (inner !== null && (inner.int('flag') & COLLECTION_HIDE_RENDER) === 0)
243
+ visit(inner, depth + 1);
244
+ }
245
+ };
246
+ visit(collection, 0);
247
+ return out;
248
+ }
249
+
250
+ /** Every object the scene renders, once each. */
251
+ function sceneObjects(scene: BlendStruct, blend: BlendData): BlendStruct[] {
252
+ if (!scene.has('master_collection')) {
253
+ /* Before 2.8: bases on the scene, drawn when one of their layers is a visible scene layer. */
254
+ const layers = scene.int('lay');
255
+ return scene
256
+ .list('base')
257
+ .filter((base) => (base.int('lay') & layers) !== 0)
258
+ .map((base) => base.deref('object'))
259
+ .filter((object): object is BlendStruct => object !== null)
260
+ .filter((object) => (object.int('restrictflag') & OB_HIDE_RENDER) === 0);
261
+ }
262
+ const master = scene.deref('master_collection');
263
+ if (master === null) return [];
264
+ const glob = blend.idsOf('GLOB')[0];
265
+ const layers = scene.list('view_layers');
266
+ const active =
267
+ glob?.has('cur_view_layer') === true
268
+ ? layers.find((layer) => layer.offset === glob.deref('cur_view_layer')?.offset)
269
+ : undefined;
270
+ const layer = active ?? layers[0];
271
+ const top = layer?.list('layer_collections')[0];
272
+
273
+ const out: BlendStruct[] = [];
274
+ const seen = new Set<number>();
275
+ const take = (collection: BlendStruct): void => {
276
+ for (const link of collection.list('gobject')) {
277
+ const object = link.deref('ob');
278
+ if (object === null || seen.has(object.offset)) continue;
279
+ if ((object.int('restrictflag') & OB_HIDE_RENDER) !== 0) continue;
280
+ seen.add(object.offset);
281
+ out.push(object);
282
+ }
283
+ };
284
+ /*
285
+ * Walk the layer collections where the view layer has them, since exclusion lives there; walk the
286
+ * collections themselves where it does not, which is a file written without a synchronised view
287
+ * layer and has nothing excluded.
288
+ */
289
+ const walkLayer = (lc: BlendStruct, depth: number): void => {
290
+ if (depth > 64 || (lc.int('flag') & LAYER_COLLECTION_EXCLUDE) !== 0) return;
291
+ const collection = lc.deref('collection');
292
+ if (collection === null || (collection.int('flag') & COLLECTION_HIDE_RENDER) !== 0) return;
293
+ take(collection);
294
+ for (const child of lc.list('layer_collections')) walkLayer(child, depth + 1);
295
+ };
296
+ const walkCollection = (collection: BlendStruct, depth: number): void => {
297
+ if (depth > 64 || (collection.int('flag') & COLLECTION_HIDE_RENDER) !== 0) return;
298
+ take(collection);
299
+ for (const child of collection.list('children')) {
300
+ const inner = child.deref('collection');
301
+ if (inner !== null) walkCollection(inner, depth + 1);
302
+ }
303
+ };
304
+ if (top !== undefined) walkLayer(top, 0);
305
+ else walkCollection(master, 0);
306
+ return out;
307
+ }
package/src/gltf.ts CHANGED
@@ -692,8 +692,20 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
692
692
  cutout: masked ? Math.min(1, Math.max(0, material?.alphaCutoff ?? 0.5)) : 0,
693
693
  /* Metalness is glTF's statement about reflection: a metal mirrors its surroundings and
694
694
  a dielectric mostly does not. It also feeds `specular`, which is not double counting,
695
- because the two describe different halves of the same fact. */
696
- reflectivity: Math.min(1, Math.max(0, modulated ? 0 : (pbr?.metallicFactor ?? 0))),
695
+ because the two describe different halves of the same fact.
696
+
697
+ **An absent factor is 1 here as it is everywhere else in glTF**, with the decal guard
698
+ `highlight` applies above: an image-coloured surface naming no metallic stays a dielectric.
699
+ It read 0 for every surface until a Blender export showed it, because Blender writes no
700
+ `metallicFactor` for a metal — 1 is the default — so every untextured metal it exported
701
+ arrived taking a highlight and mirroring nothing. */
702
+ reflectivity: Math.min(
703
+ 1,
704
+ Math.max(
705
+ 0,
706
+ modulated ? 0 : pbr?.baseColorTexture === undefined ? metallic : (pbr?.metallicFactor ?? 0),
707
+ ),
708
+ ),
697
709
  normalMap: material?.normalTexture?.index ?? -1,
698
710
  ormMap,
699
711
  emissiveMap: material?.emissiveTexture?.index ?? -1,
package/src/index.ts CHANGED
@@ -91,6 +91,47 @@ export type { Inflate } from './fbx.ts';
91
91
  * decompressors and the resolution of a name a file states are all the caller's to supply.
92
92
  */
93
93
  export { readModel, readerFor, extensionOf, MODEL_FORMATS } from './readModel.ts';
94
+ /*
95
+ * Blender's own format, read directly. `readModel` takes a `.blend` like any other model; the rest
96
+ * is for a bake that keeps a scene's structure — its instancing, its materials' channels — rather
97
+ * than flattening it into world-space meshes.
98
+ */
99
+ export { BlendNeedsBlender, openBlend, readBlend } from './blend.ts';
100
+ export { BlendData, BlendStruct } from './blendData.ts';
101
+ export {
102
+ blendToGltf,
103
+ blackbody,
104
+ imageMime,
105
+ imagePath,
106
+ materialSlots,
107
+ packedImage,
108
+ } from './blendGltf.ts';
109
+ export type { BlendGltf } from './blendGltf.ts';
110
+ export {
111
+ placeObjects,
112
+ localMatrix,
113
+ activeScene,
114
+ OB_ARMATURE,
115
+ OB_EMPTY,
116
+ OB_LAMP,
117
+ OB_MESH,
118
+ } from './blendScene.ts';
119
+ export type { BlendPlaced } from './blendScene.ts';
120
+ export { readBlendMesh } from './blendMesh.ts';
121
+ export type { BlendMeshData, BlendColorLayer } from './blendMesh.ts';
122
+ export { cornerNormals, faceNormals } from './blendNormals.ts';
123
+ export { meshPrimitives, triangulateFace } from './blendGeometry.ts';
124
+ export type { BlendPrimitive, SlotNeeds } from './blendGeometry.ts';
125
+ export { readBlendSurface, customProperties } from './blendMaterial.ts';
126
+ export type { BlendSurface, ChannelSource } from './blendMaterial.ts';
127
+ export { judgeModifiers } from './blendModifiers.ts';
128
+ export { Animation, actionCurves, readCurve } from './blendAnimation.ts';
129
+ export type { Curve } from './blendAnimation.ts';
130
+ export type { ModifierVerdict } from './blendModifiers.ts';
131
+ export { blendCompression, isBlend } from './blendFile.ts';
132
+ export type { BlendBlock, BlendHeader, DnaField, DnaStruct } from './blendFile.ts';
133
+ export { unpackBlend, zstdFrames } from './blendPacked.ts';
134
+ export type { BlendDecompress } from './blendPacked.ts';
94
135
  export type { ModelSource, ModelImport } from './readModel.ts';
95
136
  /*
96
137
  * The browser's decompressor, and the two-pass trick that makes an async one usable by a
package/src/readModel.ts CHANGED
@@ -38,6 +38,8 @@ import { parseUsda, usdzToMeshes } from './usd.ts';
38
38
  import { threeMfToMeshes } from './threemf.ts';
39
39
  import { kn5ToMeshes, toDrftNodes } from './kn5.ts';
40
40
  import { describeRecognised, recognise } from './recognise.ts';
41
+ import { readBlend } from './blend.ts';
42
+ import type { BlendDecompress } from './blendPacked.ts';
41
43
 
42
44
  /**
43
45
  * Every extension this project reads, in the order a bundle should be picked from.
@@ -57,6 +59,11 @@ export const MODEL_FORMATS: readonly { ext: string; tier: number; note: string }
57
59
  { ext: '.3mf', tier: 1, note: '3MF, geometry and colour, with no UVs in the core format' },
58
60
  { ext: '.fbx', tier: 2, note: 'FBX, experimental reader, geometry, materials, skins and clips' },
59
61
  { ext: '.kn5', tier: 2, note: 'experimental reader, geometry, materials and hierarchy' },
62
+ {
63
+ ext: '.blend',
64
+ tier: 2,
65
+ note: 'Blender, read directly; modifiers, rigs and constraints are refused and baked through Blender',
66
+ },
60
67
  ];
61
68
 
62
69
  /** Whether this project has a reader for an extension. Lower case or not. */
@@ -93,6 +100,8 @@ export interface ModelSource {
93
100
  readonly inflate?: Inflate;
94
101
  /** Raw deflate inflate, for the zip inside a `.3mf`. Not the same function as `inflate`. */
95
102
  readonly inflateRaw?: Inflate;
103
+ /** gzip and zstd, for a `.blend` saved compressed. See `blendPacked.ts`. */
104
+ readonly decompress?: BlendDecompress;
96
105
  /**
97
106
  * Derive a tangent frame where a material declares a normal map and the file supplies none.
98
107
  *
@@ -154,6 +163,14 @@ export interface ModelImport {
154
163
  lights?: readonly DrftLight[];
155
164
  /** Which way round the source's coordinate system was. `orient.ts` is what acts on it. */
156
165
  declaredHand?: Handedness;
166
+ /**
167
+ * Where the reader knows its result differs from what the authoring tool would export: a node
168
+ * graph glTF cannot hold, animation it does not read. Drawn anyway, and named.
169
+ *
170
+ * Absent for every format but `.blend`, whose authoring tool can be asked instead: the baker
171
+ * reads a non-empty list as the reason to hand the file to Blender when Blender is there.
172
+ */
173
+ approximations?: readonly string[];
157
174
  }
158
175
 
159
176
  function needed(ext: string, capability: string): DrftError {
@@ -419,6 +436,33 @@ async function dispatch(source: ModelSource): Promise<ModelImport> {
419
436
  };
420
437
  }
421
438
 
439
+ if (ext === '.blend') {
440
+ /*
441
+ * Read into a glTF document and through the glTF reader, so a `.blend` and Blender's export of
442
+ * it share every step after the document. A file needing evaluation throws
443
+ * `BlendNeedsBlender`, which the baker answers by asking Blender.
444
+ */
445
+ const result = await readBlend(bytes, source.decompress);
446
+ const parts = [result.binary];
447
+ const read = gltfToMeshes(result.doc, parts, options);
448
+ const said = [...result.approximations, ...result.warnings];
449
+ const shown = said.slice(0, 8);
450
+ return complete(
451
+ {
452
+ ...read,
453
+ ...readGltfSkins(result.doc, parts),
454
+ warnings: [
455
+ ...read.warnings,
456
+ ...shown,
457
+ ...(said.length > shown.length ? [`…and ${said.length - shown.length} more`] : []),
458
+ 'blend: direct reader (tier 2), not covered by the compatibility promise.',
459
+ ],
460
+ approximations: result.approximations,
461
+ },
462
+ result.notes,
463
+ );
464
+ }
465
+
422
466
  if (ext === '.kn5') {
423
467
  /*
424
468
  * No capability of any kind: a `.kn5` embeds every texture it uses, so this is the one binary
@@ -479,19 +523,7 @@ function base64Bytes(base64: string): Uint8Array {
479
523
  return out;
480
524
  }
481
525
 
482
- /**
483
- * The collision hull a vehicle folder ships beside its model, or null.
484
- *
485
- * **A convention, not a statement in any file**, which is why it is one named function instead of
486
- * a rule spread through the baker: a folder naming its hull differently gets no collider and the
487
- * baker says what it looked for, rather than leaving a consumer to wonder why physics has nothing
488
- * to work with.
489
- *
490
- * It is read by the ordinary reader and baked as its own asset. Carrying a hull *inside* the
491
- * model's own file would mean defining `COLL`, which is a declared FourCC with no payload, no
492
- * reader and no writer — a format decision about whether a hull is triangle soup or a set of
493
- * convex hulls, and not one an import should make in passing.
494
- */
526
+ /** The separate levels of detail a bundle ships beside its model, finest first. */
495
527
  export function levelsBeside(model: string, names: readonly string[]): string[] {
496
528
  /*
497
529
  * `<name>_lod_<letter>.kn5` beside `<name>.kn5`, finest first, which is the order the letters
@@ -512,6 +544,19 @@ export function levelsBeside(model: string, names: readonly string[]): string[]
512
544
  .map((entry) => entry.name);
513
545
  }
514
546
 
547
+ /**
548
+ * The collision hull a vehicle folder ships beside its model, or null.
549
+ *
550
+ * **A convention, not a statement in any file**, which is why it is one named function instead of
551
+ * a rule spread through the baker: a folder naming its hull differently gets no collider and the
552
+ * baker says what it looked for, rather than leaving a consumer to wonder why physics has nothing
553
+ * to work with.
554
+ *
555
+ * It is read by the ordinary reader and baked as its own asset. Carrying a hull *inside* the
556
+ * model's own file would mean defining `COLL`, which is a declared FourCC with no payload, no
557
+ * reader and no writer — a format decision about whether a hull is triangle soup or a set of
558
+ * convex hulls, and not one an import should make in passing.
559
+ */
515
560
  export function colliderBeside(names: readonly string[]): string | null {
516
561
  return names.find((name) => name.toLowerCase() === 'collider.kn5') ?? null;
517
562
  }