@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.
Files changed (48) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/meshGeometry.d.ts +1 -0
  6. package/dist/meshGeometry.d.ts.map +1 -1
  7. package/dist/meshGeometry.js +17 -8
  8. package/dist/meshGeometry.js.map +1 -1
  9. package/dist/meshGeometryAttributes.d.ts.map +1 -1
  10. package/dist/meshGeometryAttributes.js +47 -87
  11. package/dist/meshGeometryAttributes.js.map +1 -1
  12. package/dist/meshGeometryBuilders.d.ts.map +1 -1
  13. package/dist/meshGeometryBuilders.js +152 -134
  14. package/dist/meshGeometryBuilders.js.map +1 -1
  15. package/dist/meshGeometryCompute.d.ts +3 -2
  16. package/dist/meshGeometryCompute.d.ts.map +1 -1
  17. package/dist/meshGeometryCompute.js +283 -294
  18. package/dist/meshGeometryCompute.js.map +1 -1
  19. package/dist/meshGeometryIndex.js +4 -4
  20. package/dist/meshGeometryIndex.js.map +1 -1
  21. package/dist/meshGeometryLayout.d.ts.map +1 -1
  22. package/dist/meshGeometryLayout.js +2 -67
  23. package/dist/meshGeometryLayout.js.map +1 -1
  24. package/dist/meshGeometryOperations.d.ts.map +1 -1
  25. package/dist/meshGeometryOperations.js +93 -7
  26. package/dist/meshGeometryOperations.js.map +1 -1
  27. package/dist/meshGeometryTransforms.d.ts.map +1 -1
  28. package/dist/meshGeometryTransforms.js +90 -19
  29. package/dist/meshGeometryTransforms.js.map +1 -1
  30. package/dist/meshGeometryUvs.d.ts +0 -1
  31. package/dist/meshGeometryUvs.d.ts.map +1 -1
  32. package/dist/meshGeometryUvs.js +10 -18
  33. package/dist/meshGeometryUvs.js.map +1 -1
  34. package/dist/vertexFormat.d.ts +6 -0
  35. package/dist/vertexFormat.d.ts.map +1 -0
  36. package/dist/vertexFormat.js +61 -0
  37. package/dist/vertexFormat.js.map +1 -0
  38. package/package.json +4 -4
  39. package/src/meshGeometry.test.ts +23 -22
  40. package/src/meshGeometryAttributes.test.ts +37 -0
  41. package/src/meshGeometryBuilders.test.ts +333 -10
  42. package/src/meshGeometryCompute.test.ts +246 -95
  43. package/src/meshGeometryDeformationClone.test.ts +2 -0
  44. package/src/meshGeometryLayout.test.ts +1 -12
  45. package/src/meshGeometryOperations.test.ts +98 -0
  46. package/src/meshGeometryTransforms.test.ts +103 -1
  47. package/src/meshGeometryUvs.test.ts +1 -40
  48. 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
- computeMeshGeometryNormals(geometry, geometry);
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
- 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]);
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 { MeshGeometryRuntime, VertexAttributeLayout } from '@flighthq/types/contract';
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);