@lovelace_lol/embody 0.0.16 → 0.0.18

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.
@@ -0,0 +1,599 @@
1
+ import { readGlbSource } from './glb-source.mjs';
2
+
3
+ // These extensions have only the inline factors/texture references remapped below.
4
+ // Compression, scene extensions, and unknown payloads must never be silently lost.
5
+ const MATERIAL_EXTENSIONS = new Set([
6
+ 'KHR_materials_unlit', 'KHR_materials_clearcoat', 'KHR_materials_emissive_strength',
7
+ 'KHR_materials_ior', 'KHR_materials_iridescence', 'KHR_materials_sheen',
8
+ 'KHR_materials_specular', 'KHR_materials_transmission', 'KHR_materials_volume',
9
+ 'KHR_materials_anisotropy', 'KHR_materials_dispersion', 'EXT_materials_bump',
10
+ ]);
11
+ const EXTENSIONS = new Set([...MATERIAL_EXTENSIONS, 'KHR_texture_transform', 'KHR_mesh_quantization', 'EXT_texture_webp']);
12
+ const COMPONENTS = new Map([[5120, 1], [5121, 1], [5122, 2], [5123, 2], [5125, 4], [5126, 4]]);
13
+ const SHAPES = new Map([['SCALAR', [1, 1]], ['VEC2', [1, 2]], ['VEC3', [1, 3]], ['VEC4', [1, 4]],
14
+ ['MAT2', [2, 2]], ['MAT3', [3, 3]], ['MAT4', [4, 4]]]);
15
+
16
+ function fail(message) { throw new Error(`Garment extraction: ${message}`); }
17
+ function integer(value, min = 0) { return Number.isSafeInteger(value) && value >= min; }
18
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
19
+ function align4(value) { return Math.ceil(value / 4) * 4; }
20
+ function abort(signal) {
21
+ if (!signal?.aborted) return;
22
+ const error = new Error('Garment extraction cancelled.');
23
+ error.name = 'AbortError';
24
+ throw error;
25
+ }
26
+ async function yieldToCaller(signal) {
27
+ await new Promise((resolve) => setTimeout(resolve, 0));
28
+ abort(signal);
29
+ }
30
+
31
+ function table(json, name) {
32
+ const values = json[name] ?? [];
33
+ if (!Array.isArray(values) || values.some((value) => !object(value))) fail(`${name} must be an array of objects.`);
34
+ return values;
35
+ }
36
+
37
+ function indexOf(values, index, label) {
38
+ if (!integer(index) || index >= values.length) fail(`${label} has an invalid source index.`);
39
+ return values[index];
40
+ }
41
+
42
+ function validateExtensions(json) {
43
+ const used = json.extensionsUsed ?? [];
44
+ const required = json.extensionsRequired ?? [];
45
+ for (const list of [used, required]) {
46
+ if (!Array.isArray(list) || list.some((name) => typeof name !== 'string')) fail('extension declarations must be string arrays.');
47
+ const unsupported = list.filter((name) => !EXTENSIONS.has(name));
48
+ if (unsupported.length) fail(`unsupported extensions: ${[...new Set(unsupported)].join(', ')}.`);
49
+ }
50
+ if (required.some((name) => !used.includes(name))) fail('required extensions must also be declared in extensionsUsed.');
51
+ function visit(value, path) {
52
+ if (!value || typeof value !== 'object') return;
53
+ if (value.extensions !== undefined) {
54
+ if (!object(value.extensions)) fail(`${path}.extensions must be an object.`);
55
+ for (const [name, payload] of Object.entries(value.extensions)) {
56
+ if (!EXTENSIONS.has(name) || !used.includes(name)) fail(`unsupported or undeclared extension ${name}.`);
57
+ if (!object(payload)) fail(`${name} must contain an object.`);
58
+ const validPlacement = MATERIAL_EXTENSIONS.has(name) ? /^materials\[\d+\]$/.test(path)
59
+ : name === 'EXT_texture_webp' ? /^textures\[\d+\]$/.test(path)
60
+ : name === 'KHR_texture_transform' && /(?:Texture|TextureInfo)$/.test(path);
61
+ if (!validPlacement) fail(`${name} is not supported at ${path || 'the document root'}.`);
62
+ }
63
+ }
64
+ for (const [key, child] of Object.entries(value)) {
65
+ // Extras are application data, not glTF references or extension declarations.
66
+ if (key === 'extras') continue;
67
+ if (Array.isArray(child)) child.forEach((item, index) => visit(item, `${path ? `${path}.` : ''}${key}[${index}]`));
68
+ else if (child && typeof child === 'object') visit(child, path ? `${path}.${key}` : key);
69
+ }
70
+ }
71
+ visit(json, '');
72
+ }
73
+
74
+ function validateStorage(json, binary) {
75
+ const buffers = table(json, 'buffers');
76
+ if (buffers.length > 1 || buffers.some((buffer) => buffer.uri !== undefined)) fail('only one embedded GLB buffer is supported; resource URIs are not supported.');
77
+ const size = buffers[0]?.byteLength ?? 0;
78
+ if ((buffers.length && !integer(size, 1)) || binary.length < size || binary.length > size + 3) fail('embedded buffer length does not match the binary chunk.');
79
+ const views = table(json, 'bufferViews');
80
+ for (const view of views) {
81
+ if (!buffers.length || view.buffer !== 0 || !integer(view.byteOffset ?? 0) || !integer(view.byteLength, 1)
82
+ || (view.byteOffset ?? 0) + view.byteLength > size) fail('a bufferView exceeds the embedded buffer.');
83
+ if (view.byteStride !== undefined && (!integer(view.byteStride, 4) || view.byteStride > 252 || view.byteStride % 4)) fail('invalid interleaved bufferView stride.');
84
+ }
85
+ for (const image of table(json, 'images')) {
86
+ if (image.uri !== undefined) fail('external and data image URIs are not supported.');
87
+ indexOf(views, image.bufferView, 'Image bufferView');
88
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(image.mimeType)) fail(`unsupported image type ${image.mimeType}.`);
89
+ }
90
+ return { buffers, views };
91
+ }
92
+
93
+ function accessorLayout(accessor) {
94
+ const componentBytes = COMPONENTS.get(accessor.componentType);
95
+ const shape = SHAPES.get(accessor.type);
96
+ if (!componentBytes || !shape || !integer(accessor.count, 1)) fail('invalid accessor component type, shape, or count.');
97
+ const [columns, rows] = shape;
98
+ const columnBytes = columns > 1 ? align4(rows * componentBytes) : rows * componentBytes;
99
+ return { componentBytes, elementBytes: columns * columnBytes, columns, rows, columnBytes };
100
+ }
101
+
102
+ function viewRange(views, viewIndex, offset, count, elementBytes, componentBytes, interleaved) {
103
+ const view = indexOf(views, viewIndex, 'Accessor bufferView');
104
+ const stride = interleaved ? view.byteStride ?? elementBytes : elementBytes;
105
+ if (!integer(offset) || offset % componentBytes || ((view.byteOffset ?? 0) + offset) % componentBytes
106
+ || stride < elementBytes || (count - 1) * stride + offset + elementBytes > view.byteLength) fail('accessor data exceeds or is misaligned in its bufferView.');
107
+ return { view, stride, start: (view.byteOffset ?? 0) + offset };
108
+ }
109
+
110
+ function readUnsigned(binary, start, type) {
111
+ const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
112
+ return type === 5121 ? view.getUint8(start) : type === 5123 ? view.getUint16(start, true) : view.getUint32(start, true);
113
+ }
114
+
115
+ function validateAccessors(json, views, binary) {
116
+ const accessors = table(json, 'accessors');
117
+ for (const accessor of accessors) {
118
+ const layout = accessorLayout(accessor);
119
+ if (accessor.normalized !== undefined && typeof accessor.normalized !== 'boolean') fail('accessor.normalized must be boolean.');
120
+ if (accessor.normalized && [5125, 5126].includes(accessor.componentType)) fail('this accessor component type cannot be normalized.');
121
+ if (layout.columns > 1 && accessor.bufferView !== undefined
122
+ && ((views[accessor.bufferView]?.byteOffset ?? 0) + (accessor.byteOffset ?? 0)) % 4) fail('matrix accessor columns require four-byte alignment.');
123
+ if (accessor.bufferView !== undefined) viewRange(views, accessor.bufferView, accessor.byteOffset ?? 0,
124
+ accessor.count, layout.elementBytes, layout.componentBytes, true);
125
+ else if (accessor.byteOffset !== undefined) fail('an accessor without a bufferView cannot have a byte offset.');
126
+ if (accessor.sparse !== undefined) {
127
+ const sparse = accessor.sparse;
128
+ if (!object(sparse) || !integer(sparse.count, 1) || sparse.count > accessor.count
129
+ || !object(sparse.indices) || !object(sparse.values) || ![5121, 5123, 5125].includes(sparse.indices.componentType)) fail('invalid sparse accessor.');
130
+ const bytes = COMPONENTS.get(sparse.indices.componentType);
131
+ const indices = viewRange(views, sparse.indices.bufferView, sparse.indices.byteOffset ?? 0, sparse.count, bytes, bytes, false);
132
+ viewRange(views, sparse.values.bufferView, sparse.values.byteOffset ?? 0, sparse.count, layout.elementBytes, layout.componentBytes, false);
133
+ if (layout.columns > 1 && ((views[sparse.values.bufferView].byteOffset ?? 0) + (sparse.values.byteOffset ?? 0)) % 4) fail('sparse matrix values require four-byte alignment.');
134
+ let previous = -1;
135
+ for (let index = 0; index < sparse.count; index++) {
136
+ const vertex = readUnsigned(binary, indices.start + index * bytes, sparse.indices.componentType);
137
+ if (vertex <= previous || vertex >= accessor.count) fail('sparse accessor indices must be increasing and within the accessor.');
138
+ previous = vertex;
139
+ }
140
+ }
141
+ }
142
+ return accessors;
143
+ }
144
+
145
+ /** Read authored values only for bounds/ownership validation; emission copies their original bytes. */
146
+ function accessorReader(accessors, views, binary) {
147
+ const cache = new Map();
148
+ const data = new DataView(binary.buffer, binary.byteOffset, binary.byteLength);
149
+ function numberAt(offset, type, normalized) {
150
+ const value = type === 5120 ? data.getInt8(offset) : type === 5121 ? data.getUint8(offset)
151
+ : type === 5122 ? data.getInt16(offset, true) : type === 5123 ? data.getUint16(offset, true)
152
+ : type === 5125 ? data.getUint32(offset, true) : data.getFloat32(offset, true);
153
+ if (!normalized || type === 5126 || type === 5125) return value;
154
+ return type === 5120 ? Math.max(-1, value / 127) : type === 5121 ? value / 255
155
+ : type === 5122 ? Math.max(-1, value / 32767) : value / 65535;
156
+ }
157
+ return function read(index, vertex) {
158
+ if (!cache.has(index)) {
159
+ const accessor = indexOf(accessors, index, 'Vertex accessor');
160
+ const layout = accessorLayout(accessor);
161
+ const base = accessor.bufferView === undefined ? null : viewRange(views, accessor.bufferView,
162
+ accessor.byteOffset ?? 0, accessor.count, layout.elementBytes, layout.componentBytes, true);
163
+ const sparse = new Map();
164
+ if (accessor.sparse) {
165
+ const spec = accessor.sparse;
166
+ const bytes = COMPONENTS.get(spec.indices.componentType);
167
+ const indices = viewRange(views, spec.indices.bufferView, spec.indices.byteOffset ?? 0, spec.count, bytes, bytes, false);
168
+ const values = viewRange(views, spec.values.bufferView, spec.values.byteOffset ?? 0,
169
+ spec.count, layout.elementBytes, layout.componentBytes, false);
170
+ for (let item = 0; item < spec.count; item++) sparse.set(readUnsigned(binary,
171
+ indices.start + item * bytes, spec.indices.componentType), values.start + item * layout.elementBytes);
172
+ }
173
+ cache.set(index, { accessor, layout, base, sparse });
174
+ }
175
+ const { accessor, layout, base, sparse } = cache.get(index);
176
+ const offset = sparse.get(vertex) ?? (base ? base.start + vertex * base.stride : undefined);
177
+ const result = [];
178
+ for (let column = 0; column < layout.columns; column++) for (let row = 0; row < layout.rows; row++) {
179
+ result.push(offset === undefined ? 0 : numberAt(offset + column * layout.columnBytes + row * layout.componentBytes,
180
+ accessor.componentType, accessor.normalized));
181
+ }
182
+ return result;
183
+ };
184
+ }
185
+
186
+ function sourceHierarchy(nodes) {
187
+ const parents = new Map();
188
+ nodes.forEach((node, parent) => {
189
+ for (const [field, length] of [['translation', 3], ['rotation', 4], ['scale', 3], ['matrix', 16]]) {
190
+ if (node[field] !== undefined && (!Array.isArray(node[field]) || node[field].length !== length
191
+ || node[field].some((value) => typeof value !== 'number' || !Number.isFinite(value)))) fail(`node ${field} is invalid.`);
192
+ }
193
+ if (node.matrix && (node.translation || node.rotation || node.scale)) fail('nodes cannot define both matrix and TRS transforms.');
194
+ if (node.children !== undefined && !Array.isArray(node.children)) fail('node children must be an array.');
195
+ for (const child of node.children ?? []) {
196
+ indexOf(nodes, child, 'Node child');
197
+ if (parents.has(child)) fail('nodes must have only one parent.');
198
+ parents.set(child, parent);
199
+ }
200
+ });
201
+ for (let index = 0; index < nodes.length; index++) {
202
+ const visited = new Set();
203
+ for (let current = index; current !== undefined; current = parents.get(current)) {
204
+ if (visited.has(current)) fail('node hierarchy contains a cycle.');
205
+ visited.add(current);
206
+ }
207
+ }
208
+ return parents;
209
+ }
210
+
211
+ function validateSelections(json, options) {
212
+ const nodes = table(json, 'nodes');
213
+ const meshes = table(json, 'meshes');
214
+ const selections = options?.selections;
215
+ if (!Array.isArray(selections) || !selections.length) fail('select at least one source node primitive.');
216
+ const selected = new Map();
217
+ for (const selection of selections) {
218
+ const node = indexOf(nodes, selection?.nodeIndex, 'Selected node');
219
+ const mesh = indexOf(meshes, node.mesh, 'Selected node mesh');
220
+ if (selected.has(selection.nodeIndex)) fail('a source node may only occur once in selections.');
221
+ if (!Array.isArray(mesh.primitives) || !Array.isArray(selection.primitiveIndices) || !selection.primitiveIndices.length) fail('select at least one primitive of each node.');
222
+ const indices = new Set();
223
+ for (const index of selection.primitiveIndices) {
224
+ indexOf(mesh.primitives, index, 'Selected primitive');
225
+ if (indices.has(index)) fail('selected primitive indices must be unique.');
226
+ indices.add(index);
227
+ }
228
+ selected.set(selection.nodeIndex, indices);
229
+ }
230
+ const owned = options.ownedBoneNodeIndices ?? [];
231
+ if (!Array.isArray(owned) || owned.some((index) => !integer(index) || index >= nodes.length) || new Set(owned).size !== owned.length) fail('ownedBoneNodeIndices must contain unique source node indices.');
232
+ return { nodes, meshes, selected, owned: new Set(owned) };
233
+ }
234
+
235
+ function serializeGlb(json, binary) {
236
+ const encoded = new TextEncoder().encode(JSON.stringify(json));
237
+ const jsonSize = align4(encoded.length);
238
+ const binSize = align4(binary.length);
239
+ const output = new Uint8Array(20 + jsonSize + (binary.length ? 8 + binSize : 0));
240
+ const view = new DataView(output.buffer);
241
+ [0x46546c67, 2, output.length, jsonSize, 0x4e4f534a].forEach((value, index) => view.setUint32(index * 4, value, true));
242
+ output.fill(32, 20, 20 + jsonSize);
243
+ output.set(encoded, 20);
244
+ if (binary.length) {
245
+ view.setUint32(20 + jsonSize, binSize, true);
246
+ view.setUint32(24 + jsonSize, 0x004e4942, true);
247
+ output.set(binary, 28 + jsonSize);
248
+ }
249
+ return output;
250
+ }
251
+
252
+ /** Extract source-file node primitives without resampling, optimization, or a live-pose export. */
253
+ export async function extractGarmentModel(source, options) {
254
+ abort(options?.signal);
255
+ const { json, binary } = readGlbSource(source, 'Garment Model');
256
+ if (json.asset.minVersion !== undefined && json.asset.minVersion !== '2.0') fail('only glTF 2.0 minimum versions are supported.');
257
+ validateExtensions(json);
258
+ const { buffers, views } = validateStorage(json, binary);
259
+ const accessors = validateAccessors(json, views, binary);
260
+ const { nodes, meshes, selected, owned } = validateSelections(json, options);
261
+ const signal = options.signal;
262
+ const parents = sourceHierarchy(nodes);
263
+ const skins = table(json, 'skins');
264
+ const keptNodes = new Set();
265
+ const keptSkins = new Set();
266
+ const joints = new Set();
267
+ function retainAncestors(index) {
268
+ for (let current = index; current !== undefined; current = parents.get(current)) keptNodes.add(current);
269
+ }
270
+ for (const nodeIndex of selected.keys()) {
271
+ retainAncestors(nodeIndex);
272
+ const skinIndex = nodes[nodeIndex].skin;
273
+ if (skinIndex === undefined) continue;
274
+ const skin = indexOf(skins, skinIndex, 'Selected node skin');
275
+ if (!Array.isArray(skin.joints) || !skin.joints.length || new Set(skin.joints).size !== skin.joints.length) fail('skin joints must be a nonempty unique node list.');
276
+ keptSkins.add(skinIndex);
277
+ for (const joint of skin.joints) {
278
+ indexOf(nodes, joint, 'Skin joint');
279
+ joints.add(joint);
280
+ retainAncestors(joint);
281
+ }
282
+ if (skin.skeleton !== undefined) { indexOf(nodes, skin.skeleton, 'Skin skeleton'); retainAncestors(skin.skeleton); }
283
+ if (skin.inverseBindMatrices !== undefined) {
284
+ const accessor = indexOf(accessors, skin.inverseBindMatrices, 'Skin inverse bind matrices');
285
+ if (accessor.type !== 'MAT4' || accessor.componentType !== 5126 || accessor.normalized || accessor.count !== skin.joints.length) fail('skin inverse bind matrices must contain one float MAT4 per joint.');
286
+ }
287
+ }
288
+ function parentJoint(index) {
289
+ let current = parents.get(index);
290
+ while (current !== undefined && !joints.has(current)) current = parents.get(current);
291
+ return current;
292
+ }
293
+ function ownedBranchRoot(index) {
294
+ let current = index;
295
+ while (owned.has(parentJoint(current))) current = parentJoint(current);
296
+ return current;
297
+ }
298
+ for (const index of owned) {
299
+ if (!joints.has(index)) fail('a garment-owned bone must be a joint in a selected skin.');
300
+ let current = index;
301
+ while (owned.has(current)) {
302
+ current = parentJoint(current);
303
+ if (current === undefined) fail('a garment-owned bone chain requires a shared parent joint.');
304
+ }
305
+ }
306
+ const readElement = accessorReader(accessors, views, binary);
307
+ const selectedInfluences = new Set();
308
+ const otherInfluences = new Set();
309
+ function inspectPrimitive(primitive, skin, influences) {
310
+ if (!object(primitive) || !object(primitive.attributes)) fail('selected primitives require vertex attributes.');
311
+ const position = indexOf(accessors, primitive.attributes.POSITION, 'Position accessor');
312
+ if (position.type !== 'VEC3') fail('POSITION must be a VEC3 accessor.');
313
+ if (primitive.mode !== undefined && (!integer(primitive.mode) || primitive.mode > 6)) fail('invalid primitive mode.');
314
+ if (primitive.targets !== undefined && !Array.isArray(primitive.targets)) fail('morph targets must be an array.');
315
+ for (const streams of [primitive.attributes, ...(primitive.targets ?? [])]) {
316
+ if (!object(streams)) fail('primitive vertex streams must be objects.');
317
+ for (const [semantic, index] of Object.entries(streams)) {
318
+ const accessor = indexOf(accessors, index, `${semantic} accessor`);
319
+ if (accessor.count !== position.count) fail('selected primitive vertex streams have different counts.');
320
+ const layout = accessorLayout(accessor);
321
+ if (accessor.bufferView !== undefined) {
322
+ const view = views[accessor.bufferView];
323
+ if (((view.byteOffset ?? 0) + (accessor.byteOffset ?? 0)) % 4
324
+ || (view.byteStride ?? layout.elementBytes) % 4) fail('vertex attributes require four-byte element alignment.');
325
+ }
326
+ if (accessor.sparse && ((views[accessor.sparse.values.bufferView].byteOffset ?? 0)
327
+ + (accessor.sparse.values.byteOffset ?? 0)) % 4) fail('sparse vertex values require four-byte alignment.');
328
+ }
329
+ }
330
+ let referencedVertices;
331
+ if (primitive.indices !== undefined) {
332
+ const indices = indexOf(accessors, primitive.indices, 'Primitive indices');
333
+ if (indices.type !== 'SCALAR' || indices.normalized || ![5121, 5123, 5125].includes(indices.componentType)) fail('primitive indices must be unsigned integer scalars.');
334
+ referencedVertices = new Set();
335
+ for (let item = 0; item < indices.count; item++) {
336
+ const vertex = readElement(primitive.indices, item)[0];
337
+ if (vertex >= position.count) fail('primitive indices exceed the vertex count.');
338
+ referencedVertices.add(vertex);
339
+ }
340
+ }
341
+ if (!skin) return;
342
+ const semantics = Object.keys(primitive.attributes);
343
+ const sets = semantics.filter((name) => /^(JOINTS|WEIGHTS)_/.test(name));
344
+ if (!sets.includes('JOINTS_0') || !sets.includes('WEIGHTS_0')) fail('skinned primitives require JOINTS_0 and WEIGHTS_0.');
345
+ for (const name of sets) {
346
+ if (!/^(JOINTS|WEIGHTS)_\d+$/.test(name)) fail('invalid skin influence attribute name.');
347
+ const suffix = name.slice(name.indexOf('_') + 1);
348
+ const jointIndex = primitive.attributes[`JOINTS_${suffix}`];
349
+ const weightIndex = primitive.attributes[`WEIGHTS_${suffix}`];
350
+ if (jointIndex === undefined || weightIndex === undefined) fail('each JOINTS set requires a corresponding WEIGHTS set.');
351
+ if (name.startsWith('WEIGHTS')) continue;
352
+ const jointAccessor = accessors[jointIndex];
353
+ const weightAccessor = accessors[weightIndex];
354
+ if (jointAccessor.type !== 'VEC4' || ![5121, 5123].includes(jointAccessor.componentType) || jointAccessor.normalized
355
+ || weightAccessor.type !== 'VEC4' || ![5121, 5123, 5126].includes(weightAccessor.componentType)
356
+ || (weightAccessor.componentType !== 5126 && !weightAccessor.normalized)) fail('invalid joint/weight accessor types.');
357
+ for (let vertex = 0; vertex < position.count; vertex++) {
358
+ const palette = readElement(jointIndex, vertex);
359
+ const weights = readElement(weightIndex, vertex);
360
+ for (let influence = 0; influence < 4; influence++) {
361
+ if (!integer(palette[influence]) || palette[influence] >= skin.joints.length
362
+ || !Number.isFinite(weights[influence]) || weights[influence] < 0 || weights[influence] > 1) fail('invalid joint palette index or weight.');
363
+ if (weights[influence] > 0 && (!referencedVertices || referencedVertices.has(vertex))) influences?.add(skin.joints[palette[influence]]);
364
+ }
365
+ }
366
+ }
367
+ }
368
+ for (const [nodeIndex, primitiveIndices] of selected) {
369
+ const node = nodes[nodeIndex];
370
+ for (const primitiveIndex of primitiveIndices) inspectPrimitive(meshes[node.mesh].primitives[primitiveIndex],
371
+ node.skin === undefined ? null : skins[node.skin], selectedInfluences);
372
+ await yieldToCaller(signal);
373
+ }
374
+ if (owned.size) {
375
+ // Other source instances count too, even when they share the same mesh resource.
376
+ for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
377
+ const node = nodes[nodeIndex];
378
+ if (node.mesh === undefined || node.skin === undefined) continue;
379
+ const skin = indexOf(skins, node.skin, 'Source skin');
380
+ if (!Array.isArray(skin.joints) || skin.joints.some((index) => !integer(index) || index >= nodes.length)) fail('invalid source skin joints.');
381
+ const mesh = indexOf(meshes, node.mesh, 'Source mesh');
382
+ if (!Array.isArray(mesh.primitives)) fail('source mesh primitives must be an array.');
383
+ mesh.primitives.forEach((primitive, primitiveIndex) => {
384
+ if (!selected.get(nodeIndex)?.has(primitiveIndex)) inspectPrimitive(primitive, skin, otherInfluences);
385
+ });
386
+ }
387
+ const sourceJoints = new Set(skins.flatMap((skin) => skin.joints ?? []));
388
+ const weightedBranches = new Set([...selectedInfluences].filter((index) => owned.has(index)).map(ownedBranchRoot));
389
+ for (const index of owned) {
390
+ if (otherInfluences.has(index)) fail('a garment-owned bone also moves an unselected source primitive.');
391
+ const pending = [...(nodes[index].children ?? [])];
392
+ while (pending.length) {
393
+ const child = pending.pop();
394
+ if (sourceJoints.has(child) && !owned.has(child)) fail('select the complete garment-owned joint chain.');
395
+ pending.push(...(nodes[child].children ?? []));
396
+ }
397
+ // Nonweighted intermediates/leaves are legal within a branch that moves this garment.
398
+ if (!weightedBranches.has(ownedBranchRoot(index))) fail('a garment-owned bone chain must move a selected source primitive.');
399
+ if (nodes[index].extras !== undefined && !object(nodes[index].extras)) fail('garment-owned joint extras must be an object to add ownership metadata.');
400
+ }
401
+ }
402
+ await yieldToCaller(signal);
403
+
404
+ const output = { asset: structuredClone(json.asset) };
405
+ if (json.extras !== undefined) output.extras = structuredClone(json.extras);
406
+ const nodeMap = new Map([...keptNodes].sort((a, b) => a - b).map((index, next) => [index, next]));
407
+ const skinMap = new Map([...keptSkins].sort((a, b) => a - b).map((index, next) => [index, next]));
408
+ const chunks = [];
409
+ const outputViews = [];
410
+ let binaryLength = 0;
411
+ function appendView(sourceIndex, data, target) {
412
+ const sourceView = indexOf(views, sourceIndex, 'Retained bufferView');
413
+ const view = { ...structuredClone(sourceView), buffer: 0, byteOffset: binaryLength, byteLength: data.length };
414
+ delete view.byteStride;
415
+ if (target === undefined) delete view.target;
416
+ else view.target = target;
417
+ const index = outputViews.push(view) - 1;
418
+ chunks.push({ start: binaryLength, data });
419
+ binaryLength += align4(data.length);
420
+ return index;
421
+ }
422
+ function copyElements(sourceIndex, offset, count, elementBytes, componentBytes, interleaved) {
423
+ const range = viewRange(views, sourceIndex, offset, count, elementBytes, componentBytes, interleaved);
424
+ const result = new Uint8Array(count * elementBytes);
425
+ for (let element = 0; element < count; element++) result.set(binary.subarray(range.start + element * range.stride,
426
+ range.start + element * range.stride + elementBytes), element * elementBytes);
427
+ return result;
428
+ }
429
+ const accessorMap = new Map();
430
+ const outputAccessors = [];
431
+ const vertexAccessors = new Set();
432
+ for (const [nodeIndex, primitiveIndices] of selected) for (const primitiveIndex of primitiveIndices) {
433
+ const primitive = meshes[nodes[nodeIndex].mesh].primitives[primitiveIndex];
434
+ for (const streams of [primitive.attributes, ...(primitive.targets ?? [])]) {
435
+ for (const index of Object.values(streams)) vertexAccessors.add(index);
436
+ }
437
+ }
438
+ function retainAccessor(index) {
439
+ if (accessorMap.has(index)) return accessorMap.get(index);
440
+ const sourceAccessor = indexOf(accessors, index, 'Retained accessor');
441
+ const accessor = structuredClone(sourceAccessor);
442
+ const layout = accessorLayout(accessor);
443
+ if (accessor.bufferView !== undefined) {
444
+ let data = copyElements(accessor.bufferView, accessor.byteOffset ?? 0,
445
+ accessor.count, layout.elementBytes, layout.componentBytes, true);
446
+ // Quantized VEC2/VEC3 vertex streams still require each vertex to begin on four bytes.
447
+ const stride = vertexAccessors.has(index) ? align4(layout.elementBytes) : layout.elementBytes;
448
+ if (stride !== layout.elementBytes) {
449
+ const aligned = new Uint8Array(accessor.count * stride);
450
+ for (let vertex = 0; vertex < accessor.count; vertex++) aligned.set(data.subarray(vertex * layout.elementBytes,
451
+ (vertex + 1) * layout.elementBytes), vertex * stride);
452
+ data = aligned;
453
+ }
454
+ accessor.bufferView = appendView(accessor.bufferView, data, views[accessor.bufferView].target);
455
+ if (stride !== layout.elementBytes) outputViews[accessor.bufferView].byteStride = stride;
456
+ accessor.byteOffset = 0;
457
+ }
458
+ if (accessor.sparse) {
459
+ const sparse = accessor.sparse;
460
+ const bytes = COMPONENTS.get(sparse.indices.componentType);
461
+ sparse.indices.bufferView = appendView(sparse.indices.bufferView, copyElements(sparse.indices.bufferView,
462
+ sparse.indices.byteOffset ?? 0, sparse.count, bytes, bytes, false));
463
+ sparse.indices.byteOffset = 0;
464
+ sparse.values.bufferView = appendView(sparse.values.bufferView, copyElements(sparse.values.bufferView,
465
+ sparse.values.byteOffset ?? 0, sparse.count, layout.elementBytes, layout.componentBytes, false));
466
+ sparse.values.byteOffset = 0;
467
+ }
468
+ const mapped = outputAccessors.push(accessor) - 1;
469
+ accessorMap.set(index, mapped);
470
+ return mapped;
471
+ }
472
+
473
+ const materials = table(json, 'materials');
474
+ const textures = table(json, 'textures');
475
+ const images = table(json, 'images');
476
+ const samplers = table(json, 'samplers');
477
+ const materialMap = new Map();
478
+ const textureMap = new Map();
479
+ const imageMap = new Map();
480
+ const samplerMap = new Map();
481
+ function retainImage(index) {
482
+ if (imageMap.has(index)) return imageMap.get(index);
483
+ const image = structuredClone(indexOf(images, index, 'Texture image'));
484
+ const view = views[image.bufferView];
485
+ image.bufferView = appendView(image.bufferView, binary.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength));
486
+ output.images ??= [];
487
+ const mapped = output.images.push(image) - 1;
488
+ imageMap.set(index, mapped);
489
+ return mapped;
490
+ }
491
+ function retainTexture(index) {
492
+ if (textureMap.has(index)) return textureMap.get(index);
493
+ const texture = structuredClone(indexOf(textures, index, 'Material texture'));
494
+ if (texture.source !== undefined) texture.source = retainImage(texture.source);
495
+ const webp = texture.extensions?.EXT_texture_webp;
496
+ if (webp) webp.source = retainImage(webp.source);
497
+ if (texture.source === undefined && !webp) fail('a retained texture has no image source.');
498
+ if (texture.sampler !== undefined) {
499
+ if (!samplerMap.has(texture.sampler)) {
500
+ output.samplers ??= [];
501
+ samplerMap.set(texture.sampler, output.samplers.push(structuredClone(indexOf(samplers, texture.sampler, 'Texture sampler'))) - 1);
502
+ }
503
+ texture.sampler = samplerMap.get(texture.sampler);
504
+ }
505
+ output.textures ??= [];
506
+ const mapped = output.textures.push(texture) - 1;
507
+ textureMap.set(index, mapped);
508
+ return mapped;
509
+ }
510
+ function retainMaterial(index) {
511
+ if (materialMap.has(index)) return materialMap.get(index);
512
+ const material = structuredClone(indexOf(materials, index, 'Primitive material'));
513
+ function remapTextures(value) {
514
+ for (const [key, child] of Object.entries(value)) {
515
+ if (key === 'extras') continue;
516
+ if (key.endsWith('Texture') && object(child)) {
517
+ child.index = retainTexture(child.index);
518
+ } else if (object(child)) remapTextures(child);
519
+ }
520
+ }
521
+ remapTextures(material);
522
+ output.materials ??= [];
523
+ const mapped = output.materials.push(material) - 1;
524
+ materialMap.set(index, mapped);
525
+ return mapped;
526
+ }
527
+
528
+ output.meshes = [];
529
+ output.nodes = [];
530
+ for (const [sourceIndex, mapped] of nodeMap) {
531
+ abort(signal);
532
+ const node = structuredClone(nodes[sourceIndex]);
533
+ // Caller-owned classification is authoritative; donor flags cannot mark shared rig bones.
534
+ if (object(node.extras)) delete node.extras.loomlargeGarmentBone;
535
+ if (owned.has(sourceIndex)) node.extras = { ...(object(node.extras) ? node.extras : {}), loomlargeGarmentBone: true };
536
+ if (node.children) node.children = node.children.filter((index) => nodeMap.has(index)).map((index) => nodeMap.get(index));
537
+ delete node.camera;
538
+ delete node.mesh;
539
+ delete node.skin;
540
+ delete node.weights;
541
+ if (selected.has(sourceIndex)) {
542
+ const sourceNode = nodes[sourceIndex];
543
+ const mesh = structuredClone(meshes[sourceNode.mesh]);
544
+ mesh.primitives = mesh.primitives.filter((_, index) => selected.get(sourceIndex).has(index));
545
+ for (const primitive of mesh.primitives) {
546
+ if (!object(primitive.attributes) || primitive.attributes.POSITION === undefined) fail('selected primitives require POSITION attributes.');
547
+ const count = indexOf(accessors, primitive.attributes.POSITION, 'Position accessor').count;
548
+ for (const streams of [primitive.attributes, ...(primitive.targets ?? [])]) {
549
+ if (!object(streams)) fail('primitive vertex streams must be objects.');
550
+ for (const [semantic, index] of Object.entries(streams)) {
551
+ if (indexOf(accessors, index, `${semantic} accessor`).count !== count) fail('selected primitive vertex streams have different counts.');
552
+ streams[semantic] = retainAccessor(index);
553
+ }
554
+ }
555
+ if (primitive.indices !== undefined) {
556
+ const indices = indexOf(accessors, primitive.indices, 'Primitive indices');
557
+ if (indices.type !== 'SCALAR' || indices.normalized || ![5121, 5123, 5125].includes(indices.componentType)) fail('primitive indices must be unsigned integer scalars.');
558
+ primitive.indices = retainAccessor(primitive.indices);
559
+ }
560
+ if (primitive.material !== undefined) primitive.material = retainMaterial(primitive.material);
561
+ }
562
+ node.mesh = output.meshes.push(mesh) - 1;
563
+ if (sourceNode.skin !== undefined) node.skin = skinMap.get(sourceNode.skin);
564
+ if (sourceNode.weights !== undefined) node.weights = structuredClone(sourceNode.weights);
565
+ }
566
+ output.nodes[mapped] = node;
567
+ if (mapped % 16 === 15) await yieldToCaller(signal);
568
+ }
569
+ if (skinMap.size) output.skins = [...skinMap.keys()].map((index) => {
570
+ const skin = structuredClone(skins[index]);
571
+ skin.joints = skin.joints.map((joint) => nodeMap.get(joint));
572
+ if (skin.skeleton !== undefined) skin.skeleton = nodeMap.get(skin.skeleton);
573
+ if (skin.inverseBindMatrices !== undefined) skin.inverseBindMatrices = retainAccessor(skin.inverseBindMatrices);
574
+ return skin;
575
+ });
576
+ const scenes = table(json, 'scenes');
577
+ output.scenes = scenes.map((scene) => {
578
+ const result = structuredClone(scene);
579
+ if (result.nodes !== undefined && !Array.isArray(result.nodes)) fail('scene nodes must be an array.');
580
+ result.nodes = (result.nodes ?? []).filter((index) => nodeMap.has(index)).map((index) => nodeMap.get(index));
581
+ return result;
582
+ });
583
+ if (!output.scenes.some((scene) => scene.nodes.length)) output.scenes.push({ nodes: [...nodeMap].filter(([index]) => !parents.has(index)).map(([, index]) => index) });
584
+ if (json.scene !== undefined) { indexOf(scenes, json.scene, 'Default scene'); output.scene = json.scene; }
585
+ else output.scene = output.scenes.findIndex((scene) => scene.nodes.length);
586
+ if (output.scenes[output.scene]?.nodes.length === 0) output.scene = output.scenes.findIndex((scene) => scene.nodes.length);
587
+ output.accessors = outputAccessors;
588
+ output.bufferViews = outputViews;
589
+ if (binaryLength) output.buffers = [{ ...structuredClone(buffers[0]), byteLength: binaryLength }];
590
+ if (json.extensionsUsed) output.extensionsUsed = [...json.extensionsUsed];
591
+ if (json.extensionsRequired) output.extensionsRequired = [...json.extensionsRequired];
592
+ const outputBinary = new Uint8Array(binaryLength);
593
+ for (const chunk of chunks) outputBinary.set(chunk.data, chunk.start);
594
+ abort(signal);
595
+ const bytes = serializeGlb(output, outputBinary);
596
+ abort(signal);
597
+ const warnings = json.animations?.length ? ['Garment extraction omits animation clips; source node transforms, skin bind matrices, and morph targets are retained.'] : [];
598
+ return { bytes, warnings };
599
+ }
@@ -0,0 +1,31 @@
1
+ /** Own the caller's storage before asynchronous file processing begins. */
2
+ export function readGlbSource(source, label = 'Actor Model') {
3
+ if (!(source instanceof Uint8Array)) throw new TypeError(`${label} source must be a Uint8Array.`);
4
+ const bytes = new Uint8Array(source);
5
+ if (bytes.byteLength < 20) throw new Error(`${label} must be a glTF 2.0 binary (.glb).`);
6
+ const view = new DataView(bytes.buffer);
7
+ if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2
8
+ || view.getUint32(8, true) !== bytes.byteLength) {
9
+ throw new Error(`${label} has an invalid GLB header or length.`);
10
+ }
11
+ let json;
12
+ let binary = new Uint8Array();
13
+ let chunkCount = 0;
14
+ for (let offset = 12; offset < bytes.byteLength;) {
15
+ if (offset + 8 > bytes.byteLength) throw new Error(`${label} has an incomplete GLB chunk.`);
16
+ const length = view.getUint32(offset, true);
17
+ const type = view.getUint32(offset + 4, true);
18
+ if (length % 4 || offset + 8 + length > bytes.byteLength) throw new Error(`${label} has an invalid GLB chunk length.`);
19
+ if (chunkCount === 0 && type === 0x4e4f534a) {
20
+ json = JSON.parse(new TextDecoder().decode(bytes.subarray(offset + 8, offset + 8 + length)));
21
+ } else if (chunkCount !== 1 || type !== 0x004e4942) {
22
+ throw new Error(`${label} must contain only the GLB JSON and optional binary chunks.`);
23
+ } else {
24
+ binary = bytes.subarray(offset + 8, offset + 8 + length);
25
+ }
26
+ offset += 8 + length;
27
+ chunkCount++;
28
+ }
29
+ if (!json || json.asset?.version !== '2.0') throw new Error(`${label} must declare glTF version 2.0.`);
30
+ return { bytes, json, binary };
31
+ }
@@ -55,3 +55,24 @@ export function inspectActorModel(source: Uint8Array, options?: { signal?: Abort
55
55
 
56
56
  /** Prepare a new GLB. The caller retains ownership of source and persists the returned bytes separately. */
57
57
  export function prepareActorModel(source: Uint8Array, options: PrepareActorModelOptions): Promise<PreparedActorModel>;
58
+
59
+ export interface ExtractGarmentModelOptions {
60
+ /** Exact source JSON node indices and primitive indices within each node's mesh. */
61
+ selections: Array<{ nodeIndex: number; primitiveIndices: number[] }>;
62
+ /** Source node indices for garment-owned skin joints, with a shared parent joint. */
63
+ ownedBoneNodeIndices?: number[];
64
+ signal?: AbortSignal;
65
+ }
66
+
67
+ export interface ExtractedGarmentModel {
68
+ bytes: Uint8Array;
69
+ /** Animation clips are deliberately omitted; warnings identify that exclusion. */
70
+ warnings: string[];
71
+ }
72
+
73
+ /**
74
+ * Extract selected source-file primitives as a self-contained GLB, preserving accessor order,
75
+ * skin bind matrices, morph streams, original images, material texture settings, and extras.
76
+ * Does not optimize, resize, capture a live pose, persist files, or initialize the runtime.
77
+ */
78
+ export function extractGarmentModel(source: Uint8Array, options: ExtractGarmentModelOptions): Promise<ExtractedGarmentModel>;