@flighthq/mesh 0.3.0-next.906.07cea63 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meshGeometry.d.ts +1 -0
- package/dist/meshGeometry.d.ts.map +1 -1
- package/dist/meshGeometry.js +14 -1
- package/dist/meshGeometry.js.map +1 -1
- package/dist/meshGeometryBuilders.d.ts.map +1 -1
- package/dist/meshGeometryBuilders.js +91 -6
- package/dist/meshGeometryBuilders.js.map +1 -1
- package/dist/meshGeometryCompute.d.ts.map +1 -1
- package/dist/meshGeometryCompute.js +328 -97
- package/dist/meshGeometryCompute.js.map +1 -1
- package/dist/meshGeometryLayout.d.ts.map +1 -1
- package/dist/meshGeometryLayout.js +8 -1
- package/dist/meshGeometryLayout.js.map +1 -1
- package/package.json +6 -4
- package/src/meshGeometry.test.ts +25 -0
- package/src/meshGeometryCompute.test.ts +191 -3
- package/src/meshGeometryLayout.test.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flighthq/mesh",
|
|
3
|
-
"version": "0.3.0
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"author": "Joshua Granick and other contributors",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"repository": {
|
|
5
7
|
"type": "git",
|
|
6
8
|
"url": "https://github.com/flighthq/flight.git",
|
|
@@ -36,9 +38,9 @@
|
|
|
36
38
|
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@flighthq/entity": "0.3.0
|
|
40
|
-
"@flighthq/geometry": "0.3.0
|
|
41
|
-
"@flighthq/types": "0.3.0
|
|
41
|
+
"@flighthq/entity": "0.3.0",
|
|
42
|
+
"@flighthq/geometry": "0.3.0",
|
|
43
|
+
"@flighthq/types": "0.3.0"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
44
46
|
"typescript": "^5.3.0"
|
package/src/meshGeometry.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getMeshGeometrySkinBindPose,
|
|
17
17
|
getMeshGeometryVertexCount,
|
|
18
18
|
hasMeshGeometrySkin,
|
|
19
|
+
invalidateMeshGeometry,
|
|
19
20
|
setMeshGeometryMorphBindPose,
|
|
20
21
|
setMeshGeometrySkinBindPose,
|
|
21
22
|
} from './meshGeometry';
|
|
@@ -67,6 +68,17 @@ describe('cloneMeshGeometry', () => {
|
|
|
67
68
|
expect(clone.bounds!.min.x).toBe(-1);
|
|
68
69
|
expect(clone.bounds!.max.z).toBe(1);
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
it('copies tangent-split smoothing identity without sharing its runtime array', () => {
|
|
73
|
+
const source = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(2) });
|
|
74
|
+
(source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources = new Uint32Array([0, 0]);
|
|
75
|
+
|
|
76
|
+
const clone = cloneMeshGeometry(source);
|
|
77
|
+
|
|
78
|
+
const clonedSources = (clone[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources;
|
|
79
|
+
expect(Array.from(clonedSources!)).toEqual([0, 0]);
|
|
80
|
+
expect(clonedSources).not.toBe((source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources);
|
|
81
|
+
});
|
|
70
82
|
});
|
|
71
83
|
|
|
72
84
|
describe('createMeshGeometry', () => {
|
|
@@ -118,6 +130,7 @@ describe('createMeshGeometry', () => {
|
|
|
118
130
|
const runtime = geometry[EntityRuntimeKey] as MeshGeometryRuntime;
|
|
119
131
|
expect(runtime.webglData).toBeNull();
|
|
120
132
|
expect(runtime.webgpuData).toBeNull();
|
|
133
|
+
expect(runtime.tangentSmoothingSources).toBeNull();
|
|
121
134
|
});
|
|
122
135
|
});
|
|
123
136
|
|
|
@@ -196,6 +209,18 @@ describe('hasMeshGeometrySkin', () => {
|
|
|
196
209
|
});
|
|
197
210
|
});
|
|
198
211
|
|
|
212
|
+
describe('invalidateMeshGeometry', () => {
|
|
213
|
+
it('advances the shared upload revision as a wrapping u32', () => {
|
|
214
|
+
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
|
|
215
|
+
invalidateMeshGeometry(geometry);
|
|
216
|
+
expect(geometry.version).toBe(1);
|
|
217
|
+
|
|
218
|
+
geometry.version = 0xffffffff;
|
|
219
|
+
invalidateMeshGeometry(geometry);
|
|
220
|
+
expect(geometry.version).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
199
224
|
describe('setMeshGeometryMorphBindPose', () => {
|
|
200
225
|
it('stores and clears the morph base pose on the runtime slot', () => {
|
|
201
226
|
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(3) });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAabb, createBoundingSphere } from '@flighthq/geometry/contract';
|
|
2
2
|
import type { VertexAttributeLayout } from '@flighthq/types/contract';
|
|
3
3
|
|
|
4
|
-
import { createMeshGeometry } from './meshGeometry';
|
|
4
|
+
import { createMeshGeometry, invalidateMeshGeometry } from './meshGeometry';
|
|
5
5
|
import {
|
|
6
6
|
computeMeshGeometryBoundingSphere,
|
|
7
7
|
computeMeshGeometryBounds,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ensureMeshGeometryBounds,
|
|
12
12
|
refreshMeshGeometryBounds,
|
|
13
13
|
} from './meshGeometryCompute';
|
|
14
|
+
import { CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT } from './meshGeometryLayout';
|
|
14
15
|
|
|
15
16
|
const CANONICAL_LAYOUT: VertexAttributeLayout = {
|
|
16
17
|
attributes: [
|
|
@@ -190,6 +191,8 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
190
191
|
it('writes a unit tangent aligned with +X for the canonical UV mapping', () => {
|
|
191
192
|
const geometry = makeTriangle();
|
|
192
193
|
computeMeshGeometryNormals(geometry, geometry);
|
|
194
|
+
const originalVertices = geometry.vertices;
|
|
195
|
+
const originalIndices = geometry.indices;
|
|
193
196
|
computeMeshGeometryTangents(geometry, geometry);
|
|
194
197
|
// u increases along +X, so tangent.xyz ~ (1,0,0).
|
|
195
198
|
expect(geometry.vertices[6]).toBeCloseTo(1);
|
|
@@ -197,6 +200,9 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
197
200
|
expect(geometry.vertices[8]).toBeCloseTo(0);
|
|
198
201
|
// Right-handed mapping => positive handedness.
|
|
199
202
|
expect(geometry.vertices[9]).toBe(1);
|
|
203
|
+
// The common single-handed case writes in place without replacing either payload buffer.
|
|
204
|
+
expect(geometry.vertices).toBe(originalVertices);
|
|
205
|
+
expect(geometry.indices).toBe(originalIndices);
|
|
200
206
|
});
|
|
201
207
|
|
|
202
208
|
it('writes into a distinct out geometry', () => {
|
|
@@ -217,8 +223,190 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
217
223
|
computeMeshGeometryTangents(geometry, geometry);
|
|
218
224
|
expect(geometry.vertices[9]).toBe(-1);
|
|
219
225
|
});
|
|
226
|
+
|
|
227
|
+
it('splits shared vertices at a mirrored-UV handedness boundary', () => {
|
|
228
|
+
// Two CCW triangles share vertices 0/2. Their UV determinants have opposite signs, so a single
|
|
229
|
+
// tangent.w cannot describe both triangles: the shared records must be duplicated and remapped.
|
|
230
|
+
const vertices = new Float32Array(4 * 12);
|
|
231
|
+
setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
|
|
232
|
+
setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
|
|
233
|
+
setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
|
|
234
|
+
setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
|
|
235
|
+
const geometry = createMeshGeometry({
|
|
236
|
+
indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
|
|
237
|
+
layout: CANONICAL_LAYOUT,
|
|
238
|
+
vertices,
|
|
239
|
+
});
|
|
240
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
241
|
+
const previousVersion = geometry.version;
|
|
242
|
+
|
|
243
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
244
|
+
|
|
245
|
+
expect(geometry.vertices.length / 12).toBe(6);
|
|
246
|
+
expect(geometry.version).toBe(previousVersion + 1);
|
|
247
|
+
const indices = geometry.indices!;
|
|
248
|
+
const firstSign = geometry.vertices[indices[0] * 12 + 9];
|
|
249
|
+
const mirroredSign = geometry.vertices[indices[3] * 12 + 9];
|
|
250
|
+
expect(firstSign).toBe(1);
|
|
251
|
+
expect(mirroredSign).toBe(-1);
|
|
252
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
253
|
+
expect(geometry.vertices[indices[corner] * 12 + 9]).toBe(firstSign);
|
|
254
|
+
expect(geometry.vertices[indices[corner + 3] * 12 + 9]).toBe(mirroredSign);
|
|
255
|
+
}
|
|
256
|
+
// Both shared corners moved on the mirrored triangle; the positive triangle kept the originals.
|
|
257
|
+
expect(indices[0]).toBe(0);
|
|
258
|
+
expect(indices[2]).toBe(2);
|
|
259
|
+
expect(indices[3]).toBeGreaterThanOrEqual(4);
|
|
260
|
+
expect(indices[4]).toBeGreaterThanOrEqual(4);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('splits same-handed corners whose tangent directions cannot share a stable frame', () => {
|
|
264
|
+
// Both triangles are CCW and both UV determinants are positive, but their U axes point in
|
|
265
|
+
// opposite model-space directions at shared vertex 0. Handedness-only reconciliation leaves the
|
|
266
|
+
// shared tangent cancelled or dominated by one face; Flight must create a second complete record.
|
|
267
|
+
const vertices = new Float32Array(5 * 12);
|
|
268
|
+
setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
|
|
269
|
+
setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
|
|
270
|
+
setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
|
|
271
|
+
setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
|
|
272
|
+
setCanonicalVertex(vertices, 4, 0, -1, 0, 1);
|
|
273
|
+
const geometry = createMeshGeometry({
|
|
274
|
+
indices: new Uint16Array([0, 1, 2, 0, 3, 4]),
|
|
275
|
+
layout: CANONICAL_LAYOUT,
|
|
276
|
+
vertices,
|
|
277
|
+
});
|
|
278
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
279
|
+
|
|
280
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
281
|
+
|
|
282
|
+
expect(geometry.vertices.length / 12).toBe(6);
|
|
283
|
+
const indices = geometry.indices!;
|
|
284
|
+
expect(indices[0]).not.toBe(indices[3]);
|
|
285
|
+
expect(geometry.vertices[indices[0] * 12 + 6]).toBeGreaterThan(0.9);
|
|
286
|
+
expect(geometry.vertices[indices[3] * 12 + 6]).toBeLessThan(-0.9);
|
|
287
|
+
// This is a directional split within one handedness, not a mirror split.
|
|
288
|
+
expect(geometry.vertices[indices[0] * 12 + 9]).toBe(1);
|
|
289
|
+
expect(geometry.vertices[indices[3] * 12 + 9]).toBe(1);
|
|
290
|
+
|
|
291
|
+
// The reference MD5 demo historically repeated both authoring calls after import. Repeating them
|
|
292
|
+
// must neither grow topology again nor turn the Flight-created tangent split into a hard normal seam.
|
|
293
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
294
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
295
|
+
expect(geometry.vertices.length / 12).toBe(6);
|
|
296
|
+
expect(geometry.vertices[indices[0] * 12 + 3]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 3]);
|
|
297
|
+
expect(geometry.vertices[indices[0] * 12 + 4]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 4]);
|
|
298
|
+
expect(geometry.vertices[indices[0] * 12 + 5]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 5]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('keeps smooth normals shared across a folded mirrored tangent seam', () => {
|
|
302
|
+
const vertices = new Float32Array(4 * 12);
|
|
303
|
+
setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
|
|
304
|
+
setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
|
|
305
|
+
setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
|
|
306
|
+
setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
|
|
307
|
+
vertices[3 * 12 + 2] = 1;
|
|
308
|
+
const geometry = createMeshGeometry({
|
|
309
|
+
indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
|
|
310
|
+
layout: CANONICAL_LAYOUT,
|
|
311
|
+
vertices,
|
|
312
|
+
});
|
|
313
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
314
|
+
const originalNormal = Array.from(geometry.vertices.subarray(3, 6));
|
|
315
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
316
|
+
const indices = geometry.indices!;
|
|
317
|
+
expect(indices[0]).not.toBe(indices[3]);
|
|
318
|
+
|
|
319
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
320
|
+
|
|
321
|
+
for (const vertex of [indices[0], indices[3]]) {
|
|
322
|
+
expect(geometry.vertices[vertex * 12 + 3]).toBeCloseTo(originalNormal[0]);
|
|
323
|
+
expect(geometry.vertices[vertex * 12 + 4]).toBeCloseTo(originalNormal[1]);
|
|
324
|
+
expect(geometry.vertices[vertex * 12 + 5]).toBeCloseTo(originalNormal[2]);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('copies the complete skinned record when splitting a mirrored tangent seam', () => {
|
|
329
|
+
const stride = CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT.stride / 4;
|
|
330
|
+
const vertices = new Float32Array(4 * stride);
|
|
331
|
+
setSkinnedVertex(vertices, stride, 0, 0, 0, 0, 0, 10);
|
|
332
|
+
setSkinnedVertex(vertices, stride, 1, 1, 0, 1, 0, 20);
|
|
333
|
+
setSkinnedVertex(vertices, stride, 2, 0, 1, 0, 1, 30);
|
|
334
|
+
setSkinnedVertex(vertices, stride, 3, -1, 0, 1, 0, 40);
|
|
335
|
+
const geometry = createMeshGeometry({
|
|
336
|
+
indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
|
|
337
|
+
layout: CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT,
|
|
338
|
+
vertices,
|
|
339
|
+
});
|
|
340
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
341
|
+
|
|
342
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
343
|
+
|
|
344
|
+
const split = geometry.indices![3];
|
|
345
|
+
expect(split).toBeGreaterThanOrEqual(4);
|
|
346
|
+
// The split for source vertex 0 retains its joints0 and weights0 exactly. These channels are what
|
|
347
|
+
// make a topologically repaired MD5 vertex continue following the same skeleton influences.
|
|
348
|
+
expect(Array.from(geometry.vertices.subarray(split * stride + 12, split * stride + 16))).toEqual([10, 11, 12, 13]);
|
|
349
|
+
expect(geometry.vertices[split * stride + 16]).toBeCloseTo(0.1);
|
|
350
|
+
expect(geometry.vertices[split * stride + 17]).toBeCloseTo(0.2);
|
|
351
|
+
expect(geometry.vertices[split * stride + 18]).toBeCloseTo(0.3);
|
|
352
|
+
expect(geometry.vertices[split * stride + 19]).toBeCloseTo(0.4);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('uses a unit perpendicular fallback for degenerate UVs', () => {
|
|
356
|
+
const geometry = makeTriangle();
|
|
357
|
+
for (let vertex = 0; vertex < 3; vertex++) {
|
|
358
|
+
const base = vertex * 12;
|
|
359
|
+
geometry.vertices[base + 3] = 1;
|
|
360
|
+
geometry.vertices[base + 4] = 0;
|
|
361
|
+
geometry.vertices[base + 5] = 0;
|
|
362
|
+
geometry.vertices[base + 10] = 0;
|
|
363
|
+
geometry.vertices[base + 11] = 0;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
computeMeshGeometryTangents(geometry, geometry);
|
|
367
|
+
|
|
368
|
+
const tx = geometry.vertices[6];
|
|
369
|
+
const ty = geometry.vertices[7];
|
|
370
|
+
const tz = geometry.vertices[8];
|
|
371
|
+
expect(Math.hypot(tx, ty, tz)).toBeCloseTo(1);
|
|
372
|
+
expect(tx).toBeCloseTo(0);
|
|
373
|
+
expect(Math.abs(geometry.vertices[9])).toBe(1);
|
|
374
|
+
});
|
|
220
375
|
});
|
|
221
376
|
|
|
377
|
+
function setCanonicalVertex(vertices: Float32Array, vertex: number, x: number, y: number, u: number, v: number): void {
|
|
378
|
+
const base = vertex * 12;
|
|
379
|
+
vertices[base] = x;
|
|
380
|
+
vertices[base + 1] = y;
|
|
381
|
+
vertices[base + 10] = u;
|
|
382
|
+
vertices[base + 11] = v;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function setSkinnedVertex(
|
|
386
|
+
vertices: Float32Array,
|
|
387
|
+
stride: number,
|
|
388
|
+
vertex: number,
|
|
389
|
+
x: number,
|
|
390
|
+
y: number,
|
|
391
|
+
u: number,
|
|
392
|
+
v: number,
|
|
393
|
+
joint: number,
|
|
394
|
+
): void {
|
|
395
|
+
const base = vertex * stride;
|
|
396
|
+
vertices[base] = x;
|
|
397
|
+
vertices[base + 1] = y;
|
|
398
|
+
vertices[base + 10] = u;
|
|
399
|
+
vertices[base + 11] = v;
|
|
400
|
+
vertices[base + 12] = joint;
|
|
401
|
+
vertices[base + 13] = joint + 1;
|
|
402
|
+
vertices[base + 14] = joint + 2;
|
|
403
|
+
vertices[base + 15] = joint + 3;
|
|
404
|
+
vertices[base + 16] = 0.1;
|
|
405
|
+
vertices[base + 17] = 0.2;
|
|
406
|
+
vertices[base + 18] = 0.3;
|
|
407
|
+
vertices[base + 19] = 0.4;
|
|
408
|
+
}
|
|
409
|
+
|
|
222
410
|
describe('ensureMeshGeometryBounds', () => {
|
|
223
411
|
it('computes on first query, then reuses the cache until the version moves', () => {
|
|
224
412
|
const geometry = makeTriangle();
|
|
@@ -232,8 +420,8 @@ describe('ensureMeshGeometryBounds', () => {
|
|
|
232
420
|
geometry.vertices[12] = 4;
|
|
233
421
|
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(1);
|
|
234
422
|
|
|
235
|
-
//
|
|
236
|
-
geometry
|
|
423
|
+
// The public direct-write escape hatch marks the cache stale.
|
|
424
|
+
invalidateMeshGeometry(geometry);
|
|
237
425
|
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(4);
|
|
238
426
|
});
|
|
239
427
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { VertexAttributeLayout } from '@flighthq/types/contract';
|
|
1
|
+
import type { MeshGeometryRuntime, VertexAttributeLayout } from '@flighthq/types/contract';
|
|
2
|
+
import { EntityRuntimeKey } from '@flighthq/types/contract';
|
|
2
3
|
|
|
3
4
|
import { createMeshGeometry } from './meshGeometry';
|
|
4
5
|
import {
|
|
@@ -90,6 +91,16 @@ describe('convertMeshGeometryLayout', () => {
|
|
|
90
91
|
expect(converted.vertices[1]).toBeCloseTo(2);
|
|
91
92
|
expect(converted.vertices[2]).toBeCloseTo(3);
|
|
92
93
|
});
|
|
94
|
+
it('preserves tangent-split smoothing identity across a layout conversion', () => {
|
|
95
|
+
const source = createMeshGeometry({ layout: CANONICAL_MESH_GEOMETRY_LAYOUT, vertices: new Float32Array(24) });
|
|
96
|
+
(source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources = new Uint32Array([0, 0]);
|
|
97
|
+
|
|
98
|
+
const converted = convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
|
|
99
|
+
|
|
100
|
+
const convertedSources = (converted[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources;
|
|
101
|
+
expect(Array.from(convertedSources!)).toEqual([0, 0]);
|
|
102
|
+
expect(convertedSources).not.toBe((source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources);
|
|
103
|
+
});
|
|
93
104
|
it('strips to position + uv0 layout', () => {
|
|
94
105
|
const source = makeCanonicalGeometry();
|
|
95
106
|
const converted = convertMeshGeometryLayout(source, POSITION_UV0_LAYOUT);
|