@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.
- package/README.md +1 -1
- package/dist/blend.d.ts +25 -0
- package/dist/blend.js +43 -0
- package/dist/blendAnimation.d.ts +45 -0
- package/dist/blendAnimation.js +294 -0
- package/dist/blendData.d.ts +114 -0
- package/dist/blendData.js +366 -0
- package/dist/blendFile.d.ts +69 -0
- package/dist/blendFile.js +182 -0
- package/dist/blendGeometry.d.ts +45 -0
- package/dist/blendGeometry.js +210 -0
- package/dist/blendGltf.d.ts +65 -0
- package/dist/blendGltf.js +545 -0
- package/dist/blendMaterial.d.ts +68 -0
- package/dist/blendMaterial.js +545 -0
- package/dist/blendMatrix.d.ts +28 -0
- package/dist/blendMatrix.js +245 -0
- package/dist/blendMesh.d.ts +70 -0
- package/dist/blendMesh.js +409 -0
- package/dist/blendModifiers.d.ts +25 -0
- package/dist/blendModifiers.js +83 -0
- package/dist/blendNormals.d.ts +44 -0
- package/dist/blendNormals.js +458 -0
- package/dist/blendPacked.d.ts +35 -0
- package/dist/blendPacked.js +82 -0
- package/dist/blendScene.d.ts +50 -0
- package/dist/blendScene.js +280 -0
- package/dist/gltf.js +8 -2
- package/dist/index.d.ts +21 -0
- package/dist/index.js +17 -0
- package/dist/readModel.d.ts +13 -1
- package/dist/readModel.js +43 -13
- package/package.json +4 -4
- package/src/blend.ts +57 -0
- package/src/blendAnimation.ts +328 -0
- package/src/blendData.ts +375 -0
- package/src/blendFile.ts +233 -0
- package/src/blendGeometry.ts +263 -0
- package/src/blendGltf.ts +621 -0
- package/src/blendMaterial.ts +676 -0
- package/src/blendMatrix.ts +257 -0
- package/src/blendMesh.ts +496 -0
- package/src/blendModifiers.ts +96 -0
- package/src/blendNormals.ts +481 -0
- package/src/blendPacked.ts +93 -0
- package/src/blendScene.ts +307 -0
- package/src/gltf.ts +14 -2
- package/src/index.ts +41 -0
- package/src/readModel.ts +58 -13
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a `.blend`'s structs by field name, and following its pointers between blocks.
|
|
3
|
+
*
|
|
4
|
+
* `blendFile.ts` says where every block is and what every struct looks like; this is what turns
|
|
5
|
+
* those two tables into `mesh.int('totvert')` and `object.deref('data')`. Every accessor names a
|
|
6
|
+
* field rather than an offset, so a field that moved between Blender releases is found where the
|
|
7
|
+
* file says it is, and a field that does not exist in this file answers `has` false rather than
|
|
8
|
+
* reading whatever sits at an offset some other release used.
|
|
9
|
+
*
|
|
10
|
+
* **A missing field is a question, not an error.** Most of the reading above this module is
|
|
11
|
+
* "the new name if this file has it, the old one otherwise", so `has` is cheap and every typed
|
|
12
|
+
* accessor throws only when asked for a field the struct does not carry — which is a reader bug,
|
|
13
|
+
* and is said with the struct's name and the Blender version beside it.
|
|
14
|
+
*/
|
|
15
|
+
import { DrftError } from '@driftengine/drft';
|
|
16
|
+
import { readBlendBlocks, readBlendHeader, readDna } from './blendFile.js';
|
|
17
|
+
/** A parsed file: its blocks, its catalogue, and the maps from an address to a block. */
|
|
18
|
+
export class BlendData {
|
|
19
|
+
header;
|
|
20
|
+
bytes;
|
|
21
|
+
view;
|
|
22
|
+
blocks;
|
|
23
|
+
structs;
|
|
24
|
+
byType = new Map();
|
|
25
|
+
/**
|
|
26
|
+
* Which datablock each block's data belongs to: the most recent block that is not `DATA`.
|
|
27
|
+
*
|
|
28
|
+
* **Addresses are only unique inside one datablock, and 5.0 relies on it.** Blender's own reader
|
|
29
|
+
* keeps one map for datablocks and a second, per datablock, for the data blocks that follow it,
|
|
30
|
+
* and 5.0 writes placeholder addresses that repeat between datablocks: measured on a 5.0 file,
|
|
31
|
+
* one address named eight different meshes' attribute arrays. A file-wide map answered every
|
|
32
|
+
* mesh with the last mesh's positions.
|
|
33
|
+
*/
|
|
34
|
+
owner;
|
|
35
|
+
ids = new Map();
|
|
36
|
+
scoped = new Map();
|
|
37
|
+
sorted = new Map();
|
|
38
|
+
/** Before 5.0 every address was a real one, so a data pointer may be resolved file-wide. */
|
|
39
|
+
fileWide;
|
|
40
|
+
constructor(bytes) {
|
|
41
|
+
this.bytes = bytes;
|
|
42
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
43
|
+
this.header = readBlendHeader(bytes);
|
|
44
|
+
this.blocks = readBlendBlocks(bytes, this.header);
|
|
45
|
+
const dna = this.blocks.find((block) => block.code === 'DNA1');
|
|
46
|
+
if (dna === undefined)
|
|
47
|
+
throw new DrftError('blend: the file carries no DNA1 catalogue');
|
|
48
|
+
this.structs = readDna(bytes, dna, this.header);
|
|
49
|
+
for (const struct of this.structs)
|
|
50
|
+
this.byType.set(struct.type, struct);
|
|
51
|
+
this.owner = new Int32Array(this.blocks.length);
|
|
52
|
+
this.fileWide = this.header.version < 500 ? new Map() : null;
|
|
53
|
+
let scope = -1;
|
|
54
|
+
this.blocks.forEach((block, i) => {
|
|
55
|
+
if (block.code !== 'DATA') {
|
|
56
|
+
scope = i;
|
|
57
|
+
if (block.address !== 0n)
|
|
58
|
+
this.ids.set(block.address, i);
|
|
59
|
+
}
|
|
60
|
+
else if (block.address !== 0n) {
|
|
61
|
+
let map = this.scoped.get(scope);
|
|
62
|
+
if (map === undefined)
|
|
63
|
+
this.scoped.set(scope, (map = new Map()));
|
|
64
|
+
map.set(block.address, i);
|
|
65
|
+
let list = this.sorted.get(scope);
|
|
66
|
+
if (list === undefined)
|
|
67
|
+
this.sorted.set(scope, (list = []));
|
|
68
|
+
list.push(i);
|
|
69
|
+
}
|
|
70
|
+
this.owner[i] = block.code === 'DATA' ? scope : i;
|
|
71
|
+
if (this.fileWide !== null && block.address !== 0n)
|
|
72
|
+
this.fileWide.set(block.address, i);
|
|
73
|
+
});
|
|
74
|
+
for (const list of this.sorted.values()) {
|
|
75
|
+
list.sort((a, b) => {
|
|
76
|
+
const x = this.blocks[a].address;
|
|
77
|
+
const y = this.blocks[b].address;
|
|
78
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The catalogue entry for a struct type, or undefined where this file's Blender had none. */
|
|
83
|
+
struct(type) {
|
|
84
|
+
return this.byType.get(type);
|
|
85
|
+
}
|
|
86
|
+
/** The datablock a block's data belongs to, which is what scopes every pointer inside it. */
|
|
87
|
+
scopeOf(block) {
|
|
88
|
+
return this.owner[block] ?? -1;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The block an address falls in, and how far into it, as seen from inside datablock `scope`.
|
|
92
|
+
*
|
|
93
|
+
* The datablock's own data first, then the datablocks themselves, which is the order Blender's
|
|
94
|
+
* reader resolves in. Null for an address nothing holds.
|
|
95
|
+
*/
|
|
96
|
+
find(address, scope) {
|
|
97
|
+
if (address === 0n)
|
|
98
|
+
return null;
|
|
99
|
+
const own = this.scoped.get(scope)?.get(address) ?? this.ids.get(address);
|
|
100
|
+
if (own !== undefined)
|
|
101
|
+
return { block: own, offset: 0 };
|
|
102
|
+
const list = this.sorted.get(scope);
|
|
103
|
+
if (list !== undefined) {
|
|
104
|
+
let lo = 0;
|
|
105
|
+
let hi = list.length - 1;
|
|
106
|
+
while (lo <= hi) {
|
|
107
|
+
const mid = (lo + hi) >> 1;
|
|
108
|
+
const index = list[mid];
|
|
109
|
+
const block = this.blocks[index];
|
|
110
|
+
if (block.address > address)
|
|
111
|
+
hi = mid - 1;
|
|
112
|
+
else if (block.address + BigInt(block.length) <= address)
|
|
113
|
+
lo = mid + 1;
|
|
114
|
+
else
|
|
115
|
+
return { block: index, offset: Number(address - block.address) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const anywhere = this.fileWide?.get(address);
|
|
119
|
+
return anywhere === undefined ? null : { block: anywhere, offset: 0 };
|
|
120
|
+
}
|
|
121
|
+
/** Every datablock of a two-letter code — `OB`, `ME`, `MA` — as views of their own struct. */
|
|
122
|
+
idsOf(code) {
|
|
123
|
+
const out = [];
|
|
124
|
+
this.blocks.forEach((block, i) => {
|
|
125
|
+
if (block.code !== code)
|
|
126
|
+
return;
|
|
127
|
+
const struct = this.structs[block.sdna];
|
|
128
|
+
if (struct !== undefined)
|
|
129
|
+
out.push(new BlendStruct(this, struct, block.offset, i));
|
|
130
|
+
});
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** A view of the struct at an address, typed by the block that holds it or by `type`. */
|
|
134
|
+
at(address, scope, type) {
|
|
135
|
+
const found = this.find(address, scope);
|
|
136
|
+
if (found === null)
|
|
137
|
+
return null;
|
|
138
|
+
const block = this.blocks[found.block];
|
|
139
|
+
const struct = type === undefined ? this.structs[block.sdna] : this.byType.get(type);
|
|
140
|
+
if (struct === undefined)
|
|
141
|
+
return null;
|
|
142
|
+
return new BlendStruct(this, struct, block.offset + found.offset, this.scopeOf(found.block));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The structs of an array a pointer names, indexed *inside* its block.
|
|
146
|
+
*
|
|
147
|
+
* **Never by adding to the address.** From 5.0 an address is a placeholder rather than a
|
|
148
|
+
* location, so `base + 24` is not the second element and can be exactly the address of some
|
|
149
|
+
* unrelated block.
|
|
150
|
+
*/
|
|
151
|
+
array(address, scope, type, count) {
|
|
152
|
+
const found = this.find(address, scope);
|
|
153
|
+
const struct = this.byType.get(type);
|
|
154
|
+
if (found === null || struct === undefined)
|
|
155
|
+
return [];
|
|
156
|
+
const block = this.blocks[found.block];
|
|
157
|
+
const start = block.offset + found.offset;
|
|
158
|
+
const room = Math.floor((block.offset + block.length - start) / struct.size);
|
|
159
|
+
const n = Math.min(count ?? room, room);
|
|
160
|
+
const owner = this.scopeOf(found.block);
|
|
161
|
+
const out = [];
|
|
162
|
+
for (let i = 0; i < n; i++)
|
|
163
|
+
out.push(new BlendStruct(this, struct, start + i * struct.size, owner));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** The raw bytes a pointer names, through the end of its block. */
|
|
167
|
+
raw(address, scope) {
|
|
168
|
+
const found = this.find(address, scope);
|
|
169
|
+
if (found === null)
|
|
170
|
+
return null;
|
|
171
|
+
const block = this.blocks[found.block];
|
|
172
|
+
return this.bytes.subarray(block.offset + found.offset, block.offset + block.length);
|
|
173
|
+
}
|
|
174
|
+
/** A zero-terminated string a `char *` names. */
|
|
175
|
+
text(address, scope) {
|
|
176
|
+
const raw = this.raw(address, scope);
|
|
177
|
+
if (raw === null)
|
|
178
|
+
return '';
|
|
179
|
+
const stop = raw.indexOf(0);
|
|
180
|
+
return new TextDecoder().decode(stop < 0 ? raw : raw.subarray(0, stop));
|
|
181
|
+
}
|
|
182
|
+
/** The block a pointer names, for its count and its catalogue type. */
|
|
183
|
+
blockOf(address, scope) {
|
|
184
|
+
const found = this.find(address, scope);
|
|
185
|
+
return found === null ? null : (this.blocks[found.block] ?? null);
|
|
186
|
+
}
|
|
187
|
+
pointer(at) {
|
|
188
|
+
const le = this.header.littleEndian;
|
|
189
|
+
return this.header.pointerSize === 8
|
|
190
|
+
? this.view.getBigUint64(at, le)
|
|
191
|
+
: BigInt(this.view.getUint32(at, le));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** One struct in the file, read by field name. */
|
|
195
|
+
export class BlendStruct {
|
|
196
|
+
file;
|
|
197
|
+
struct;
|
|
198
|
+
offset;
|
|
199
|
+
scope;
|
|
200
|
+
constructor(file, struct, offset,
|
|
201
|
+
/** The datablock this struct's data belongs to, which every pointer in it is resolved inside. */
|
|
202
|
+
scope) {
|
|
203
|
+
this.file = file;
|
|
204
|
+
this.struct = struct;
|
|
205
|
+
this.offset = offset;
|
|
206
|
+
this.scope = scope;
|
|
207
|
+
}
|
|
208
|
+
get type() {
|
|
209
|
+
return this.struct.type;
|
|
210
|
+
}
|
|
211
|
+
has(name) {
|
|
212
|
+
return this.struct.fields.has(name);
|
|
213
|
+
}
|
|
214
|
+
field(name) {
|
|
215
|
+
const field = this.struct.fields.get(name);
|
|
216
|
+
if (field === undefined) {
|
|
217
|
+
throw new DrftError(`blend: ${this.struct.type} has no field "${name}" in a file from Blender ` +
|
|
218
|
+
`${this.file.header.version}, which is a reader bug rather than a bad file`);
|
|
219
|
+
}
|
|
220
|
+
return field;
|
|
221
|
+
}
|
|
222
|
+
/** An integer field of any width, signed as its type is. */
|
|
223
|
+
int(name, index = 0) {
|
|
224
|
+
const field = this.field(name);
|
|
225
|
+
return readInt(this.file, field.type, this.offset + field.offset + index * elementSize(field));
|
|
226
|
+
}
|
|
227
|
+
float(name, index = 0) {
|
|
228
|
+
const field = this.field(name);
|
|
229
|
+
const at = this.offset + field.offset + index * elementSize(field);
|
|
230
|
+
const le = this.file.header.littleEndian;
|
|
231
|
+
return field.type === 'double'
|
|
232
|
+
? this.file.view.getFloat64(at, le)
|
|
233
|
+
: this.file.view.getFloat32(at, le);
|
|
234
|
+
}
|
|
235
|
+
/** Every element of a float array field, flattened: `co[3]` is three, `mat[4][4]` sixteen. */
|
|
236
|
+
floats(name) {
|
|
237
|
+
const field = this.field(name);
|
|
238
|
+
const count = field.dims.reduce((n, d) => n * d, 1);
|
|
239
|
+
const out = [];
|
|
240
|
+
for (let i = 0; i < count; i++)
|
|
241
|
+
out.push(this.float(name, i));
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
ints(name) {
|
|
245
|
+
const field = this.field(name);
|
|
246
|
+
const count = field.dims.reduce((n, d) => n * d, 1);
|
|
247
|
+
const out = [];
|
|
248
|
+
for (let i = 0; i < count; i++)
|
|
249
|
+
out.push(this.int(name, i));
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/** A `char[]` field as text, up to its first zero. */
|
|
253
|
+
string(name) {
|
|
254
|
+
const field = this.field(name);
|
|
255
|
+
const start = this.offset + field.offset;
|
|
256
|
+
const bytes = this.file.bytes.subarray(start, start + field.size);
|
|
257
|
+
const stop = bytes.indexOf(0);
|
|
258
|
+
return new TextDecoder().decode(stop < 0 ? bytes : bytes.subarray(0, stop));
|
|
259
|
+
}
|
|
260
|
+
/** An ID's name without its two-letter code: `OBCube` is `Cube`. */
|
|
261
|
+
idName() {
|
|
262
|
+
return this.sub('id').string('name').slice(2);
|
|
263
|
+
}
|
|
264
|
+
ptr(name, index = 0) {
|
|
265
|
+
const field = this.field(name);
|
|
266
|
+
if (!field.pointer)
|
|
267
|
+
throw new DrftError(`blend: ${this.struct.type}.${name} is not a pointer`);
|
|
268
|
+
return this.file.pointer(this.offset + field.offset + index * this.file.header.pointerSize);
|
|
269
|
+
}
|
|
270
|
+
/** Follow a pointer field, typed by the block it lands in or by `type` where that is known. */
|
|
271
|
+
deref(name, type) {
|
|
272
|
+
return this.file.at(this.ptr(name), this.scope, type);
|
|
273
|
+
}
|
|
274
|
+
/** An array a pointer field names, `count` long or as long as its block. */
|
|
275
|
+
array(name, type, count) {
|
|
276
|
+
return this.file.array(this.ptr(name), this.scope, type, count);
|
|
277
|
+
}
|
|
278
|
+
/** The bytes a pointer field names, through the end of their block. */
|
|
279
|
+
raw(name) {
|
|
280
|
+
return this.file.raw(this.ptr(name), this.scope);
|
|
281
|
+
}
|
|
282
|
+
/** The block a pointer field names. */
|
|
283
|
+
blockOf(name) {
|
|
284
|
+
return this.file.blockOf(this.ptr(name), this.scope);
|
|
285
|
+
}
|
|
286
|
+
/** A `char *` field's text. */
|
|
287
|
+
text(name) {
|
|
288
|
+
return this.file.text(this.ptr(name), this.scope);
|
|
289
|
+
}
|
|
290
|
+
/** An embedded struct field, viewed in place. */
|
|
291
|
+
sub(name) {
|
|
292
|
+
const field = this.field(name);
|
|
293
|
+
const struct = this.file.struct(field.type);
|
|
294
|
+
if (struct === undefined)
|
|
295
|
+
throw new DrftError(`blend: no struct ${field.type} in the catalogue`);
|
|
296
|
+
return new BlendStruct(this.file, struct, this.offset + field.offset, this.scope);
|
|
297
|
+
}
|
|
298
|
+
/** The links of a `ListBase` field, following `next` until it runs out. */
|
|
299
|
+
list(name, type) {
|
|
300
|
+
const base = this.sub(name);
|
|
301
|
+
const out = [];
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
let address = base.ptr('first');
|
|
304
|
+
while (address !== 0n && !seen.has(address)) {
|
|
305
|
+
seen.add(address);
|
|
306
|
+
const link = this.file.at(address, this.scope, type);
|
|
307
|
+
if (link === null)
|
|
308
|
+
break;
|
|
309
|
+
out.push(link);
|
|
310
|
+
/* `next` is the first pointer of every link, including those that embed it in a header
|
|
311
|
+
struct of their own, as every modifier and constraint does. */
|
|
312
|
+
address = this.file.pointer(link.offset);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
/** An array of pointers that a pointer-to-pointer field names: `Mesh.mat`, `Object.mat`. */
|
|
317
|
+
pointers(name, count) {
|
|
318
|
+
const raw = this.file.find(this.ptr(name), this.scope);
|
|
319
|
+
if (raw === null)
|
|
320
|
+
return [];
|
|
321
|
+
const out = [];
|
|
322
|
+
const base = this.file.blocks[raw.block].offset + raw.offset;
|
|
323
|
+
for (let i = 0; i < count; i++)
|
|
324
|
+
out.push(this.file.pointer(base + i * this.file.header.pointerSize));
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function elementSize(field) {
|
|
329
|
+
const count = field.dims.reduce((n, d) => n * d, 1);
|
|
330
|
+
return field.size / count;
|
|
331
|
+
}
|
|
332
|
+
function readInt(file, type, at) {
|
|
333
|
+
const le = file.header.littleEndian;
|
|
334
|
+
const view = file.view;
|
|
335
|
+
switch (type) {
|
|
336
|
+
case 'char':
|
|
337
|
+
case 'uchar':
|
|
338
|
+
case 'uint8_t':
|
|
339
|
+
case 'bool':
|
|
340
|
+
return view.getUint8(at);
|
|
341
|
+
case 'int8_t':
|
|
342
|
+
return view.getInt8(at);
|
|
343
|
+
case 'short':
|
|
344
|
+
case 'int16_t':
|
|
345
|
+
return view.getInt16(at, le);
|
|
346
|
+
case 'ushort':
|
|
347
|
+
case 'uint16_t':
|
|
348
|
+
return view.getUint16(at, le);
|
|
349
|
+
case 'int':
|
|
350
|
+
case 'int32_t':
|
|
351
|
+
return view.getInt32(at, le);
|
|
352
|
+
case 'uint':
|
|
353
|
+
case 'uint32_t':
|
|
354
|
+
return view.getUint32(at, le);
|
|
355
|
+
case 'int64_t':
|
|
356
|
+
case 'long':
|
|
357
|
+
return Number(view.getBigInt64(at, le));
|
|
358
|
+
case 'uint64_t':
|
|
359
|
+
case 'ulong':
|
|
360
|
+
return Number(view.getBigUint64(at, le));
|
|
361
|
+
case 'float':
|
|
362
|
+
return view.getFloat32(at, le);
|
|
363
|
+
default:
|
|
364
|
+
throw new DrftError(`blend: no integer reading for a field of type ${type}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `.blend` file's container: its header, its blocks, and the catalogue of structures the file
|
|
3
|
+
* carries to describe its own layout.
|
|
4
|
+
*
|
|
5
|
+
* **The catalogue is what makes this readable at all.** Every `.blend` ends with a `DNA1` block
|
|
6
|
+
* listing each C struct the writing Blender knew — its fields, their types, their sizes — so a
|
|
7
|
+
* reader learns the layout *from the file* rather than hard-coding one Blender release. A mesh
|
|
8
|
+
* written by 2.79 and one written by 5.2 differ in nearly every struct, and both are read by the
|
|
9
|
+
* same code because neither is assumed.
|
|
10
|
+
*
|
|
11
|
+
* **Three container shapes are read, and anything else is refused by name.** The twelve-byte
|
|
12
|
+
* header every release before 5.0 wrote, with four- or eight-byte pointers and either byte order,
|
|
13
|
+
* and the seventeen-byte header 5.0 introduced, whose block headers carry 64-bit lengths. A file
|
|
14
|
+
* compressed with gzip (before 3.0) or zstd (3.0 on) is reported as such, and `readBlendFile`
|
|
15
|
+
* takes the decompressed bytes: decompressing is the host's capability, not this module's.
|
|
16
|
+
*
|
|
17
|
+
* What this gives up: a block whose pointer is not the start of a block is found by search, which
|
|
18
|
+
* is a binary search over every block. That is the cost of not assuming how Blender allocates.
|
|
19
|
+
*/
|
|
20
|
+
/** What the first bytes say, before any block is read. */
|
|
21
|
+
export interface BlendHeader {
|
|
22
|
+
readonly pointerSize: 4 | 8;
|
|
23
|
+
readonly littleEndian: boolean;
|
|
24
|
+
/** The writing Blender's version, as the header spells it: 279, 405, 500. */
|
|
25
|
+
readonly version: number;
|
|
26
|
+
/** Bytes of header before the first block. */
|
|
27
|
+
readonly headerSize: number;
|
|
28
|
+
/** Bytes in each block header: 20, 24, or 32 for the 64-bit form 5.0 writes. */
|
|
29
|
+
readonly blockHeaderSize: number;
|
|
30
|
+
}
|
|
31
|
+
/** One block: a run of `count` structs of catalogue type `sdna`, at `offset` in the file. */
|
|
32
|
+
export interface BlendBlock {
|
|
33
|
+
readonly code: string;
|
|
34
|
+
readonly sdna: number;
|
|
35
|
+
/** The address the struct had in the writing process, which every pointer to it names. */
|
|
36
|
+
readonly address: bigint;
|
|
37
|
+
readonly length: number;
|
|
38
|
+
readonly count: number;
|
|
39
|
+
readonly offset: number;
|
|
40
|
+
}
|
|
41
|
+
/** One field of a catalogued struct, with its offset already summed. */
|
|
42
|
+
export interface DnaField {
|
|
43
|
+
/** The bare name: `*next` and `co[3]` are `next` and `co`. */
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly type: string;
|
|
46
|
+
readonly offset: number;
|
|
47
|
+
readonly size: number;
|
|
48
|
+
readonly pointer: boolean;
|
|
49
|
+
/** Array dimensions, outermost first; empty for a scalar. */
|
|
50
|
+
readonly dims: readonly number[];
|
|
51
|
+
}
|
|
52
|
+
export interface DnaStruct {
|
|
53
|
+
readonly index: number;
|
|
54
|
+
readonly type: string;
|
|
55
|
+
readonly size: number;
|
|
56
|
+
readonly fields: ReadonlyMap<string, DnaField>;
|
|
57
|
+
}
|
|
58
|
+
/** Which compression, if any, a file's first bytes declare. */
|
|
59
|
+
export type BlendCompression = 'gzip' | 'zstd' | null;
|
|
60
|
+
/** The compression a `.blend` was saved with, by its magic, or null for a plain file. */
|
|
61
|
+
export declare function blendCompression(bytes: Uint8Array): BlendCompression;
|
|
62
|
+
/** Whether these bytes are an uncompressed `.blend`. */
|
|
63
|
+
export declare function isBlend(bytes: Uint8Array): boolean;
|
|
64
|
+
/** Parse the header, refusing any shape this module has not been taught. */
|
|
65
|
+
export declare function readBlendHeader(bytes: Uint8Array): BlendHeader;
|
|
66
|
+
/** Walk every block header up to `ENDB`. */
|
|
67
|
+
export declare function readBlendBlocks(bytes: Uint8Array, header: BlendHeader): BlendBlock[];
|
|
68
|
+
/** Parse the `DNA1` block into structs with every field's offset summed. */
|
|
69
|
+
export declare function readDna(bytes: Uint8Array, block: BlendBlock, header: BlendHeader): DnaStruct[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `.blend` file's container: its header, its blocks, and the catalogue of structures the file
|
|
3
|
+
* carries to describe its own layout.
|
|
4
|
+
*
|
|
5
|
+
* **The catalogue is what makes this readable at all.** Every `.blend` ends with a `DNA1` block
|
|
6
|
+
* listing each C struct the writing Blender knew — its fields, their types, their sizes — so a
|
|
7
|
+
* reader learns the layout *from the file* rather than hard-coding one Blender release. A mesh
|
|
8
|
+
* written by 2.79 and one written by 5.2 differ in nearly every struct, and both are read by the
|
|
9
|
+
* same code because neither is assumed.
|
|
10
|
+
*
|
|
11
|
+
* **Three container shapes are read, and anything else is refused by name.** The twelve-byte
|
|
12
|
+
* header every release before 5.0 wrote, with four- or eight-byte pointers and either byte order,
|
|
13
|
+
* and the seventeen-byte header 5.0 introduced, whose block headers carry 64-bit lengths. A file
|
|
14
|
+
* compressed with gzip (before 3.0) or zstd (3.0 on) is reported as such, and `readBlendFile`
|
|
15
|
+
* takes the decompressed bytes: decompressing is the host's capability, not this module's.
|
|
16
|
+
*
|
|
17
|
+
* What this gives up: a block whose pointer is not the start of a block is found by search, which
|
|
18
|
+
* is a binary search over every block. That is the cost of not assuming how Blender allocates.
|
|
19
|
+
*/
|
|
20
|
+
import { DrftError } from '@driftengine/drft';
|
|
21
|
+
/** The compression a `.blend` was saved with, by its magic, or null for a plain file. */
|
|
22
|
+
export function blendCompression(bytes) {
|
|
23
|
+
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b)
|
|
24
|
+
return 'gzip';
|
|
25
|
+
if (bytes.length >= 4 && bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f) {
|
|
26
|
+
return bytes[3] === 0xfd ? 'zstd' : null;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Whether these bytes are an uncompressed `.blend`. */
|
|
31
|
+
export function isBlend(bytes) {
|
|
32
|
+
const magic = 'BLENDER';
|
|
33
|
+
if (bytes.length < 12)
|
|
34
|
+
return false;
|
|
35
|
+
for (let i = 0; i < magic.length; i++)
|
|
36
|
+
if (bytes[i] !== magic.charCodeAt(i))
|
|
37
|
+
return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const ascii = (bytes, at, length) => String.fromCharCode(...bytes.subarray(at, at + length));
|
|
41
|
+
/** Parse the header, refusing any shape this module has not been taught. */
|
|
42
|
+
export function readBlendHeader(bytes) {
|
|
43
|
+
if (!isBlend(bytes)) {
|
|
44
|
+
const packed = blendCompression(bytes);
|
|
45
|
+
throw new DrftError(packed === null
|
|
46
|
+
? 'blend: not a .blend file (no BLENDER magic)'
|
|
47
|
+
: `blend: this file is ${packed}-compressed and must be decompressed before it is read`);
|
|
48
|
+
}
|
|
49
|
+
const seventh = String.fromCharCode(bytes[7] ?? 0);
|
|
50
|
+
if (seventh === '-' || seventh === '_') {
|
|
51
|
+
const endian = String.fromCharCode(bytes[8] ?? 0);
|
|
52
|
+
if (endian !== 'v' && endian !== 'V')
|
|
53
|
+
throw new DrftError(`blend: byte order "${endian}"`);
|
|
54
|
+
const pointerSize = seventh === '-' ? 8 : 4;
|
|
55
|
+
return {
|
|
56
|
+
pointerSize,
|
|
57
|
+
littleEndian: endian === 'v',
|
|
58
|
+
version: Number(ascii(bytes, 9, 3)),
|
|
59
|
+
headerSize: 12,
|
|
60
|
+
blockHeaderSize: pointerSize === 8 ? 24 : 20,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/* 5.0 on: `BLENDER` + header size + `-` + file format version + byte order + four digits. */
|
|
64
|
+
const headerSize = Number(ascii(bytes, 7, 2));
|
|
65
|
+
const format = Number(ascii(bytes, 10, 2));
|
|
66
|
+
const endian = String.fromCharCode(bytes[12] ?? 0);
|
|
67
|
+
if (headerSize !== 17 ||
|
|
68
|
+
bytes[9] !== 0x2d ||
|
|
69
|
+
format !== 1 ||
|
|
70
|
+
(endian !== 'v' && endian !== 'V')) {
|
|
71
|
+
throw new DrftError(`blend: header "${ascii(bytes, 0, 17)}" is a container layout this reader does not know`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
pointerSize: 8,
|
|
75
|
+
littleEndian: endian === 'v',
|
|
76
|
+
version: Number(ascii(bytes, 13, 4)),
|
|
77
|
+
headerSize,
|
|
78
|
+
blockHeaderSize: 32,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Walk every block header up to `ENDB`. */
|
|
82
|
+
export function readBlendBlocks(bytes, header) {
|
|
83
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
84
|
+
const le = header.littleEndian;
|
|
85
|
+
const blocks = [];
|
|
86
|
+
let at = header.headerSize;
|
|
87
|
+
while (at + header.blockHeaderSize <= bytes.length) {
|
|
88
|
+
const code = ascii(bytes, at, 4).replace(/\0+$/, '');
|
|
89
|
+
let length;
|
|
90
|
+
let address;
|
|
91
|
+
let sdna;
|
|
92
|
+
let count;
|
|
93
|
+
if (header.blockHeaderSize === 32) {
|
|
94
|
+
sdna = view.getInt32(at + 4, le);
|
|
95
|
+
address = view.getBigUint64(at + 8, le);
|
|
96
|
+
length = Number(view.getBigInt64(at + 16, le));
|
|
97
|
+
count = Number(view.getBigInt64(at + 24, le));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
length = view.getInt32(at + 4, le);
|
|
101
|
+
address =
|
|
102
|
+
header.pointerSize === 8
|
|
103
|
+
? view.getBigUint64(at + 8, le)
|
|
104
|
+
: BigInt(view.getUint32(at + 8, le));
|
|
105
|
+
const rest = at + 8 + header.pointerSize;
|
|
106
|
+
sdna = view.getInt32(rest, le);
|
|
107
|
+
count = view.getInt32(rest + 4, le);
|
|
108
|
+
}
|
|
109
|
+
if (code === 'ENDB')
|
|
110
|
+
return blocks;
|
|
111
|
+
const offset = at + header.blockHeaderSize;
|
|
112
|
+
if (length < 0 || offset + length > bytes.length) {
|
|
113
|
+
throw new DrftError(`blend: block "${code}" at ${at} runs past the end of the file`);
|
|
114
|
+
}
|
|
115
|
+
blocks.push({ code, sdna, address, length, count, offset });
|
|
116
|
+
at = offset + length;
|
|
117
|
+
}
|
|
118
|
+
throw new DrftError('blend: the file ends before its ENDB block, so it was not saved completely');
|
|
119
|
+
}
|
|
120
|
+
/** Parse the `DNA1` block into structs with every field's offset summed. */
|
|
121
|
+
export function readDna(bytes, block, header) {
|
|
122
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
123
|
+
const le = header.littleEndian;
|
|
124
|
+
let at = block.offset;
|
|
125
|
+
const end = block.offset + block.length;
|
|
126
|
+
const expect = (tag) => {
|
|
127
|
+
at = block.offset + Math.ceil((at - block.offset) / 4) * 4;
|
|
128
|
+
if (ascii(bytes, at, 4) !== tag)
|
|
129
|
+
throw new DrftError(`blend: DNA1 has no ${tag} section`);
|
|
130
|
+
at += 4;
|
|
131
|
+
};
|
|
132
|
+
const strings = () => {
|
|
133
|
+
const count = view.getInt32(at, le);
|
|
134
|
+
at += 4;
|
|
135
|
+
const out = [];
|
|
136
|
+
for (let i = 0; i < count; i++) {
|
|
137
|
+
const stop = bytes.indexOf(0, at);
|
|
138
|
+
if (stop < 0 || stop >= end)
|
|
139
|
+
throw new DrftError('blend: DNA1 string table is truncated');
|
|
140
|
+
out.push(ascii(bytes, at, stop - at));
|
|
141
|
+
at = stop + 1;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
};
|
|
145
|
+
expect('SDNA');
|
|
146
|
+
expect('NAME');
|
|
147
|
+
const names = strings();
|
|
148
|
+
expect('TYPE');
|
|
149
|
+
const types = strings();
|
|
150
|
+
expect('TLEN');
|
|
151
|
+
const lengths = [];
|
|
152
|
+
for (let i = 0; i < types.length; i++)
|
|
153
|
+
lengths.push(view.getInt16(at + i * 2, le));
|
|
154
|
+
at += types.length * 2;
|
|
155
|
+
expect('STRC');
|
|
156
|
+
const count = view.getInt32(at, le);
|
|
157
|
+
at += 4;
|
|
158
|
+
const structs = [];
|
|
159
|
+
for (let s = 0; s < count; s++) {
|
|
160
|
+
const typeIndex = view.getInt16(at, le);
|
|
161
|
+
const fieldCount = view.getInt16(at + 2, le);
|
|
162
|
+
at += 4;
|
|
163
|
+
const fields = new Map();
|
|
164
|
+
let offset = 0;
|
|
165
|
+
for (let f = 0; f < fieldCount; f++) {
|
|
166
|
+
const typeOf = view.getInt16(at, le);
|
|
167
|
+
const type = types[typeOf] ?? '';
|
|
168
|
+
const raw = names[view.getInt16(at + 2, le)] ?? '';
|
|
169
|
+
at += 4;
|
|
170
|
+
const pointer = raw.startsWith('*') || raw.startsWith('(*');
|
|
171
|
+
const dims = [...raw.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
|
|
172
|
+
const element = pointer ? header.pointerSize : (lengths[typeOf] ?? 0);
|
|
173
|
+
const size = dims.reduce((n, d) => n * d, element);
|
|
174
|
+
const name = raw.replace(/^[(*]+/, '').replace(/[)[].*$/, '');
|
|
175
|
+
fields.set(name, { name, type, offset, size, pointer, dims });
|
|
176
|
+
offset += size;
|
|
177
|
+
}
|
|
178
|
+
const type = types[typeIndex] ?? '';
|
|
179
|
+
structs.push({ index: s, type, size: lengths[typeIndex] ?? offset, fields });
|
|
180
|
+
}
|
|
181
|
+
return structs;
|
|
182
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `.blend` mesh as triangle primitives, one per material slot, in glTF's conventions.
|
|
3
|
+
*
|
|
4
|
+
* **The same conversion serves every route out of a `.blend`.** `blendGltf.ts` writes these into a
|
|
5
|
+
* glTF document for `readModel`, and a bake that keeps a world's instancing calls this once per
|
|
6
|
+
* distinct mesh rather than once per object. So what a corner becomes — its position turned Y up,
|
|
7
|
+
* its UV flipped to glTF's origin, its colour linear — is decided here and nowhere else.
|
|
8
|
+
*
|
|
9
|
+
* **Vertices are shared where every attribute agrees exactly**, which is how Blender's exporter
|
|
10
|
+
* builds them, found by walking the exported copies of each source vertex rather than by hashing,
|
|
11
|
+
* so a fifty-million-corner city costs a scan rather than fifty million string keys.
|
|
12
|
+
*
|
|
13
|
+
* **Faces are triangulated as Blender does it**: a quad split from its first corner, an ngon by ear
|
|
14
|
+
* clipping in its own plane. Blender clips ears in a different order, so an ngon's diagonals can
|
|
15
|
+
* differ from Blender's; on a planar face that changes no pixel, on a bent one it changes the bend.
|
|
16
|
+
*/
|
|
17
|
+
import type { BlendMeshData } from './blendMesh.ts';
|
|
18
|
+
/** What a material slot asks of the geometry that wears it. */
|
|
19
|
+
export interface SlotNeeds {
|
|
20
|
+
/** Which colour attribute to carry as `COLOR_0`: a name, `''` for the render one, or null. */
|
|
21
|
+
readonly vertexColor: string | null;
|
|
22
|
+
/** Which UV map to carry as `TEXCOORD_0`, by name, or null for the render map. */
|
|
23
|
+
readonly uvMap: string | null;
|
|
24
|
+
/** A 3x3 affine transform applied to that map in Blender's UV space, or null. */
|
|
25
|
+
readonly uvTransform: readonly number[] | null;
|
|
26
|
+
}
|
|
27
|
+
export interface BlendPrimitive {
|
|
28
|
+
/** The material slot, or -1 where the object has none. */
|
|
29
|
+
readonly slot: number;
|
|
30
|
+
readonly positions: Float32Array;
|
|
31
|
+
readonly normals: Float32Array;
|
|
32
|
+
readonly uvs: Float32Array | null;
|
|
33
|
+
readonly colors: Float32Array | null;
|
|
34
|
+
readonly indices: Uint32Array;
|
|
35
|
+
/** The source vertex each exported vertex came from, for shape keys and weights. */
|
|
36
|
+
readonly sourceVertex: Int32Array;
|
|
37
|
+
/** The source corner each exported vertex first came from, for per-corner shape normals. */
|
|
38
|
+
readonly sourceCorner: Int32Array;
|
|
39
|
+
}
|
|
40
|
+
/** Blender Z up to glTF Y up: (x, y, z) becomes (x, z, -y). */
|
|
41
|
+
export declare function toYUp(out: Float32Array, at: number, x: number, y: number, z: number): void;
|
|
42
|
+
/** Triangles of one face, as corner indices, appended to `out`. */
|
|
43
|
+
export declare function triangulateFace(mesh: BlendMeshData, face: number, normal: readonly number[], out: number[]): void;
|
|
44
|
+
/** Split a mesh into one primitive per material slot it uses. */
|
|
45
|
+
export declare function meshPrimitives(mesh: BlendMeshData, normals: Float32Array, slots: number, needs: (slot: number) => SlotNeeds): BlendPrimitive[];
|