@flighthq/mesh 0.3.0 → 0.3.1-next.1041.35b950c
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 +17 -8
- package/dist/meshGeometry.js.map +1 -1
- package/dist/meshGeometryAttributes.d.ts.map +1 -1
- package/dist/meshGeometryAttributes.js +47 -87
- package/dist/meshGeometryAttributes.js.map +1 -1
- package/dist/meshGeometryBuilders.d.ts.map +1 -1
- package/dist/meshGeometryBuilders.js +152 -134
- package/dist/meshGeometryBuilders.js.map +1 -1
- package/dist/meshGeometryCompute.d.ts +3 -2
- package/dist/meshGeometryCompute.d.ts.map +1 -1
- package/dist/meshGeometryCompute.js +283 -294
- package/dist/meshGeometryCompute.js.map +1 -1
- package/dist/meshGeometryIndex.js +4 -4
- package/dist/meshGeometryIndex.js.map +1 -1
- package/dist/meshGeometryLayout.d.ts.map +1 -1
- package/dist/meshGeometryLayout.js +2 -67
- package/dist/meshGeometryLayout.js.map +1 -1
- package/dist/meshGeometryOperations.d.ts.map +1 -1
- package/dist/meshGeometryOperations.js +93 -7
- package/dist/meshGeometryOperations.js.map +1 -1
- package/dist/meshGeometryTransforms.d.ts.map +1 -1
- package/dist/meshGeometryTransforms.js +90 -19
- package/dist/meshGeometryTransforms.js.map +1 -1
- package/dist/meshGeometryUvs.d.ts +0 -1
- package/dist/meshGeometryUvs.d.ts.map +1 -1
- package/dist/meshGeometryUvs.js +10 -18
- package/dist/meshGeometryUvs.js.map +1 -1
- package/dist/vertexFormat.d.ts +6 -0
- package/dist/vertexFormat.d.ts.map +1 -0
- package/dist/vertexFormat.js +61 -0
- package/dist/vertexFormat.js.map +1 -0
- package/package.json +4 -4
- package/src/meshGeometry.test.ts +23 -22
- package/src/meshGeometryAttributes.test.ts +37 -0
- package/src/meshGeometryBuilders.test.ts +333 -10
- package/src/meshGeometryCompute.test.ts +246 -95
- package/src/meshGeometryDeformationClone.test.ts +2 -0
- package/src/meshGeometryLayout.test.ts +1 -12
- package/src/meshGeometryOperations.test.ts +98 -0
- package/src/meshGeometryTransforms.test.ts +103 -1
- package/src/meshGeometryUvs.test.ts +1 -40
- package/src/vertexFormat.test.ts +38 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createAabb, createBoundingSphere } from '@flighthq/geometry/contract';
|
|
2
|
-
import type { VertexAttributeLayout } from '@flighthq/types/contract';
|
|
2
|
+
import type { MeshGeometry, VertexAttributeLayout } from '@flighthq/types/contract';
|
|
3
3
|
|
|
4
4
|
import { createMeshGeometry, invalidateMeshGeometry } from './meshGeometry';
|
|
5
5
|
import {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
computeMeshGeometryBounds,
|
|
8
8
|
computeMeshGeometryFlatNormals,
|
|
9
9
|
computeMeshGeometryNormals,
|
|
10
|
+
computeMeshGeometryPositionGroups,
|
|
10
11
|
computeMeshGeometryTangents,
|
|
11
12
|
ensureMeshGeometryBounds,
|
|
12
13
|
refreshMeshGeometryBounds,
|
|
@@ -41,6 +42,43 @@ function makeTriangle() {
|
|
|
41
42
|
return createMeshGeometry({ indices: indices, layout: CANONICAL_LAYOUT, vertices: vertices });
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function makeUvSeamFold() {
|
|
46
|
+
const vertices = new Float32Array(6 * 12);
|
|
47
|
+
const setVertex = (i: number, px: number, py: number, pz: number, u: number, v: number): void => {
|
|
48
|
+
const base = i * 12;
|
|
49
|
+
vertices[base] = px;
|
|
50
|
+
vertices[base + 1] = py;
|
|
51
|
+
vertices[base + 2] = pz;
|
|
52
|
+
vertices[base + 10] = u;
|
|
53
|
+
vertices[base + 11] = v;
|
|
54
|
+
};
|
|
55
|
+
// The first triangle has twice the area of the second. Vertices 0/3 and 1/4 are exact position
|
|
56
|
+
// duplicates with distinct UVs across their shared edge.
|
|
57
|
+
setVertex(0, 0, 0, 0, 0, 0);
|
|
58
|
+
setVertex(1, 2, 0, 0, 1, 0);
|
|
59
|
+
setVertex(2, 0, 2, 0, 0, 1);
|
|
60
|
+
setVertex(3, 0, 0, 0, 0.25, 0.25);
|
|
61
|
+
setVertex(4, 2, 0, 0, 0.75, 0.25);
|
|
62
|
+
setVertex(5, 0, 0, 1, 0.25, 0.75);
|
|
63
|
+
return createMeshGeometry({
|
|
64
|
+
indices: new Uint16Array([0, 1, 2, 3, 4, 5]),
|
|
65
|
+
layout: CANONICAL_LAYOUT,
|
|
66
|
+
vertices,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reconstructBitangent(vertices: Readonly<Float32Array>, vertex: number): [number, number, number] {
|
|
71
|
+
const base = vertex * 12;
|
|
72
|
+
const nx = vertices[base + 3];
|
|
73
|
+
const ny = vertices[base + 4];
|
|
74
|
+
const nz = vertices[base + 5];
|
|
75
|
+
const tx = vertices[base + 6];
|
|
76
|
+
const ty = vertices[base + 7];
|
|
77
|
+
const tz = vertices[base + 8];
|
|
78
|
+
const sign = vertices[base + 9];
|
|
79
|
+
return [sign * (ny * tz - nz * ty), sign * (nz * tx - nx * tz), sign * (nx * ty - ny * tx)];
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
describe('computeMeshGeometryBoundingSphere', () => {
|
|
45
83
|
it('writes the center and radius for a unit-side triangle', () => {
|
|
46
84
|
const geometry = makeTriangle();
|
|
@@ -185,6 +223,57 @@ describe('computeMeshGeometryNormals', () => {
|
|
|
185
223
|
computeMeshGeometryNormals(out, source);
|
|
186
224
|
expect(out.vertices[5]).toBeCloseTo(1);
|
|
187
225
|
});
|
|
226
|
+
|
|
227
|
+
it('bumps the output version after rewriting normals', () => {
|
|
228
|
+
const source = makeTriangle();
|
|
229
|
+
const out = makeTriangle();
|
|
230
|
+
const previousVersion = out.version;
|
|
231
|
+
|
|
232
|
+
computeMeshGeometryNormals(out, source);
|
|
233
|
+
|
|
234
|
+
expect(out.version).toBe(previousVersion + 1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('keeps duplicated positions independent when position groups are omitted', () => {
|
|
238
|
+
const geometry = makeUvSeamFold();
|
|
239
|
+
|
|
240
|
+
computeMeshGeometryNormals(geometry, geometry);
|
|
241
|
+
|
|
242
|
+
expect(Array.from(geometry.vertices.subarray(3, 6))).toEqual([0, 0, 1]);
|
|
243
|
+
expect(Array.from(geometry.vertices.subarray(3 * 12 + 3, 3 * 12 + 6))).toEqual([0, -1, 0]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('area-weights face normals inside exact position groups before normalization', () => {
|
|
247
|
+
const geometry = makeUvSeamFold();
|
|
248
|
+
const groups = computeMeshGeometryPositionGroups(geometry);
|
|
249
|
+
|
|
250
|
+
computeMeshGeometryNormals(geometry, geometry, groups);
|
|
251
|
+
|
|
252
|
+
const expectedY = -1 / Math.sqrt(5);
|
|
253
|
+
const expectedZ = 2 / Math.sqrt(5);
|
|
254
|
+
for (const vertex of [0, 1, 3, 4]) {
|
|
255
|
+
const base = vertex * 12 + 3;
|
|
256
|
+
expect(geometry.vertices[base]).toBeCloseTo(0);
|
|
257
|
+
expect(geometry.vertices[base + 1]).toBeCloseTo(expectedY);
|
|
258
|
+
expect(geometry.vertices[base + 2]).toBeCloseTo(expectedZ);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('computeMeshGeometryPositionGroups', () => {
|
|
264
|
+
it('returns the first exact-position vertex as each group representative', () => {
|
|
265
|
+
const geometry = makeUvSeamFold();
|
|
266
|
+
|
|
267
|
+
expect(Array.from(computeMeshGeometryPositionGroups(geometry))).toEqual([0, 1, 2, 0, 1, 5]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('does not group adjacent Float32 values', () => {
|
|
271
|
+
const geometry = makeUvSeamFold();
|
|
272
|
+
const bits = new Uint32Array(geometry.vertices.buffer);
|
|
273
|
+
bits[3 * 12] = 1;
|
|
274
|
+
|
|
275
|
+
expect(Array.from(computeMeshGeometryPositionGroups(geometry))).toEqual([0, 1, 2, 3, 1, 5]);
|
|
276
|
+
});
|
|
188
277
|
});
|
|
189
278
|
|
|
190
279
|
describe('computeMeshGeometryTangents', () => {
|
|
@@ -224,6 +313,38 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
224
313
|
expect(geometry.vertices[9]).toBe(-1);
|
|
225
314
|
});
|
|
226
315
|
|
|
316
|
+
it('keeps the reconstructed bitangent continuous across a grouped mirrored-UV seam', () => {
|
|
317
|
+
const vertices = new Float32Array(6 * 12);
|
|
318
|
+
// The duplicate seam position (vertices 0/3) sees different face frames on each side. The second
|
|
319
|
+
// UV chart mirrors u: its tangent and handedness flip, while the shared bitangent must not.
|
|
320
|
+
setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
|
|
321
|
+
setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
|
|
322
|
+
setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
|
|
323
|
+
setCanonicalVertex(vertices, 3, 0, 0, 0, 0);
|
|
324
|
+
setCanonicalVertex(vertices, 4, -1, 0, 0, 1);
|
|
325
|
+
setCanonicalVertex(vertices, 5, 0, -1, 1, 0);
|
|
326
|
+
const geometry = createMeshGeometry({
|
|
327
|
+
indices: new Uint16Array([0, 1, 2, 3, 4, 5]),
|
|
328
|
+
layout: CANONICAL_LAYOUT,
|
|
329
|
+
vertices,
|
|
330
|
+
});
|
|
331
|
+
const groups = computeMeshGeometryPositionGroups(geometry);
|
|
332
|
+
computeMeshGeometryNormals(geometry, geometry, groups);
|
|
333
|
+
|
|
334
|
+
computeMeshGeometryTangents(geometry, geometry, groups);
|
|
335
|
+
|
|
336
|
+
expect(geometry.vertices[9]).toBe(1);
|
|
337
|
+
expect(geometry.vertices[3 * 12 + 9]).toBe(-1);
|
|
338
|
+
const firstBitangent = reconstructBitangent(geometry.vertices, 0);
|
|
339
|
+
const mirroredBitangent = reconstructBitangent(geometry.vertices, 3);
|
|
340
|
+
expect(firstBitangent[0]).toBeCloseTo(-1 / Math.sqrt(2));
|
|
341
|
+
expect(firstBitangent[1]).toBeCloseTo(1 / Math.sqrt(2));
|
|
342
|
+
expect(firstBitangent[2]).toBeCloseTo(0);
|
|
343
|
+
for (let component = 0; component < 3; component++) {
|
|
344
|
+
expect(mirroredBitangent[component]).toBeCloseTo(firstBitangent[component]);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
227
348
|
it('splits shared vertices at a mirrored-UV handedness boundary', () => {
|
|
228
349
|
// Two CCW triangles share vertices 0/2. Their UV determinants have opposite signs, so a single
|
|
229
350
|
// tangent.w cannot describe both triangles: the shared records must be duplicated and remapped.
|
|
@@ -237,10 +358,11 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
237
358
|
layout: CANONICAL_LAYOUT,
|
|
238
359
|
vertices,
|
|
239
360
|
});
|
|
240
|
-
|
|
361
|
+
const groups = computeMeshGeometryPositionGroups(geometry);
|
|
362
|
+
computeMeshGeometryNormals(geometry, geometry, groups);
|
|
241
363
|
const previousVersion = geometry.version;
|
|
242
364
|
|
|
243
|
-
computeMeshGeometryTangents(geometry, geometry);
|
|
365
|
+
computeMeshGeometryTangents(geometry, geometry, groups);
|
|
244
366
|
|
|
245
367
|
expect(geometry.vertices.length / 12).toBe(6);
|
|
246
368
|
expect(geometry.version).toBe(previousVersion + 1);
|
|
@@ -258,70 +380,10 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
258
380
|
expect(indices[2]).toBe(2);
|
|
259
381
|
expect(indices[3]).toBeGreaterThanOrEqual(4);
|
|
260
382
|
expect(indices[4]).toBeGreaterThanOrEqual(4);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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]);
|
|
383
|
+
const firstBitangent = reconstructBitangent(geometry.vertices, indices[0]);
|
|
384
|
+
const mirroredBitangent = reconstructBitangent(geometry.vertices, indices[3]);
|
|
385
|
+
for (let component = 0; component < 3; component++) {
|
|
386
|
+
expect(mirroredBitangent[component]).toBeCloseTo(firstBitangent[component]);
|
|
325
387
|
}
|
|
326
388
|
});
|
|
327
389
|
|
|
@@ -374,6 +436,34 @@ describe('computeMeshGeometryTangents', () => {
|
|
|
374
436
|
});
|
|
375
437
|
});
|
|
376
438
|
|
|
439
|
+
describe('ensureMeshGeometryBounds', () => {
|
|
440
|
+
it('computes on first query, then reuses the cache until the version moves', () => {
|
|
441
|
+
const geometry = makeTriangle();
|
|
442
|
+
expect(geometry.bounds).toBeNull();
|
|
443
|
+
|
|
444
|
+
const bounds = ensureMeshGeometryBounds(geometry);
|
|
445
|
+
expect(bounds?.max.x).toBe(1);
|
|
446
|
+
|
|
447
|
+
// A vertex edit WITHOUT a version bump must not be picked up — the version is the dirty signal,
|
|
448
|
+
// and honouring the cache is what makes the steady-state query an integer compare.
|
|
449
|
+
geometry.vertices[12] = 4;
|
|
450
|
+
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(1);
|
|
451
|
+
|
|
452
|
+
// The public direct-write escape hatch marks the cache stale.
|
|
453
|
+
invalidateMeshGeometry(geometry);
|
|
454
|
+
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(4);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('trusts bounds supplied at construction without re-sweeping them', () => {
|
|
458
|
+
const geometry = makeTriangle();
|
|
459
|
+
refreshMeshGeometryBounds(geometry);
|
|
460
|
+
const bounds = geometry.bounds;
|
|
461
|
+
|
|
462
|
+
// Same AABB instance back, no recompute: the cache is valid for the current version.
|
|
463
|
+
expect(ensureMeshGeometryBounds(geometry)).toBe(bounds);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
377
467
|
function setCanonicalVertex(vertices: Float32Array, vertex: number, x: number, y: number, u: number, v: number): void {
|
|
378
468
|
const base = vertex * 12;
|
|
379
469
|
vertices[base] = x;
|
|
@@ -407,34 +497,6 @@ function setSkinnedVertex(
|
|
|
407
497
|
vertices[base + 19] = 0.4;
|
|
408
498
|
}
|
|
409
499
|
|
|
410
|
-
describe('ensureMeshGeometryBounds', () => {
|
|
411
|
-
it('computes on first query, then reuses the cache until the version moves', () => {
|
|
412
|
-
const geometry = makeTriangle();
|
|
413
|
-
expect(geometry.bounds).toBeNull();
|
|
414
|
-
|
|
415
|
-
const bounds = ensureMeshGeometryBounds(geometry);
|
|
416
|
-
expect(bounds?.max.x).toBe(1);
|
|
417
|
-
|
|
418
|
-
// A vertex edit WITHOUT a version bump must not be picked up — the version is the dirty signal,
|
|
419
|
-
// and honouring the cache is what makes the steady-state query an integer compare.
|
|
420
|
-
geometry.vertices[12] = 4;
|
|
421
|
-
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(1);
|
|
422
|
-
|
|
423
|
-
// The public direct-write escape hatch marks the cache stale.
|
|
424
|
-
invalidateMeshGeometry(geometry);
|
|
425
|
-
expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(4);
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('trusts bounds supplied at construction without re-sweeping them', () => {
|
|
429
|
-
const geometry = makeTriangle();
|
|
430
|
-
refreshMeshGeometryBounds(geometry);
|
|
431
|
-
const bounds = geometry.bounds;
|
|
432
|
-
|
|
433
|
-
// Same AABB instance back, no recompute: the cache is valid for the current version.
|
|
434
|
-
expect(ensureMeshGeometryBounds(geometry)).toBe(bounds);
|
|
435
|
-
});
|
|
436
|
-
});
|
|
437
|
-
|
|
438
500
|
describe('refreshMeshGeometryBounds', () => {
|
|
439
501
|
it('allocates once, then refreshes the same cached bounds after vertex edits', () => {
|
|
440
502
|
const geometry = makeTriangle();
|
|
@@ -449,3 +511,92 @@ describe('refreshMeshGeometryBounds', () => {
|
|
|
449
511
|
expect(bounds?.max.x).toBe(4);
|
|
450
512
|
});
|
|
451
513
|
});
|
|
514
|
+
|
|
515
|
+
describe('triangle-strip topology', () => {
|
|
516
|
+
// A four-vertex strip [0, 1, 2, 3] is two triangles; the same surface as a list is
|
|
517
|
+
// [0, 1, 2, 2, 1, 3], including the winding flip the odd strip triangle carries. Every compute
|
|
518
|
+
// pass must produce the same attributes from either spelling.
|
|
519
|
+
//
|
|
520
|
+
// The fixture is deliberately IRREGULAR — the fourth corner is lifted out of the plane and its
|
|
521
|
+
// UV is skewed. A flat quad with axis-aligned UVs makes both triangles agree by accident: every
|
|
522
|
+
// tangent comes out (1, 0, 0, 1) whichever topology is used, so the tangent pass looks correct
|
|
523
|
+
// while ignoring topology entirely. The irregular corner is what makes the two disagree when the
|
|
524
|
+
// second triangle is dropped.
|
|
525
|
+
function createStripQuad(topology: 'triangle-list' | 'triangle-strip'): MeshGeometry {
|
|
526
|
+
const vertices = new Float32Array(4 * 12);
|
|
527
|
+
setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
|
|
528
|
+
setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
|
|
529
|
+
setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
|
|
530
|
+
setCanonicalVertex(vertices, 3, 1, 1, 0.3, 0.9);
|
|
531
|
+
vertices[3 * 12 + 2] = 1; // lift the fourth corner out of the plane
|
|
532
|
+
const indices = topology === 'triangle-strip' ? [0, 1, 2, 3] : [0, 1, 2, 2, 1, 3];
|
|
533
|
+
return createMeshGeometry({
|
|
534
|
+
indices: new Uint16Array(indices),
|
|
535
|
+
layout: CANONICAL_LAYOUT,
|
|
536
|
+
topology,
|
|
537
|
+
vertices,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function expectStripMatchesList(
|
|
542
|
+
compute: (out: MeshGeometry, geometry: Readonly<MeshGeometry>) => void,
|
|
543
|
+
offset: number,
|
|
544
|
+
componentCount: number,
|
|
545
|
+
): void {
|
|
546
|
+
const strip = createStripQuad('triangle-strip');
|
|
547
|
+
const list = createStripQuad('triangle-list');
|
|
548
|
+
compute(strip, strip);
|
|
549
|
+
compute(list, list);
|
|
550
|
+
for (let vertex = 0; vertex < 4; vertex++) {
|
|
551
|
+
for (let component = 0; component < componentCount; component++) {
|
|
552
|
+
expect(strip.vertices[vertex * 12 + offset + component]).toBeCloseTo(
|
|
553
|
+
list.vertices[vertex * 12 + offset + component],
|
|
554
|
+
5,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
it('computeMeshGeometryNormals reads a strip as two triangles, not one', () => {
|
|
561
|
+
// Dropping the second triangle leaves vertex 3 at exactly (0, 0, 0) and vertices 1 and 2
|
|
562
|
+
// carrying only the first triangle's contribution — three of the four wrong, not just the one
|
|
563
|
+
// no earlier triangle referenced.
|
|
564
|
+
expectStripMatchesList(computeMeshGeometryNormals, 3, 3);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('computeMeshGeometryFlatNormals reads a strip as two triangles, not one', () => {
|
|
568
|
+
expectStripMatchesList(computeMeshGeometryFlatNormals, 3, 3);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('computeMeshGeometryTangents reads a strip as two triangles, not one', () => {
|
|
572
|
+
// A vertex that accumulates nothing falls back to a unit perpendicular, so the dropped
|
|
573
|
+
// triangle shows up here as a plausible-looking tangent rather than a zero — harder to spot by
|
|
574
|
+
// eye than the zero normal, which is why it is asserted against the list rather than a literal.
|
|
575
|
+
const strip = createStripQuad('triangle-strip');
|
|
576
|
+
const list = createStripQuad('triangle-list');
|
|
577
|
+
computeMeshGeometryNormals(strip, strip);
|
|
578
|
+
computeMeshGeometryNormals(list, list);
|
|
579
|
+
computeMeshGeometryTangents(strip, strip);
|
|
580
|
+
computeMeshGeometryTangents(list, list);
|
|
581
|
+
for (let vertex = 0; vertex < 4; vertex++) {
|
|
582
|
+
for (let component = 0; component < 4; component++) {
|
|
583
|
+
expect(strip.vertices[vertex * 12 + 6 + component]).toBeCloseTo(list.vertices[vertex * 12 + 6 + component], 5);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('leaves no vertex of a strip without a normal', () => {
|
|
589
|
+
// The symptom that reaches a renderer: an unreferenced vertex keeps the zero normal it was
|
|
590
|
+
// allocated with, and lights as a black seam.
|
|
591
|
+
const strip = createStripQuad('triangle-strip');
|
|
592
|
+
computeMeshGeometryNormals(strip, strip);
|
|
593
|
+
for (let vertex = 0; vertex < 4; vertex++) {
|
|
594
|
+
const length = Math.hypot(
|
|
595
|
+
strip.vertices[vertex * 12 + 3],
|
|
596
|
+
strip.vertices[vertex * 12 + 4],
|
|
597
|
+
strip.vertices[vertex * 12 + 5],
|
|
598
|
+
);
|
|
599
|
+
expect(length).toBeCloseTo(1, 5);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
});
|
|
@@ -48,6 +48,8 @@ describe('cloneMeshGeometryForDeformation', () => {
|
|
|
48
48
|
positions: new Float32Array([1, 2, 3]),
|
|
49
49
|
skinnedNormals: new Float32Array(3),
|
|
50
50
|
skinnedPositions: new Float32Array(3),
|
|
51
|
+
skinnedTangents: new Float32Array(0),
|
|
52
|
+
tangents: new Float32Array(0),
|
|
51
53
|
weights: new Float32Array(4),
|
|
52
54
|
});
|
|
53
55
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { EntityRuntimeKey } from '@flighthq/types/contract';
|
|
1
|
+
import type { VertexAttributeLayout } from '@flighthq/types/contract';
|
|
3
2
|
|
|
4
3
|
import { createMeshGeometry } from './meshGeometry';
|
|
5
4
|
import {
|
|
@@ -91,16 +90,6 @@ describe('convertMeshGeometryLayout', () => {
|
|
|
91
90
|
expect(converted.vertices[1]).toBeCloseTo(2);
|
|
92
91
|
expect(converted.vertices[2]).toBeCloseTo(3);
|
|
93
92
|
});
|
|
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
|
-
});
|
|
104
93
|
it('strips to position + uv0 layout', () => {
|
|
105
94
|
const source = makeCanonicalGeometry();
|
|
106
95
|
const converted = convertMeshGeometryLayout(source, POSITION_UV0_LAYOUT);
|
|
@@ -139,6 +139,29 @@ describe('mergeMeshGeometries', () => {
|
|
|
139
139
|
const b = createMeshGeometry({ layout: POSITION_ONLY_LAYOUT, vertices: new Float32Array(9) });
|
|
140
140
|
expect(mergeMeshGeometries([a, b])).toBeNull();
|
|
141
141
|
});
|
|
142
|
+
it('returns null on topology mismatch', () => {
|
|
143
|
+
// The merged result carries ONE topology, so concatenating a line-list into a triangle-list
|
|
144
|
+
// would reinterpret pairs of line endpoints as face corners — triangles nobody authored, from
|
|
145
|
+
// a call that reported success. There is no correct single topology for that merge, so it is
|
|
146
|
+
// refused, the same way a layout mismatch is.
|
|
147
|
+
const faces = createQuadMeshGeometry();
|
|
148
|
+
const lines = createMeshGeometry({
|
|
149
|
+
layout: faces.layout,
|
|
150
|
+
topology: 'line-list',
|
|
151
|
+
vertices: new Float32Array(faces.vertices.length),
|
|
152
|
+
});
|
|
153
|
+
expect(mergeMeshGeometries([faces, lines])).toBeNull();
|
|
154
|
+
expect(mergeMeshGeometries([lines, faces])).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
it('merges geometries that share a non-default topology', () => {
|
|
157
|
+
// The check is mismatch, not triangle-list-only: two line meshes still merge.
|
|
158
|
+
const layout = createQuadMeshGeometry().layout;
|
|
159
|
+
const a = createMeshGeometry({ layout, topology: 'line-list', vertices: new Float32Array(24) });
|
|
160
|
+
const b = createMeshGeometry({ layout, topology: 'line-list', vertices: new Float32Array(24) });
|
|
161
|
+
const merged = mergeMeshGeometries([a, b]);
|
|
162
|
+
expect(merged).not.toBeNull();
|
|
163
|
+
expect(merged!.topology).toBe('line-list');
|
|
164
|
+
});
|
|
142
165
|
it('concatenates vertices from two geometries', () => {
|
|
143
166
|
const a = createQuadMeshGeometry();
|
|
144
167
|
const b = createQuadMeshGeometry();
|
|
@@ -207,4 +230,79 @@ describe('validateMeshGeometry', () => {
|
|
|
207
230
|
const geo = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: verts });
|
|
208
231
|
expect(validateMeshGeometry(geo)).toBe(false);
|
|
209
232
|
});
|
|
233
|
+
|
|
234
|
+
// Position was the only channel checked, so a degenerate normal, tangent or UV passed straight
|
|
235
|
+
// through. This validator sits between every importer and the GPU, and a NaN normal reaches a shader
|
|
236
|
+
// exactly as a NaN position does — normalize() of it is undefined rather than merely misplaced.
|
|
237
|
+
// Canonical layout float offsets: position 0..2, normal 3..5, tangent 6..9, uv0 10..11.
|
|
238
|
+
it.each([
|
|
239
|
+
['a normal', 3],
|
|
240
|
+
['a tangent', 6],
|
|
241
|
+
['a uv', 10],
|
|
242
|
+
])('returns false for NaN in %s', (_channel, offset) => {
|
|
243
|
+
const verts = new Float32Array(12);
|
|
244
|
+
verts[offset] = NaN;
|
|
245
|
+
|
|
246
|
+
expect(validateMeshGeometry(createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: verts }))).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('returns false for Infinity in a tangent', () => {
|
|
250
|
+
const verts = new Float32Array(12);
|
|
251
|
+
verts[7] = Infinity;
|
|
252
|
+
|
|
253
|
+
expect(validateMeshGeometry(createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: verts }))).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A subset is a draw call's offset and count. A range past the end of the element stream is one the
|
|
257
|
+
// driver rejects, so approving it here means the validator passed something no backend can execute.
|
|
258
|
+
it('returns false for a subset whose draw range runs past the index buffer', () => {
|
|
259
|
+
const geo = createMeshGeometry({
|
|
260
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
261
|
+
layout: CANONICAL_LAYOUT,
|
|
262
|
+
vertices: new Float32Array(3 * 12),
|
|
263
|
+
});
|
|
264
|
+
geo.subsets = [{ indexCount: 99, indexOffset: 2 }];
|
|
265
|
+
|
|
266
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// A triangle list ending mid-triangle has no drawable interpretation of its remainder.
|
|
270
|
+
it('returns false for an element count that cannot form whole triangles', () => {
|
|
271
|
+
const geo = createMeshGeometry({
|
|
272
|
+
indices: new Uint16Array([0, 1, 2, 0]),
|
|
273
|
+
layout: CANONICAL_LAYOUT,
|
|
274
|
+
vertices: new Float32Array(3 * 12),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(validateMeshGeometry(geo)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('accepts a line list with an even element count and rejects an odd one', () => {
|
|
281
|
+
const evenGeo = createMeshGeometry({
|
|
282
|
+
indices: new Uint16Array([0, 1, 1, 2]),
|
|
283
|
+
layout: CANONICAL_LAYOUT,
|
|
284
|
+
topology: 'line-list',
|
|
285
|
+
vertices: new Float32Array(3 * 12),
|
|
286
|
+
});
|
|
287
|
+
const oddGeo = createMeshGeometry({
|
|
288
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
289
|
+
layout: CANONICAL_LAYOUT,
|
|
290
|
+
topology: 'line-list',
|
|
291
|
+
vertices: new Float32Array(3 * 12),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(validateMeshGeometry(evenGeo)).toBe(true);
|
|
295
|
+
expect(validateMeshGeometry(oddGeo)).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// An empty range draws nothing, which is a legal state rather than a malformed one.
|
|
299
|
+
it('accepts an empty element stream', () => {
|
|
300
|
+
const geo = createMeshGeometry({
|
|
301
|
+
indices: new Uint16Array(0),
|
|
302
|
+
layout: CANONICAL_LAYOUT,
|
|
303
|
+
vertices: new Float32Array(3 * 12),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(validateMeshGeometry(geo)).toBe(true);
|
|
307
|
+
});
|
|
210
308
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createMatrix4, setMatrix4Identity } from '@flighthq/geometry/contract';
|
|
1
|
+
import { createMatrix4, setMatrix4, setMatrix4Identity } from '@flighthq/geometry/contract';
|
|
2
2
|
import type { Aabb, VertexAttributeLayout } from '@flighthq/types/contract';
|
|
3
3
|
|
|
4
4
|
import { createMeshGeometry } from './meshGeometry';
|
|
@@ -66,6 +66,108 @@ describe('centerMeshGeometry', () => {
|
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
describe('mirror and non-uniform scale invariants', () => {
|
|
70
|
+
// ★ THE FIXTURE IS THE POINT. `makeVertex` above is axis-aligned — normal (1,0,0), tangent
|
|
71
|
+
// (0,1,0) — and under scale(2,1,1) the correct tangent transform and the inverse-transpose one
|
|
72
|
+
// produce the SAME vector, so it cannot tell them apart. That is why a tangent transformed by the
|
|
73
|
+
// wrong matrix survived here. These use a diagonal frame in the XY plane, where the two answers
|
|
74
|
+
// differ. Do not "simplify" this fixture back to an axis-aligned one.
|
|
75
|
+
function makeDiagonalFrameVertex() {
|
|
76
|
+
const r = Math.SQRT1_2;
|
|
77
|
+
const vertices = new Float32Array(12);
|
|
78
|
+
vertices[0] = 1;
|
|
79
|
+
vertices[3] = r;
|
|
80
|
+
vertices[4] = r; // normal ( .707, .707, 0)
|
|
81
|
+
vertices[6] = r;
|
|
82
|
+
vertices[7] = -r; // tangent ( .707, -.707, 0)
|
|
83
|
+
vertices[9] = 1; // handedness
|
|
84
|
+
return createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalDotTangent(geometry: ReturnType<typeof makeDiagonalFrameVertex>) {
|
|
88
|
+
const v = geometry.vertices;
|
|
89
|
+
return v[3] * v[6] + v[4] * v[7] + v[5] * v[8];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it('keeps the tangent on the surface under a non-uniform scale', () => {
|
|
93
|
+
// A tangent is a true vector and follows the model matrix; a normal is a covector and follows
|
|
94
|
+
// the inverse-transpose. Transform both with the inverse-transpose and the tangent tilts off
|
|
95
|
+
// the surface — it stops being perpendicular to its own normal, which is what breaks the TBN
|
|
96
|
+
// frame every normal-mapped material reconstructs.
|
|
97
|
+
const geometry = makeDiagonalFrameVertex();
|
|
98
|
+
const matrix = createMatrix4();
|
|
99
|
+
setMatrix4(matrix, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
|
|
100
|
+
transformMeshGeometry(geometry, matrix);
|
|
101
|
+
expect(normalDotTangent(geometry)).toBeCloseTo(0, 5);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('keeps the tangent on the surface under scaleMeshGeometry too', () => {
|
|
105
|
+
const geometry = makeDiagonalFrameVertex();
|
|
106
|
+
scaleMeshGeometry(geometry, 2, 1, 1);
|
|
107
|
+
expect(normalDotTangent(geometry)).toBeCloseTo(0, 5);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reverses indexed triangle winding and flips handedness when the transform mirrors', () => {
|
|
111
|
+
// A baked mirror leaves no determinant behind for a renderer to detect, so the geometry would
|
|
112
|
+
// upload inside-out under an identity model matrix. The correction has to happen at bake time.
|
|
113
|
+
const vertices = new Float32Array(3 * 12);
|
|
114
|
+
for (let v = 0; v < 3; v++) vertices[v * 12 + 9] = 1; // handedness on every vertex
|
|
115
|
+
const geometry = createMeshGeometry({
|
|
116
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
117
|
+
layout: CANONICAL_LAYOUT,
|
|
118
|
+
vertices,
|
|
119
|
+
});
|
|
120
|
+
scaleMeshGeometry(geometry, -1, 1, 1);
|
|
121
|
+
expect(Array.from(geometry.indices!)).toEqual([0, 2, 1]);
|
|
122
|
+
for (let v = 0; v < 3; v++) expect(geometry.vertices[v * 12 + 9]).toBe(-1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reverses non-indexed triangle winding by swapping the records themselves', () => {
|
|
126
|
+
// With no index array the record ORDER is the winding, so the second and third records of each
|
|
127
|
+
// triple exchange places — and they must exchange whole, not just their positions.
|
|
128
|
+
const vertices = new Float32Array(3 * 12);
|
|
129
|
+
for (let v = 0; v < 3; v++) {
|
|
130
|
+
vertices[v * 12] = v; // position.x identifies the record
|
|
131
|
+
vertices[v * 12 + 10] = v; // uv0.u rides along to prove the whole record moved
|
|
132
|
+
}
|
|
133
|
+
const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices });
|
|
134
|
+
scaleMeshGeometry(geometry, -1, 1, 1);
|
|
135
|
+
expect([geometry.vertices[0], geometry.vertices[12], geometry.vertices[24]]).toEqual([0, -2, -1]);
|
|
136
|
+
expect([geometry.vertices[10], geometry.vertices[22], geometry.vertices[34]]).toEqual([0, 2, 1]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('leaves winding and handedness alone when the transform does not mirror', () => {
|
|
140
|
+
const vertices = new Float32Array(3 * 12);
|
|
141
|
+
for (let v = 0; v < 3; v++) vertices[v * 12 + 9] = 1;
|
|
142
|
+
const geometry = createMeshGeometry({
|
|
143
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
144
|
+
layout: CANONICAL_LAYOUT,
|
|
145
|
+
vertices,
|
|
146
|
+
});
|
|
147
|
+
scaleMeshGeometry(geometry, 2, 3, 4);
|
|
148
|
+
expect(Array.from(geometry.indices!)).toEqual([0, 1, 2]);
|
|
149
|
+
for (let v = 0; v < 3; v++) expect(geometry.vertices[v * 12 + 9]).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('flips strip handedness but leaves strip indices untouched', () => {
|
|
153
|
+
// Handedness is per-vertex and topology-independent, so it is corrected here as everywhere.
|
|
154
|
+
// The winding is NOT: a strip shares each vertex between up to three triangles, so a per-triple
|
|
155
|
+
// swap does not describe it, and there is no established strip reversal in this repository to
|
|
156
|
+
// follow. This pins the current, deliberate gap rather than claiming strips are handled.
|
|
157
|
+
const vertices = new Float32Array(4 * 12);
|
|
158
|
+
for (let v = 0; v < 4; v++) vertices[v * 12 + 9] = 1;
|
|
159
|
+
const geometry = createMeshGeometry({
|
|
160
|
+
indices: new Uint16Array([0, 1, 2, 3]),
|
|
161
|
+
layout: CANONICAL_LAYOUT,
|
|
162
|
+
topology: 'triangle-strip',
|
|
163
|
+
vertices,
|
|
164
|
+
});
|
|
165
|
+
scaleMeshGeometry(geometry, -1, 1, 1);
|
|
166
|
+
expect(Array.from(geometry.indices!)).toEqual([0, 1, 2, 3]);
|
|
167
|
+
for (let v = 0; v < 4; v++) expect(geometry.vertices[v * 12 + 9]).toBe(-1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
69
171
|
describe('scaleMeshGeometry', () => {
|
|
70
172
|
it('scales positions by the given factors', () => {
|
|
71
173
|
const geo = makeVertex(1, 2, 3);
|