@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,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)).
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
});
|