@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,4 +1,4 @@
1
- import type { MeshGeometry } from '@flighthq/types/contract';
1
+ import type { MeshGeometry, MeshTriangleVertexIndices } from '@flighthq/types/contract';
2
2
 
3
3
  import { getMeshGeometryIndexCount, getMeshGeometryVertexCount } from './meshGeometry';
4
4
  import {
@@ -20,6 +20,8 @@ import {
20
20
  createTorusKnotMeshGeometry,
21
21
  createTorusMeshGeometry,
22
22
  } from './meshGeometryBuilders';
23
+ import { getMeshGeometryTriangleCount, getMeshGeometryTriangleVertexIndices } from './meshGeometryOperations';
24
+ import { validateMeshGeometry } from './meshGeometryOperations';
23
25
 
24
26
  function expectUnitNormals(geometry: Readonly<MeshGeometry>): void {
25
27
  const count = getMeshGeometryVertexCount(geometry);
@@ -42,6 +44,10 @@ function expectUnitTangents(geometry: Readonly<MeshGeometry>): void {
42
44
  }
43
45
  }
44
46
 
47
+ function expectFiniteCanonicalRecord(geometry: Readonly<MeshGeometry>): void {
48
+ for (const value of geometry.vertices) expect(Number.isFinite(value)).toBe(true);
49
+ }
50
+
45
51
  // Asserts a triangle's geometric face normal agrees with its vertices' averaged shading normal, so
46
52
  // the winding (CCW front-facing) matches the outward-pointing normals the builder emits. A back-
47
53
  // facing wind would be culled and expose the interior surface instead. Scans from the first triangle
@@ -91,9 +97,23 @@ describe('createBoxMeshGeometry', () => {
91
97
  describe('createCapsuleMeshGeometry', () => {
92
98
  it('builds a capsule that spans -(height/2+radius)..(height/2+radius) on Y', () => {
93
99
  const geometry = createCapsuleMeshGeometry(0.5, 1, 8, 4);
94
- expect(getMeshGeometryVertexCount(geometry)).toBeGreaterThan(0);
100
+ expect(getMeshGeometryVertexCount(geometry)).toBe(90);
101
+ expect(getMeshGeometryIndexCount(geometry)).toBe(432);
95
102
  expect(geometry.bounds!.min.y).toBeCloseTo(-1, 1);
96
103
  expect(geometry.bounds!.max.y).toBeCloseTo(1, 1);
104
+
105
+ const stride = 12;
106
+ const ringVertexCount = 9;
107
+ const topEquator = 4 * ringVertexCount * stride;
108
+ const bottomEquator = 5 * ringVertexCount * stride;
109
+ expect(geometry.vertices[topEquator]).toBeCloseTo(0.5);
110
+ expect(geometry.vertices[topEquator + 1]).toBeCloseTo(0.5);
111
+ expect(geometry.vertices[topEquator + 4]).toBeCloseTo(0);
112
+ expect(geometry.vertices[topEquator + 11]).toBeCloseTo(4 / 9);
113
+ expect(geometry.vertices[bottomEquator]).toBeCloseTo(0.5);
114
+ expect(geometry.vertices[bottomEquator + 1]).toBeCloseTo(-0.5);
115
+ expect(geometry.vertices[bottomEquator + 4]).toBeCloseTo(0);
116
+ expect(geometry.vertices[bottomEquator + 11]).toBeCloseTo(5 / 9);
97
117
  expectUnitNormals(geometry);
98
118
  });
99
119
  });
@@ -126,6 +146,15 @@ describe('createCylinderMeshGeometry', () => {
126
146
  expectUnitNormals(geometry);
127
147
  expectUnitTangents(geometry);
128
148
  });
149
+
150
+ it('keeps cylinder and cone records finite when height is zero', () => {
151
+ const cylinder = createCylinderMeshGeometry(0.5, 0.5, 0, 8);
152
+ const cone = createConeMeshGeometry(0.5, 0, 8);
153
+ expectFiniteCanonicalRecord(cylinder);
154
+ expectFiniteCanonicalRecord(cone);
155
+ expect(validateMeshGeometry(cylinder)).toBe(true);
156
+ expect(validateMeshGeometry(cone)).toBe(true);
157
+ });
129
158
  });
130
159
 
131
160
  describe('createDodecahedronMeshGeometry', () => {
@@ -182,15 +211,78 @@ describe('createPlaneMeshGeometry', () => {
182
211
  });
183
212
 
184
213
  describe('createPolyhedronMeshGeometry', () => {
185
- it('accepts custom vertex/face data', () => {
186
- const verts: ReadonlyArray<readonly [number, number, number]> = [
187
- [0, 1, 0],
188
- [-1, -1, 0],
189
- [1, -1, 0],
190
- ];
191
- const faces: ReadonlyArray<readonly [number, number, number]> = [[0, 1, 2]];
192
- const geometry = createPolyhedronMeshGeometry(verts, faces, 1, 0);
214
+ const TRIANGLE_VERTS: ReadonlyArray<readonly [number, number, number]> = [
215
+ [0, 1, 0],
216
+ [-1, -1, 0],
217
+ [1, -1, 0],
218
+ ];
219
+ const ONE_FACE: ReadonlyArray<readonly [number, number, number]> = [[0, 1, 2]];
220
+
221
+ // The name promises the custom data is USED. A vertex count alone cannot tell that from a builder
222
+ // that ignored both arguments and emitted any three vertices, so the seed positions are asserted
223
+ // through to the output — projected onto the radius sphere, which is what this builder does with them.
224
+ it('projects the supplied vertices onto the radius sphere', () => {
225
+ const geometry = createPolyhedronMeshGeometry(TRIANGLE_VERTS, ONE_FACE, 2, 0);
226
+
193
227
  expect(getMeshGeometryVertexCount(geometry)).toBe(3);
228
+ const floatsPerVertex = geometry.layout.stride / 4;
229
+ // The first seed is already unit length on +Y, so it lands on the sphere pole at the given radius.
230
+ expect(geometry.vertices[0]).toBeCloseTo(0, 6);
231
+ expect(geometry.vertices[1]).toBeCloseTo(2, 6);
232
+ expect(geometry.vertices[2]).toBeCloseTo(0, 6);
233
+ for (let v = 0; v < 3; v++) {
234
+ const base = v * floatsPerVertex;
235
+ expect(Math.hypot(geometry.vertices[base], geometry.vertices[base + 1], geometry.vertices[base + 2])).toBeCloseTo(
236
+ 2,
237
+ 6,
238
+ );
239
+ // Normals are the unit direction, independent of radius.
240
+ expect(
241
+ Math.hypot(geometry.vertices[base + 3], geometry.vertices[base + 4], geometry.vertices[base + 5]),
242
+ ).toBeCloseTo(1, 6);
243
+ }
244
+ });
245
+
246
+ // Different seed data must produce different geometry — the assertion the count-only version could
247
+ // not make, and the one that fails if the arguments are ever ignored.
248
+ it('produces different geometry for different seed vertices', () => {
249
+ const a = createPolyhedronMeshGeometry(TRIANGLE_VERTS, ONE_FACE, 1, 0);
250
+ const b = createPolyhedronMeshGeometry(
251
+ [
252
+ [0, 0, 1],
253
+ [0, 1, 0],
254
+ [1, 0, 0],
255
+ ],
256
+ ONE_FACE,
257
+ 1,
258
+ 0,
259
+ );
260
+
261
+ expect(Array.from(a.vertices)).not.toEqual(Array.from(b.vertices));
262
+ });
263
+
264
+ // Each subdivision splits every face into four, so the triangle count multiplies by four per level.
265
+ it.each([
266
+ [0, 3],
267
+ [1, 12],
268
+ [2, 48],
269
+ ])('subdivides to %i levels giving %i vertices', (detail, expected) => {
270
+ expect(getMeshGeometryVertexCount(createPolyhedronMeshGeometry(TRIANGLE_VERTS, ONE_FACE, 1, detail))).toBe(
271
+ expected,
272
+ );
273
+ });
274
+
275
+ it('keeps every subdivided vertex on the sphere', () => {
276
+ const geometry = createPolyhedronMeshGeometry(TRIANGLE_VERTS, ONE_FACE, 3, 2);
277
+
278
+ const floatsPerVertex = geometry.layout.stride / 4;
279
+ for (let v = 0; v < getMeshGeometryVertexCount(geometry); v++) {
280
+ const base = v * floatsPerVertex;
281
+ expect(Math.hypot(geometry.vertices[base], geometry.vertices[base + 1], geometry.vertices[base + 2])).toBeCloseTo(
282
+ 3,
283
+ 5,
284
+ );
285
+ }
194
286
  });
195
287
  });
196
288
 
@@ -253,3 +345,234 @@ describe('createTorusMeshGeometry', () => {
253
345
  expectWindingMatchesNormals(geometry);
254
346
  });
255
347
  });
348
+
349
+ // A cone is this cylinder with a zero top radius, so its top ring collapses to the apex and one triangle
350
+ // of every side quad had two coincident corners — a third of the mesh rasterising nothing. Removing them
351
+ // is invisible on screen by construction, which is why the assertion below is about ZERO AREA rather
352
+ // than about a triangle count: the count is an implementation detail, the absence of dead geometry is
353
+ // the property.
354
+ describe('degenerate side triangles', () => {
355
+ it.each([
356
+ ['cone', () => createConeMeshGeometry(0.7, 1.4)],
357
+ ['cone with a zero height', () => createConeMeshGeometry(0.5, 0, 8)],
358
+ ['cylinder with a zero bottom radius', () => createCylinderMeshGeometry(0.5, 0, 1, 12)],
359
+ ])('emits no zero-area triangle for a %s', (_name, build) => {
360
+ const geometry = build();
361
+ const floatsPerVertex = geometry.layout.stride / 4;
362
+ const corner: MeshTriangleVertexIndices = { i0: 0, i1: 0, i2: 0 };
363
+ let triangles = 0;
364
+
365
+ for (let t = 0; t < getMeshGeometryTriangleCount(geometry); t++) {
366
+ if (!getMeshGeometryTriangleVertexIndices(corner, geometry, t)) continue;
367
+ triangles++;
368
+ const p = [corner.i0, corner.i1, corner.i2].map((i) => [
369
+ geometry.vertices[i * floatsPerVertex],
370
+ geometry.vertices[i * floatsPerVertex + 1],
371
+ geometry.vertices[i * floatsPerVertex + 2],
372
+ ]);
373
+ const e1 = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
374
+ const e2 = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
375
+ const area = Math.hypot(
376
+ e1[1] * e2[2] - e1[2] * e2[1],
377
+ e1[2] * e2[0] - e1[0] * e2[2],
378
+ e1[0] * e2[1] - e1[1] * e2[0],
379
+ );
380
+ expect(area).toBeGreaterThan(1e-12);
381
+ }
382
+
383
+ // Without this the loop could pass by finding no triangles at all.
384
+ expect(triangles).toBeGreaterThan(0);
385
+ });
386
+
387
+ // The apex keeps one vertex per segment even though they share a position: each carries its own u,
388
+ // which is what lets a texture vary around the cone. Only the dead INDICES were removed.
389
+ it('keeps the per-segment apex vertices that carry distinct uvs', () => {
390
+ const geometry = createConeMeshGeometry(0.7, 1.4, 8);
391
+ const floatsPerVertex = geometry.layout.stride / 4;
392
+
393
+ const apexUvs = new Set<number>();
394
+ for (let v = 0; v < getMeshGeometryVertexCount(geometry); v++) {
395
+ const base = v * floatsPerVertex;
396
+ const isApex = Math.hypot(geometry.vertices[base], geometry.vertices[base + 2]) <= 1e-9;
397
+ if (isApex && geometry.vertices[base + 1] > 0) apexUvs.add(geometry.vertices[base + 10]);
398
+ }
399
+
400
+ expect(apexUvs.size).toBeGreaterThan(1);
401
+ });
402
+ });
403
+
404
+ describe('mesh builder count inputs', () => {
405
+ const polyhedronVertices: ReadonlyArray<readonly [number, number, number]> = [
406
+ [0, 1, 0],
407
+ [-1, -1, 0],
408
+ [1, -1, 0],
409
+ ];
410
+ const polyhedronFaces: ReadonlyArray<readonly [number, number, number]> = [[0, 1, 2]];
411
+
412
+ it.each([
413
+ ['capsule', () => createCapsuleMeshGeometry(0.5, 1, 3.5, 1.5), () => createCapsuleMeshGeometry(0.5, 1, 3, 1)],
414
+ ['circle', () => createCircleMeshGeometry(0.5, 3.5), () => createCircleMeshGeometry(0.5, 3)],
415
+ ['cone', () => createConeMeshGeometry(0.5, 1, 3.5), () => createConeMeshGeometry(0.5, 1, 3)],
416
+ ['cylinder', () => createCylinderMeshGeometry(0.5, 0.5, 1, 3.5), () => createCylinderMeshGeometry(0.5, 0.5, 1, 3)],
417
+ ['dodecahedron', () => createDodecahedronMeshGeometry(0.5, 1.5), () => createDodecahedronMeshGeometry(0.5, 1)],
418
+ ['icosahedron', () => createIcosahedronMeshGeometry(0.5, 1.5), () => createIcosahedronMeshGeometry(0.5, 1)],
419
+ ['icosphere', () => createIcosphereMeshGeometry(0.5, 1.5), () => createIcosphereMeshGeometry(0.5, 1)],
420
+ ['octahedron', () => createOctahedronMeshGeometry(0.5, 1.5), () => createOctahedronMeshGeometry(0.5, 1)],
421
+ ['plane', () => createPlaneMeshGeometry(1, 1, 1.5, 1.5), () => createPlaneMeshGeometry(1, 1, 1, 1)],
422
+ [
423
+ 'polyhedron',
424
+ () => createPolyhedronMeshGeometry(polyhedronVertices, polyhedronFaces, 0.5, 1.5),
425
+ () => createPolyhedronMeshGeometry(polyhedronVertices, polyhedronFaces, 0.5, 1),
426
+ ],
427
+ ['ring', () => createRingMeshGeometry(0.25, 0.5, 3.5), () => createRingMeshGeometry(0.25, 0.5, 3)],
428
+ ['sphere', () => createSphereMeshGeometry(0.5, 3.5, 2.5), () => createSphereMeshGeometry(0.5, 3, 2)],
429
+ [
430
+ 'torus knot',
431
+ () => createTorusKnotMeshGeometry(0.5, 0.15, 3.5, 3.5),
432
+ () => createTorusKnotMeshGeometry(0.5, 0.15, 3, 3),
433
+ ],
434
+ ['torus', () => createTorusMeshGeometry(0.5, 0.2, 3.5, 3.5), () => createTorusMeshGeometry(0.5, 0.2, 3, 3)],
435
+ ])('floors every fractional %s count and produces a valid mesh', (_name, fractional, floored) => {
436
+ const actual = fractional();
437
+ const expected = floored();
438
+ expect(validateMeshGeometry(actual)).toBe(true);
439
+ expect(getMeshGeometryVertexCount(actual)).toBe(getMeshGeometryVertexCount(expected));
440
+ expect(getMeshGeometryIndexCount(actual)).toBe(getMeshGeometryIndexCount(expected));
441
+ });
442
+
443
+ it.each([
444
+ ['capsule radialSegments', (value: number) => createCapsuleMeshGeometry(0.5, 1, value, 1)],
445
+ ['capsule capSegments', (value: number) => createCapsuleMeshGeometry(0.5, 1, 3, value)],
446
+ ['circle segments', (value: number) => createCircleMeshGeometry(0.5, value)],
447
+ ['cone radialSegments', (value: number) => createConeMeshGeometry(0.5, 1, value)],
448
+ ['cylinder radialSegments', (value: number) => createCylinderMeshGeometry(0.5, 0.5, 1, value)],
449
+ ['dodecahedron detail', (value: number) => createDodecahedronMeshGeometry(0.5, value)],
450
+ ['icosahedron detail', (value: number) => createIcosahedronMeshGeometry(0.5, value)],
451
+ ['icosphere subdivisions', (value: number) => createIcosphereMeshGeometry(0.5, value)],
452
+ ['octahedron detail', (value: number) => createOctahedronMeshGeometry(0.5, value)],
453
+ ['plane widthSegments', (value: number) => createPlaneMeshGeometry(1, 1, value, 1)],
454
+ ['plane depthSegments', (value: number) => createPlaneMeshGeometry(1, 1, 1, value)],
455
+ [
456
+ 'polyhedron detail',
457
+ (value: number) => createPolyhedronMeshGeometry(polyhedronVertices, polyhedronFaces, 0.5, value),
458
+ ],
459
+ ['ring segments', (value: number) => createRingMeshGeometry(0.25, 0.5, value)],
460
+ ['sphere widthSegments', (value: number) => createSphereMeshGeometry(0.5, value, 2)],
461
+ ['sphere heightSegments', (value: number) => createSphereMeshGeometry(0.5, 3, value)],
462
+ ['tetrahedron detail', (value: number) => createTetrahedronMeshGeometry(0.5, value)],
463
+ ['torus knot tubularSegments', (value: number) => createTorusKnotMeshGeometry(0.5, 0.15, value, 3)],
464
+ ['torus knot radialSegments', (value: number) => createTorusKnotMeshGeometry(0.5, 0.15, 3, value)],
465
+ ['torus radialSegments', (value: number) => createTorusMeshGeometry(0.5, 0.2, value, 3)],
466
+ ['torus tubularSegments', (value: number) => createTorusMeshGeometry(0.5, 0.2, 3, value)],
467
+ ])('rejects non-finite %s', (_name, build) => {
468
+ expect(() => build(Number.NaN)).toThrow(RangeError);
469
+ expect(() => build(Number.POSITIVE_INFINITY)).toThrow(RangeError);
470
+ });
471
+ });
472
+
473
+ // Longitude wraps at u = 0/1, so a face straddling the seam gets corners like 0.99 and 0.01 and
474
+ // interpolates BACKWARDS across the whole texture — the entire map crushed into one triangle. The
475
+ // unmistakable signature is a single face holding a corner near 0 AND a corner near 1; after the
476
+ // correction lifts the low corners past 1, no face carries both.
477
+ //
478
+ // Emitting per-face vertices is what makes the correction POSSIBLE (a shared vertex could only hold one
479
+ // u) but it does not perform it, and for a long time only the first half was here.
480
+ describe('spherical UV seam', () => {
481
+ it.each([
482
+ ['icosahedron', () => createIcosahedronMeshGeometry()],
483
+ ['dodecahedron', () => createDodecahedronMeshGeometry()],
484
+ ['octahedron', () => createOctahedronMeshGeometry()],
485
+ ['tetrahedron', () => createTetrahedronMeshGeometry()],
486
+ ['icosphere', () => createIcosphereMeshGeometry()],
487
+ ])('gives no %s face corners at both ends of the longitude range', (_name, build) => {
488
+ const geometry = build();
489
+ const floatsPerVertex = geometry.layout.stride / 4;
490
+ const corner: MeshTriangleVertexIndices = { i0: 0, i1: 0, i2: 0 };
491
+
492
+ for (let t = 0; t < getMeshGeometryTriangleCount(geometry); t++) {
493
+ if (!getMeshGeometryTriangleVertexIndices(corner, geometry, t)) continue;
494
+ const us = [corner.i0, corner.i1, corner.i2].map((i) => geometry.vertices[i * floatsPerVertex + 10]);
495
+ const wrapped = Math.min(...us) < 0.1 && Math.max(...us) > 0.9;
496
+ expect(wrapped).toBe(false);
497
+ }
498
+ });
499
+
500
+ // A pole vertex sits on the Y axis, where every meridian meets, so atan2 hands it an arbitrary
501
+ // longitude unrelated to the face it belongs to — u = 0.5 while its face-mates sit near 1.0 and 1.25,
502
+ // stretching the face across half the texture. Having no longitude of its own to preserve, it takes
503
+ // the average of its face-mates and therefore must lie BETWEEN them.
504
+ it.each([
505
+ ['octahedron', () => createOctahedronMeshGeometry()],
506
+ ['icosphere', () => createIcosphereMeshGeometry()],
507
+ ])('places a %s pole vertex between its face-mates in longitude', (_name, build) => {
508
+ const geometry = build();
509
+ const floatsPerVertex = geometry.layout.stride / 4;
510
+ const corner: MeshTriangleVertexIndices = { i0: 0, i1: 0, i2: 0 };
511
+ let polesSeen = 0;
512
+
513
+ for (let t = 0; t < getMeshGeometryTriangleCount(geometry); t++) {
514
+ if (!getMeshGeometryTriangleVertexIndices(corner, geometry, t)) continue;
515
+ const indices = [corner.i0, corner.i1, corner.i2];
516
+ const horizontalRadius = indices.map((i) =>
517
+ Math.hypot(geometry.vertices[i * floatsPerVertex], geometry.vertices[i * floatsPerVertex + 2]),
518
+ );
519
+ const poleSlot = horizontalRadius.findIndex((r) => r <= 1e-6);
520
+ if (poleSlot < 0) continue;
521
+ polesSeen++;
522
+ const us = indices.map((i) => geometry.vertices[i * floatsPerVertex + 10]);
523
+ const mates = us.filter((_, k) => k !== poleSlot);
524
+ expect(us[poleSlot]).toBeGreaterThanOrEqual(Math.min(...mates));
525
+ expect(us[poleSlot]).toBeLessThanOrEqual(Math.max(...mates));
526
+ }
527
+
528
+ // The fixture has to contain pole faces or the assertions above never run.
529
+ expect(polesSeen).toBeGreaterThan(0);
530
+ });
531
+
532
+ // WHAT IS ALLOWED TO REMAIN, stated as a criterion rather than a count. A handful of faces still span
533
+ // more than half the longitude range, and that is not a missed correction: on a 20-face solid a single
534
+ // face genuinely subtends that much longitude, and no UV assignment can make it narrow. What WOULD be
535
+ // a missed correction is such a face containing a POLE corner, because a pole has no longitude of its
536
+ // own and must take its face-mates'.
537
+ //
538
+ // Asserting the criterion rather than "there are exactly 4" is deliberate: a count has to be edited
539
+ // whenever anyone changes a segment count or subdivision level, and a check that always prints the
540
+ // same non-zero number teaches everyone to scroll past it — and then it cannot warn about the fifth.
541
+ // This phrasing stays exactly as strict when the geometry changes.
542
+ it.each([
543
+ ['dodecahedron', () => createDodecahedronMeshGeometry()],
544
+ ['icosahedron', () => createIcosahedronMeshGeometry()],
545
+ ['icosphere', () => createIcosphereMeshGeometry()],
546
+ ['octahedron', () => createOctahedronMeshGeometry()],
547
+ ['tetrahedron', () => createTetrahedronMeshGeometry()],
548
+ ])('leaves no %s wide face carrying a pole corner', (_name, build) => {
549
+ const geometry = build();
550
+ const floatsPerVertex = geometry.layout.stride / 4;
551
+ const corner: MeshTriangleVertexIndices = { i0: 0, i1: 0, i2: 0 };
552
+
553
+ for (let t = 0; t < getMeshGeometryTriangleCount(geometry); t++) {
554
+ if (!getMeshGeometryTriangleVertexIndices(corner, geometry, t)) continue;
555
+ const indices = [corner.i0, corner.i1, corner.i2];
556
+ const us = indices.map((i) => geometry.vertices[i * floatsPerVertex + 10]);
557
+ if (Math.max(...us) - Math.min(...us) <= 0.5) continue;
558
+ const hasPoleCorner = indices.some(
559
+ (i) => Math.hypot(geometry.vertices[i * floatsPerVertex], geometry.vertices[i * floatsPerVertex + 2]) <= 1e-6,
560
+ );
561
+ expect(hasPoleCorner).toBe(false);
562
+ }
563
+ });
564
+
565
+ // A corrected face legitimately carries u above 1: no parameterisation confined to 0..1 can express
566
+ // a face crossing the seam continuously. Sampling wraps it, so this is the intended state, not drift.
567
+ it('lifts a seam face past 1 rather than clamping it into range', () => {
568
+ const geometry = createIcosahedronMeshGeometry();
569
+ const floatsPerVertex = geometry.layout.stride / 4;
570
+
571
+ let maxU = -Infinity;
572
+ for (let v = 0; v < getMeshGeometryVertexCount(geometry); v++) {
573
+ maxU = Math.max(maxU, geometry.vertices[v * floatsPerVertex + 10]);
574
+ }
575
+
576
+ expect(maxU).toBeGreaterThan(1);
577
+ });
578
+ });