@forgeax/engine-geometry 0.1.19 → 0.1.21
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 +12 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/mesh-builder.integration.test.d.ts +2 -0
- package/dist/__tests__/mesh-builder.integration.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-builder.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-builder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/teapot.unit.test.d.ts +2 -0
- package/dist/__tests__/teapot.unit.test.d.ts.map +1 -0
- package/dist/assets/mesh-binary.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +1883 -2
- package/dist/index.mjs.map +1 -1
- package/dist/mesh-builder.d.ts +28 -0
- package/dist/mesh-builder.d.ts.map +1 -0
- package/dist/teapot.d.ts +14 -0
- package/dist/teapot.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/mesh-builder.integration.test.ts +58 -0
- package/src/__tests__/mesh-builder.unit.test.ts +96 -0
- package/src/__tests__/teapot.unit.test.ts +48 -0
- package/src/assets/mesh-binary.ts +12 -1
- package/src/index.ts +11 -1
- package/src/mesh-builder.ts +457 -0
- package/src/teapot.ts +263 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { box3 } from '@forgeax/engine-math';
|
|
2
|
+
import {
|
|
3
|
+
ASSET_ERROR_HINTS,
|
|
4
|
+
AssetError,
|
|
5
|
+
err,
|
|
6
|
+
type MeshAsset,
|
|
7
|
+
type MeshMaterialSlot,
|
|
8
|
+
ok,
|
|
9
|
+
type PrimitiveTopology,
|
|
10
|
+
type Result,
|
|
11
|
+
type Submesh,
|
|
12
|
+
type VertexAttributeMap,
|
|
13
|
+
} from '@forgeax/engine-types';
|
|
14
|
+
import { packInterleavedVertexAttributes } from './vertex-attribute-layout';
|
|
15
|
+
|
|
16
|
+
const ATTRIBUTE_KEYS = [
|
|
17
|
+
'position',
|
|
18
|
+
'normal',
|
|
19
|
+
'uv',
|
|
20
|
+
'tangent',
|
|
21
|
+
'skinIndex',
|
|
22
|
+
'skinWeight',
|
|
23
|
+
'uv1',
|
|
24
|
+
'uv2',
|
|
25
|
+
'uv3',
|
|
26
|
+
'uv4',
|
|
27
|
+
'uv5',
|
|
28
|
+
'uv6',
|
|
29
|
+
'uv7',
|
|
30
|
+
'color',
|
|
31
|
+
] as const satisfies readonly (keyof VertexAttributeMap)[];
|
|
32
|
+
|
|
33
|
+
type AttributeKey = (typeof ATTRIBUTE_KEYS)[number];
|
|
34
|
+
type AttributeSource = NonNullable<VertexAttributeMap[AttributeKey]>;
|
|
35
|
+
type AttributeView = Float32Array | Uint16Array;
|
|
36
|
+
|
|
37
|
+
const ATTRIBUTE_COMPONENTS: Readonly<Record<AttributeKey, number>> = {
|
|
38
|
+
position: 3,
|
|
39
|
+
normal: 3,
|
|
40
|
+
uv: 2,
|
|
41
|
+
tangent: 4,
|
|
42
|
+
skinIndex: 4,
|
|
43
|
+
skinWeight: 4,
|
|
44
|
+
uv1: 2,
|
|
45
|
+
uv2: 2,
|
|
46
|
+
uv3: 2,
|
|
47
|
+
uv4: 2,
|
|
48
|
+
uv5: 2,
|
|
49
|
+
uv6: 2,
|
|
50
|
+
uv7: 2,
|
|
51
|
+
color: 4,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const TOPOLOGIES: readonly PrimitiveTopology[] = [
|
|
55
|
+
'point-list',
|
|
56
|
+
'line-list',
|
|
57
|
+
'line-strip',
|
|
58
|
+
'triangle-list',
|
|
59
|
+
'triangle-strip',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Input shape for one independently drawn submesh. */
|
|
63
|
+
export type MeshBuilderSubmesh = Partial<Submesh> & {
|
|
64
|
+
readonly topology?: PrimitiveTopology;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** All authoring facts accepted by {@link createMeshBuilder}. */
|
|
68
|
+
export interface MeshBuilderOptions {
|
|
69
|
+
readonly attributes?: VertexAttributeMap;
|
|
70
|
+
readonly indices?: ArrayLike<number>;
|
|
71
|
+
readonly submeshes?: readonly MeshBuilderSubmesh[];
|
|
72
|
+
readonly materialSlots?: readonly MeshMaterialSlot[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MeshBuilder {
|
|
76
|
+
/** Append one equally-cardinal attribute batch to the accumulated source. */
|
|
77
|
+
appendVertices(attributes: VertexAttributeMap): Result<void, AssetError>;
|
|
78
|
+
/** Append raw index values; width is derived during build. */
|
|
79
|
+
appendIndices(indices: ArrayLike<number>): Result<void, AssetError>;
|
|
80
|
+
/** Add a draw range; omitted ranges are completed from the final source. */
|
|
81
|
+
addSubmesh(submesh: MeshBuilderSubmesh): Result<void, AssetError>;
|
|
82
|
+
/** Derive one immutable MeshAsset from the accumulated source. */
|
|
83
|
+
build(): Result<MeshAsset, AssetError>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function failure(field: string, value: unknown, reason: string): Result<never, AssetError> {
|
|
87
|
+
return err(
|
|
88
|
+
new AssetError({
|
|
89
|
+
code: 'asset-invalid-value',
|
|
90
|
+
expected: `valid MeshBuilder ${field}: ${reason}`,
|
|
91
|
+
hint: ASSET_ERROR_HINTS['asset-invalid-value'],
|
|
92
|
+
detail: { field, value, reason },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sourceView(key: AttributeKey, value: AttributeSource): AttributeView | undefined {
|
|
98
|
+
if (key === 'skinIndex') {
|
|
99
|
+
if (value instanceof Uint16Array) return value;
|
|
100
|
+
if (value instanceof ArrayBuffer && value.byteLength % Uint16Array.BYTES_PER_ELEMENT === 0) {
|
|
101
|
+
return new Uint16Array(value);
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (value instanceof Float32Array) return value;
|
|
106
|
+
if (value instanceof ArrayBuffer && value.byteLength % Float32Array.BYTES_PER_ELEMENT === 0) {
|
|
107
|
+
return new Float32Array(value);
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cloneAttribute(key: AttributeKey, value: AttributeSource): AttributeView | undefined {
|
|
113
|
+
const view = sourceView(key, value);
|
|
114
|
+
if (view === undefined) return undefined;
|
|
115
|
+
return view.slice();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function attributeKeys(attributes: VertexAttributeMap): AttributeKey[] {
|
|
119
|
+
return ATTRIBUTE_KEYS.filter((key) => attributes[key] !== undefined);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendArray(target: AttributeView, source: AttributeView): AttributeView {
|
|
123
|
+
if (target instanceof Uint16Array && source instanceof Uint16Array) {
|
|
124
|
+
const output = new Uint16Array(target.length + source.length);
|
|
125
|
+
output.set(target, 0);
|
|
126
|
+
output.set(source, target.length);
|
|
127
|
+
return output;
|
|
128
|
+
}
|
|
129
|
+
if (target instanceof Float32Array && source instanceof Float32Array) {
|
|
130
|
+
const output = new Float32Array(target.length + source.length);
|
|
131
|
+
output.set(target, 0);
|
|
132
|
+
output.set(source, target.length);
|
|
133
|
+
return output;
|
|
134
|
+
}
|
|
135
|
+
// The caller groups batches by canonical key, so this branch only protects
|
|
136
|
+
// against an internally inconsistent future storage change.
|
|
137
|
+
return target;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function copySlots(slots: readonly MeshMaterialSlot[] | undefined): MeshMaterialSlot[] {
|
|
141
|
+
return (slots ?? [{ slotName: 'Default' }]).map((slot) => ({
|
|
142
|
+
slotName: slot.slotName,
|
|
143
|
+
...(slot.sourceKey === undefined ? {} : { sourceKey: slot.sourceKey }),
|
|
144
|
+
...(slot.defaultMaterial === undefined ? {} : { defaultMaterial: slot.defaultMaterial }),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateAttributeBatch(
|
|
149
|
+
attributes: VertexAttributeMap,
|
|
150
|
+
): Result<{ readonly keys: readonly AttributeKey[]; readonly vertexCount: number }, AssetError> {
|
|
151
|
+
const keys = attributeKeys(attributes);
|
|
152
|
+
if (keys.length === 0)
|
|
153
|
+
return failure('attributes', [], 'at least one canonical attribute is required');
|
|
154
|
+
const position = attributes.position;
|
|
155
|
+
if (position === undefined)
|
|
156
|
+
return failure('attributes.position', undefined, 'position is required');
|
|
157
|
+
const positionView = sourceView('position', position);
|
|
158
|
+
if (positionView === undefined) {
|
|
159
|
+
return failure(
|
|
160
|
+
'attributes.position',
|
|
161
|
+
typeof position,
|
|
162
|
+
'position must use Float32Array or ArrayBuffer',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (positionView.length === 0 || positionView.length % 3 !== 0) {
|
|
166
|
+
return failure(
|
|
167
|
+
'attributes.position',
|
|
168
|
+
positionView.length,
|
|
169
|
+
'position cardinality must be a non-zero multiple of 3',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const vertexCount = positionView.length / 3;
|
|
173
|
+
for (const key of keys) {
|
|
174
|
+
const value = attributes[key];
|
|
175
|
+
if (value === undefined) continue;
|
|
176
|
+
const view = sourceView(key, value);
|
|
177
|
+
if (view === undefined) {
|
|
178
|
+
return failure(
|
|
179
|
+
key,
|
|
180
|
+
typeof value,
|
|
181
|
+
key === 'skinIndex' ? 'storage must be Uint16Array' : 'storage must be Float32Array',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const expectedLength = vertexCount * ATTRIBUTE_COMPONENTS[key];
|
|
185
|
+
if (view.length !== expectedLength) {
|
|
186
|
+
return failure(key, view.length, `cardinality must be ${expectedLength}`);
|
|
187
|
+
}
|
|
188
|
+
for (let elementIndex = 0; elementIndex < view.length; elementIndex += 1) {
|
|
189
|
+
const valueAt = view[elementIndex];
|
|
190
|
+
if (valueAt === undefined || !Number.isFinite(valueAt)) {
|
|
191
|
+
return failure(key, valueAt, `${key}[${elementIndex}] must be finite`);
|
|
192
|
+
}
|
|
193
|
+
if (key === 'skinIndex' && (!Number.isInteger(valueAt) || valueAt < 0 || valueAt > 0xffff)) {
|
|
194
|
+
return failure(key, valueAt, `${key}[${elementIndex}] must be an integer in [0, 65535]`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return ok({ keys, vertexCount });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateSlots(slots: readonly MeshMaterialSlot[]): Result<void, AssetError> {
|
|
202
|
+
if (slots.length === 0)
|
|
203
|
+
return failure('materialSlots', slots.length, 'at least one material slot is required');
|
|
204
|
+
const names = new Set<string>();
|
|
205
|
+
for (let index = 0; index < slots.length; index += 1) {
|
|
206
|
+
const slot = slots[index];
|
|
207
|
+
const name = slot?.slotName.trim() ?? '';
|
|
208
|
+
if (name.length === 0 || names.has(name)) {
|
|
209
|
+
return failure(`materialSlots[${index}].slotName`, name, 'must be non-empty and unique');
|
|
210
|
+
}
|
|
211
|
+
names.add(name);
|
|
212
|
+
}
|
|
213
|
+
return ok(undefined);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function completedSubmeshes(
|
|
217
|
+
requested: readonly MeshBuilderSubmesh[],
|
|
218
|
+
vertexCount: number,
|
|
219
|
+
indexCount: number,
|
|
220
|
+
materialSlotCount: number,
|
|
221
|
+
): Result<readonly Submesh[], AssetError> {
|
|
222
|
+
const source =
|
|
223
|
+
requested.length === 0
|
|
224
|
+
? [
|
|
225
|
+
{
|
|
226
|
+
indexOffset: 0,
|
|
227
|
+
indexCount,
|
|
228
|
+
vertexCount,
|
|
229
|
+
topology: 'triangle-list' as const,
|
|
230
|
+
materialSlot: 0,
|
|
231
|
+
},
|
|
232
|
+
]
|
|
233
|
+
: requested;
|
|
234
|
+
const output: Submesh[] = [];
|
|
235
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
236
|
+
const candidate = source[index];
|
|
237
|
+
if (candidate === undefined)
|
|
238
|
+
return failure(`submeshes[${index}]`, undefined, 'entry is required');
|
|
239
|
+
const indexOffset = candidate.indexOffset ?? 0;
|
|
240
|
+
const submeshIndexCount = candidate.indexCount ?? (indexCount > 0 ? indexCount : 0);
|
|
241
|
+
const submeshVertexCount = candidate.vertexCount ?? vertexCount;
|
|
242
|
+
const topology = candidate.topology ?? 'triangle-list';
|
|
243
|
+
const materialSlot = candidate.materialSlot ?? index;
|
|
244
|
+
if (!TOPOLOGIES.includes(topology)) {
|
|
245
|
+
return failure(
|
|
246
|
+
`submeshes[${index}].topology`,
|
|
247
|
+
topology,
|
|
248
|
+
'must be a WebGPU primitive topology',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
!Number.isInteger(indexOffset) ||
|
|
253
|
+
indexOffset < 0 ||
|
|
254
|
+
!Number.isInteger(submeshIndexCount) ||
|
|
255
|
+
submeshIndexCount < 0 ||
|
|
256
|
+
!Number.isInteger(submeshVertexCount) ||
|
|
257
|
+
submeshVertexCount < 0 ||
|
|
258
|
+
submeshVertexCount > vertexCount ||
|
|
259
|
+
!Number.isInteger(materialSlot) ||
|
|
260
|
+
materialSlot < 0 ||
|
|
261
|
+
materialSlot >= materialSlotCount
|
|
262
|
+
) {
|
|
263
|
+
return failure(
|
|
264
|
+
`submeshes[${index}]`,
|
|
265
|
+
JSON.stringify(candidate),
|
|
266
|
+
'range and material slot are invalid',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (indexCount === 0 && (indexOffset !== 0 || submeshIndexCount !== 0)) {
|
|
270
|
+
return failure(
|
|
271
|
+
`submeshes[${index}]`,
|
|
272
|
+
JSON.stringify(candidate),
|
|
273
|
+
'non-indexed meshes use indexOffset=0 and indexCount=0',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (indexCount > 0 && indexOffset + submeshIndexCount > indexCount) {
|
|
277
|
+
return failure(
|
|
278
|
+
`submeshes[${index}]`,
|
|
279
|
+
JSON.stringify(candidate),
|
|
280
|
+
'index range exceeds the accumulated index buffer',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (indexCount === 0 && (topology === 'line-strip' || topology === 'triangle-strip')) {
|
|
284
|
+
return failure(
|
|
285
|
+
`submeshes[${index}].topology`,
|
|
286
|
+
topology,
|
|
287
|
+
'strip topology requires an index buffer',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
output.push({
|
|
291
|
+
indexOffset,
|
|
292
|
+
indexCount: submeshIndexCount,
|
|
293
|
+
vertexCount: submeshVertexCount,
|
|
294
|
+
topology,
|
|
295
|
+
materialSlot,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return ok(Object.freeze(output));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Create a geometry-owned builder. All derived facts are computed once in
|
|
303
|
+
* `build()` from the accumulated canonical attribute source.
|
|
304
|
+
*/
|
|
305
|
+
export function createMeshBuilder(options: MeshBuilderOptions = {}): MeshBuilder {
|
|
306
|
+
const attributes: Partial<Record<AttributeKey, AttributeView>> = {};
|
|
307
|
+
const indices: number[] = [];
|
|
308
|
+
const submeshes: MeshBuilderSubmesh[] = [];
|
|
309
|
+
const materialSlots = copySlots(options.materialSlots);
|
|
310
|
+
|
|
311
|
+
const appendVertices = (batch: VertexAttributeMap): Result<void, AssetError> => {
|
|
312
|
+
const checked = validateAttributeBatch(batch);
|
|
313
|
+
if (!checked.ok) return checked;
|
|
314
|
+
const incomingKeys = checked.value.keys;
|
|
315
|
+
const existingKeys = ATTRIBUTE_KEYS.filter((key) => attributes[key] !== undefined);
|
|
316
|
+
if (existingKeys.length > 0 && existingKeys.join('|') !== incomingKeys.join('|')) {
|
|
317
|
+
return failure(
|
|
318
|
+
'attributes',
|
|
319
|
+
incomingKeys.join(','),
|
|
320
|
+
'every appended batch must carry the same canonical keys',
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
for (const key of incomingKeys) {
|
|
324
|
+
const value = batch[key];
|
|
325
|
+
if (value === undefined) continue;
|
|
326
|
+
const cloned = cloneAttribute(key, value);
|
|
327
|
+
if (cloned === undefined) return failure(key, typeof value, 'storage could not be cloned');
|
|
328
|
+
const previous = attributes[key];
|
|
329
|
+
attributes[key] = previous === undefined ? cloned : appendArray(previous, cloned);
|
|
330
|
+
}
|
|
331
|
+
return ok(undefined);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const appendIndices = (batch: ArrayLike<number>): Result<void, AssetError> => {
|
|
335
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
336
|
+
const value = Number(batch[index]);
|
|
337
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
338
|
+
return failure('indices', value, `indices[${index}] must be an integer in [0, 2^32-1]`);
|
|
339
|
+
}
|
|
340
|
+
indices.push(value);
|
|
341
|
+
}
|
|
342
|
+
return ok(undefined);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const addSubmesh = (submesh: MeshBuilderSubmesh): Result<void, AssetError> => {
|
|
346
|
+
if (submesh === null || typeof submesh !== 'object') {
|
|
347
|
+
return failure('submeshes', submesh, 'entry must be an object');
|
|
348
|
+
}
|
|
349
|
+
submeshes.push({ ...submesh });
|
|
350
|
+
return ok(undefined);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const build = (): Result<MeshAsset, AssetError> => {
|
|
354
|
+
const source = attributes as VertexAttributeMap;
|
|
355
|
+
const checked = validateAttributeBatch(source);
|
|
356
|
+
if (!checked.ok) return checked;
|
|
357
|
+
const slotCheck = validateSlots(materialSlots);
|
|
358
|
+
if (!slotCheck.ok) return slotCheck;
|
|
359
|
+
const vertexCount = checked.value.vertexCount;
|
|
360
|
+
const position = source.position;
|
|
361
|
+
if (position === undefined)
|
|
362
|
+
return failure('attributes.position', undefined, 'position is required');
|
|
363
|
+
const positionView = sourceView('position', position);
|
|
364
|
+
if (!(positionView instanceof Float32Array)) {
|
|
365
|
+
return failure('attributes.position', typeof position, 'position storage is invalid');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let maxIndex = 0;
|
|
369
|
+
for (const value of indices) maxIndex = Math.max(maxIndex, value);
|
|
370
|
+
const indexArray =
|
|
371
|
+
indices.length === 0
|
|
372
|
+
? undefined
|
|
373
|
+
: maxIndex <= 0xffff
|
|
374
|
+
? new Uint16Array(indices)
|
|
375
|
+
: new Uint32Array(indices);
|
|
376
|
+
if (indexArray !== undefined) {
|
|
377
|
+
for (let index = 0; index < indexArray.length; index += 1) {
|
|
378
|
+
const value = indexArray[index];
|
|
379
|
+
if (value === undefined || value >= vertexCount) {
|
|
380
|
+
return failure(
|
|
381
|
+
'indices',
|
|
382
|
+
value ?? -1,
|
|
383
|
+
`indices[${index}] must be less than vertexCount (${vertexCount})`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const packed = packInterleavedVertexAttributes(source, vertexCount);
|
|
390
|
+
if (!packed.ok) return packed;
|
|
391
|
+
const ranges = completedSubmeshes(
|
|
392
|
+
submeshes,
|
|
393
|
+
vertexCount,
|
|
394
|
+
indexArray?.length ?? 0,
|
|
395
|
+
materialSlots.length,
|
|
396
|
+
);
|
|
397
|
+
if (!ranges.ok) return ranges;
|
|
398
|
+
const aabb = box3.fromPositions(box3.create(), positionView);
|
|
399
|
+
const copiedAttributes: VertexAttributeMap = {};
|
|
400
|
+
for (const key of checked.value.keys) {
|
|
401
|
+
const value = source[key];
|
|
402
|
+
if (value === undefined) continue;
|
|
403
|
+
const cloned = cloneAttribute(key, value);
|
|
404
|
+
if (cloned === undefined) return failure(key, typeof value, 'storage could not be cloned');
|
|
405
|
+
if (key === 'skinIndex') {
|
|
406
|
+
if (!(cloned instanceof Uint16Array)) {
|
|
407
|
+
return failure(key, typeof value, 'skinIndex storage must be Uint16Array');
|
|
408
|
+
}
|
|
409
|
+
copiedAttributes.skinIndex = cloned;
|
|
410
|
+
} else {
|
|
411
|
+
if (!(cloned instanceof Float32Array)) {
|
|
412
|
+
return failure(key, typeof value, `${key} storage must be Float32Array`);
|
|
413
|
+
}
|
|
414
|
+
copiedAttributes[key] = cloned;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const mesh: MeshAsset = {
|
|
418
|
+
kind: 'mesh',
|
|
419
|
+
vertices: packed.value.vertices.slice(),
|
|
420
|
+
...(indexArray === undefined ? {} : { indices: indexArray.slice() }),
|
|
421
|
+
attributes: copiedAttributes,
|
|
422
|
+
aabb: Float32Array.from(aabb),
|
|
423
|
+
submeshes: ranges.value,
|
|
424
|
+
materialSlots: Object.freeze(materialSlots.map((slot) => ({ ...slot }))),
|
|
425
|
+
};
|
|
426
|
+
return ok(Object.freeze(mesh));
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
if (options.attributes !== undefined) {
|
|
430
|
+
const result = appendVertices(options.attributes);
|
|
431
|
+
if (!result.ok) {
|
|
432
|
+
// Keep construction side-effect free; the structured error is surfaced
|
|
433
|
+
// by build() because the builder itself intentionally has no throw path.
|
|
434
|
+
const constructionError = result.error;
|
|
435
|
+
return {
|
|
436
|
+
appendVertices,
|
|
437
|
+
appendIndices,
|
|
438
|
+
addSubmesh,
|
|
439
|
+
build: () => err(constructionError),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (options.indices !== undefined) {
|
|
444
|
+
const result = appendIndices(options.indices);
|
|
445
|
+
if (!result.ok) {
|
|
446
|
+
const constructionError = result.error;
|
|
447
|
+
return {
|
|
448
|
+
appendVertices,
|
|
449
|
+
appendIndices,
|
|
450
|
+
addSubmesh,
|
|
451
|
+
build: () => err(constructionError),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const submesh of options.submeshes ?? []) submeshes.push({ ...submesh });
|
|
456
|
+
return { appendVertices, appendIndices, addSubmesh, build };
|
|
457
|
+
}
|