@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,13 +1,12 @@
1
1
  import { createMeshGeometry } from './meshGeometry';
2
- import { refreshMeshGeometryBounds } from './meshGeometryCompute';
2
+ import { computeMeshGeometryTangents, refreshMeshGeometryBounds } from './meshGeometryCompute';
3
3
  // Primitive builders for the canonical interleaved PBR vertex record:
4
4
  // position(3) + normal(3) + tangent(4, w = handedness) + uv0(2) = 12 f32 / 48 bytes.
5
5
  // Coordinate and winding convention is pinned across the 3D suite: RIGHT-HANDED coordinates,
6
6
  // COUNTER-CLOCKWISE (CCW) front faces, and the tangent `w` component is the bitangent sign per
7
7
  // glTF — bitangent = cross(normal, tangent.xyz) * tangent.w. Every builder writes outward-facing
8
- // normals, generates UVs, computes per-vertex tangents, and fills the cached local-space bounds.
9
- // Builder topology already duplicates every UV discontinuity, so its compact tangent finalizer does
10
- // not need the general authoring API's topology reconciliation.
8
+ // normals, generates UVs, computes per-vertex tangents (via computeMeshGeometryTangents, which
9
+ // sets the correct w-sign from the UV gradient), and fills the cached local-space bounds.
11
10
  // Builds an axis-aligned box of the given dimensions centered at the origin, one quad per face
12
11
  // with per-face outward normals and per-face 0..1 UVs (so each face textures independently).
13
12
  export function createBoxMeshGeometry(width = 1, height = 1, depth = 1) {
@@ -41,9 +40,10 @@ export function createBoxMeshGeometry(width = 1, height = 1, depth = 1) {
41
40
  // (excluding the caps) centered at the origin, with `radialSegments` around the axis and
42
41
  // `capSegments` latitudinal divisions per hemisphere. Normals are smooth across the seam between
43
42
  // the caps and the side wall. UVs run v from 0 (top) to 1 (bottom) along the full length.
43
+ // Segment counts must be finite; fractional values are floored after clamping to their minima.
44
44
  export function createCapsuleMeshGeometry(radius = 0.5, height = 1, radialSegments = 16, capSegments = 8) {
45
- const rSeg = Math.max(3, radialSegments);
46
- const cSeg = Math.max(1, capSegments);
45
+ const rSeg = normalizeMeshCount(radialSegments, 3);
46
+ const cSeg = normalizeMeshCount(capSegments, 1);
47
47
  const halfH = height * 0.5;
48
48
  const positions = [];
49
49
  const normals = [];
@@ -63,17 +63,20 @@ export function createCapsuleMeshGeometry(radius = 0.5, height = 1, radialSegmen
63
63
  }
64
64
  };
65
65
  const ringVertexCount = rSeg + 1;
66
- const vDivisor = 2 * cSeg + 1; // total number of rings minus 1
66
+ const ringCount = 2 * cSeg + 2;
67
+ const vDivisor = ringCount - 1;
67
68
  // Top hemisphere (phi from 0 to PI/2).
68
69
  for (let j = 0; j <= cSeg; j++) {
69
70
  addRing((j / cSeg) * (Math.PI * 0.5), halfH);
70
71
  }
72
+ // Duplicate the equator at the bottom of the cylindrical span. The two equators share radial
73
+ // normals but need distinct positions (and UV rows) to form the side wall between the caps.
74
+ addRing(Math.PI * 0.5, -halfH);
71
75
  // Bottom hemisphere (phi from PI/2 to PI).
72
76
  for (let j = 1; j <= cSeg; j++) {
73
77
  addRing(Math.PI * 0.5 + (j / cSeg) * (Math.PI * 0.5), -halfH);
74
78
  }
75
79
  // Fix up the v UV coordinate.
76
- const ringCount = 2 * cSeg + 1;
77
80
  for (let j = 0; j < ringCount; j++) {
78
81
  const v = j / vDivisor;
79
82
  for (let i = 0; i <= rSeg; i++) {
@@ -81,14 +84,15 @@ export function createCapsuleMeshGeometry(radius = 0.5, height = 1, radialSegmen
81
84
  }
82
85
  }
83
86
  // Connect rings into quads.
84
- const totalRings = 2 * cSeg + 1;
85
- for (let j = 0; j < totalRings - 1; j++) {
87
+ for (let j = 0; j < ringCount - 1; j++) {
86
88
  for (let i = 0; i < rSeg; i++) {
87
89
  const a = j * ringVertexCount + i;
88
90
  const b = a + 1;
89
91
  const c = a + ringVertexCount;
90
92
  const d = c + 1;
91
- indices.push(a, c, b, b, c, d);
93
+ // Reversed against the sphere's identical-looking line on purpose: this builder walks its
94
+ // rings the opposite way round, so the same index order faces the other way.
95
+ indices.push(a, b, c, b, d, c);
92
96
  }
93
97
  }
94
98
  return buildCanonicalMeshGeometry(positions, normals, uvs, indices);
@@ -97,8 +101,9 @@ export function createCapsuleMeshGeometry(radius = 0.5, height = 1, radialSegmen
97
101
  // `radius` is the outer radius; `segments` is the number of rim vertices. A center vertex fans
98
102
  // out to `segments` rim vertices via a triangle fan, so the result is `segments` triangles.
99
103
  // UVs map from the disc plane onto 0..1 with (0.5, 0.5) at the center.
104
+ // `segments` must be finite; a fractional value is floored after clamping to the minimum of 3.
100
105
  export function createCircleMeshGeometry(radius = 0.5, segments = 32) {
101
- const segs = Math.max(3, segments);
106
+ const segs = normalizeMeshCount(segments, 3);
102
107
  const positions = [];
103
108
  const normals = [];
104
109
  const uvs = [];
@@ -109,6 +114,7 @@ export function createCircleMeshGeometry(radius = 0.5, segments = 32) {
109
114
  // Builds a right circular cone of `radius` and `height` centered at the origin, apex at +Y, base
110
115
  // at -Y, with `radialSegments` around the axis. A capped base disc is included when `capped` is
111
116
  // true (default). Side normals are smooth around the ring and slanted by the cone half-angle.
117
+ // `radialSegments` must be finite; a fractional value is floored after clamping to 3.
112
118
  export function createConeMeshGeometry(radius = 0.5, height = 1, radialSegments = 32, capped = true) {
113
119
  return createCylinderMeshGeometry(0, radius, height, radialSegments, capped);
114
120
  }
@@ -116,15 +122,17 @@ export function createConeMeshGeometry(radius = 0.5, height = 1, radialSegments
116
122
  // top (`topRadius`) and bottom (`bottomRadius`) radii so it also serves as a truncated cone (a
117
123
  // zero top radius yields a cone). `radialSegments` rings around the axis; `capped` adds top and
118
124
  // bottom discs (skipping a zero-radius cap).
125
+ // `radialSegments` must be finite; a fractional value is floored after clamping to 3. A zero
126
+ // height is a valid collapsed cylinder and uses stable radial side normals.
119
127
  export function createCylinderMeshGeometry(topRadius = 0.5, bottomRadius = 0.5, height = 1, radialSegments = 32, capped = true) {
120
- const segments = Math.max(3, radialSegments);
128
+ const segments = normalizeMeshCount(radialSegments, 3);
121
129
  const halfHeight = height * 0.5;
122
130
  const positions = [];
123
131
  const normals = [];
124
132
  const uvs = [];
125
133
  const indices = [];
126
134
  // Side wall: slope of the silhouette feeds the radial normal tilt.
127
- const slope = (bottomRadius - topRadius) / height;
135
+ const slope = height !== 0 ? (bottomRadius - topRadius) / height : 0;
128
136
  const sideStart = positions.length / 3;
129
137
  for (let y = 0; y <= 1; y++) {
130
138
  const radius = y === 0 ? bottomRadius : topRadius;
@@ -143,12 +151,24 @@ export function createCylinderMeshGeometry(topRadius = 0.5, bottomRadius = 0.5,
143
151
  uvs.push(s / segments, y);
144
152
  }
145
153
  }
154
+ // Each segment is a quad split into (a, c, b) and (b, c, d). When one radius is zero that ring
155
+ // collapses to a single point, so one of the two triangles has two coincident corners and encloses no
156
+ // area: it consumes index bandwidth and a vertex-shader invocation to rasterise nothing. A cone is
157
+ // exactly that case — it is this cylinder with a zero top radius — so a third of its triangles were
158
+ // dead. Dropping the collapsed triangle leaves the surviving fan untouched, vertices included: the
159
+ // apex ring keeps one vertex per segment because each carries its own u, which is what lets the
160
+ // texture vary around the cone.
161
+ const topCollapsed = topRadius === 0;
162
+ const bottomCollapsed = bottomRadius === 0;
146
163
  for (let s = 0; s < segments; s++) {
147
164
  const a = sideStart + s;
148
165
  const b = sideStart + s + 1;
149
166
  const c = sideStart + (segments + 1) + s;
150
167
  const d = sideStart + (segments + 1) + s + 1;
151
- indices.push(a, c, b, b, c, d);
168
+ if (!bottomCollapsed)
169
+ indices.push(a, c, b);
170
+ if (!topCollapsed)
171
+ indices.push(b, c, d);
152
172
  }
153
173
  if (capped) {
154
174
  if (bottomRadius > 0) {
@@ -161,11 +181,13 @@ export function createCylinderMeshGeometry(topRadius = 0.5, bottomRadius = 0.5,
161
181
  return buildCanonicalMeshGeometry(positions, normals, uvs, indices);
162
182
  }
163
183
  // Builds a regular dodecahedron (12 pentagonal faces, triangulated) inscribed in a sphere of `radius`.
184
+ // `detail` must be finite; a fractional value is floored after clamping to 0..5.
164
185
  export function createDodecahedronMeshGeometry(radius = 0.5, detail = 0) {
165
186
  return createPolyhedronMeshGeometry(DODECAHEDRON_VERTS, DODECAHEDRON_FACES, radius, detail);
166
187
  }
167
188
  // Builds a regular icosahedron (20 triangular faces) inscribed in a sphere of `radius`.
168
189
  // This is the base shape for createIcosphereMeshGeometry at detail=0.
190
+ // `detail` must be finite; a fractional value is floored after clamping to 0..5.
169
191
  export function createIcosahedronMeshGeometry(radius = 0.5, detail = 0) {
170
192
  return createPolyhedronMeshGeometry(ICOSAHEDRON_VERTS, ICOSAHEDRON_FACES, radius, detail);
171
193
  }
@@ -174,8 +196,9 @@ export function createIcosahedronMeshGeometry(radius = 0.5, detail = 0) {
174
196
  // from a regular icosahedron, each face is subdivided `subdivisions` times (0 = raw icosahedron,
175
197
  // 1 = 80 triangles, 2 = 320, etc.). Normals are the unit-sphere position direction; UVs are
176
198
  // spherical (longitude/latitude).
199
+ // `subdivisions` must be finite; a fractional value is floored after clamping to 0..6.
177
200
  export function createIcosphereMeshGeometry(radius = 0.5, subdivisions = 2) {
178
- const subs = Math.max(0, Math.min(subdivisions, 6)); // cap at 6 to prevent > 262k triangles
201
+ const subs = normalizeMeshCount(subdivisions, 0, 6); // cap at 6 to prevent > 262k triangles
179
202
  // Icosahedron base vertices (unit sphere).
180
203
  const phi = (1 + Math.sqrt(5)) * 0.5;
181
204
  const scale = 1 / Math.sqrt(1 + phi * phi);
@@ -250,18 +273,18 @@ export function createIcosphereMeshGeometry(radius = 0.5, subdivisions = 2) {
250
273
  const normals = [];
251
274
  const uvs = [];
252
275
  const faceIndices = [];
253
- // Emit unique vertices with spherical UVs. Since the seam is complex for an icosphere,
254
- // we emit independent vertices per face (non-indexed, then buildCanonicalMeshGeometry).
276
+ // Emit unique vertices with spherical UVs. Independent vertices per face (non-indexed, then
277
+ // buildCanonicalMeshGeometry) are what let a seam vertex carry a different u on each face it touches;
278
+ // correctSeamU then chooses those values, which is the half that makes the seam actually work.
255
279
  for (const [a, b, c] of faces) {
256
- for (const vi of [a, b, c]) {
257
- const v = verts[vi];
280
+ const corner = [a, b, c];
281
+ const seamU = faceSphericalU(verts[a], verts[b], verts[c]);
282
+ for (let k = 0; k < 3; k++) {
283
+ const v = verts[corner[k]];
258
284
  const nx = v[0], ny = v[1], nz = v[2];
259
285
  positions.push(radius * nx, radius * ny, radius * nz);
260
286
  normals.push(nx, ny, nz);
261
- // Spherical UV: u=longitude (0..1), v=latitude (0..1, 0=top).
262
- const u = 0.5 + Math.atan2(nz, nx) / (Math.PI * 2);
263
- const sv = 0.5 - Math.asin(Math.max(-1, Math.min(1, ny))) / Math.PI;
264
- uvs.push(u, sv);
287
+ uvs.push(seamU[k], sphericalV(ny));
265
288
  }
266
289
  }
267
290
  for (let i = 0; i < positions.length / 3; i++) {
@@ -270,15 +293,17 @@ export function createIcosphereMeshGeometry(radius = 0.5, subdivisions = 2) {
270
293
  return buildCanonicalMeshGeometry(positions, normals, uvs, faceIndices);
271
294
  }
272
295
  // Builds a regular octahedron (8 triangular faces) inscribed in a sphere of `radius`.
296
+ // `detail` must be finite; a fractional value is floored after clamping to 0..5.
273
297
  export function createOctahedronMeshGeometry(radius = 0.5, detail = 0) {
274
298
  return createPolyhedronMeshGeometry(OCTAHEDRON_VERTS, OCTAHEDRON_FACES, radius, detail);
275
299
  }
276
300
  // Builds a flat plane in the XZ plane (Y up, normal +Y) centered at the origin, subdivided into
277
301
  // `widthSegments` x `depthSegments` quads with a 0..1 UV grid. Suitable as a ground plane or a
278
302
  // textured "panel" mesh.
303
+ // Segment counts must be finite; fractional values are floored after clamping to 1.
279
304
  export function createPlaneMeshGeometry(width = 1, depth = 1, widthSegments = 1, depthSegments = 1) {
280
- const wSeg = Math.max(1, widthSegments);
281
- const dSeg = Math.max(1, depthSegments);
305
+ const wSeg = normalizeMeshCount(widthSegments, 1);
306
+ const dSeg = normalizeMeshCount(depthSegments, 1);
282
307
  const hw = width * 0.5, hd = depth * 0.5;
283
308
  const positions = [];
284
309
  const normals = [];
@@ -313,8 +338,9 @@ export function createPlaneMeshGeometry(width = 1, depth = 1, widthSegments = 1,
313
338
  // This is the shared backend for the Platonic solid builders and is also exported for custom
314
339
  // polyhedra. Vertices are projected onto the unit sphere before scaling so the output is a
315
340
  // geodesic sphere approximation.
341
+ // `detail` must be finite; a fractional value is floored after clamping to 0..5.
316
342
  export function createPolyhedronMeshGeometry(vertexPositions, faceIndices, radius = 0.5, detail = 0) {
317
- const subs = Math.max(0, Math.min(detail, 5));
343
+ const subs = normalizeMeshCount(detail, 0, 5);
318
344
  const verts = vertexPositions.map(([x, y, z]) => {
319
345
  const len = Math.sqrt(x * x + y * y + z * z);
320
346
  return [x / len, y / len, z / len];
@@ -353,14 +379,14 @@ export function createPolyhedronMeshGeometry(vertexPositions, faceIndices, radiu
353
379
  const uvs = [];
354
380
  const flatIndices = [];
355
381
  for (const [a, b, c] of faces) {
356
- for (const vi of [a, b, c]) {
357
- const v = verts[vi];
382
+ const corner = [a, b, c];
383
+ const seamU = faceSphericalU(verts[a], verts[b], verts[c]);
384
+ for (let k = 0; k < 3; k++) {
385
+ const v = verts[corner[k]];
358
386
  const nx = v[0], ny = v[1], nz = v[2];
359
387
  positions.push(radius * nx, radius * ny, radius * nz);
360
388
  normals.push(nx, ny, nz);
361
- const u = 0.5 + Math.atan2(nz, nx) / (Math.PI * 2);
362
- const sv = 0.5 - Math.asin(Math.max(-1, Math.min(1, ny))) / Math.PI;
363
- uvs.push(u, sv);
389
+ uvs.push(seamU[k], sphericalV(ny));
364
390
  }
365
391
  }
366
392
  for (let i = 0; i < positions.length / 3; i++)
@@ -381,8 +407,9 @@ export function createQuadMeshGeometry(width = 1, height = 1) {
381
407
  // Builds a flat ring (annulus) in the XZ plane centered at the origin. `innerRadius` is the
382
408
  // hole radius and `outerRadius` is the ring's outer edge. Normal is +Y. `segments` is the
383
409
  // number of vertices around the ring. UVs map inner radius to u=0 and outer to u=1.
410
+ // `segments` must be finite; a fractional value is floored after clamping to 3.
384
411
  export function createRingMeshGeometry(innerRadius = 0.25, outerRadius = 0.5, segments = 32) {
385
- const segs = Math.max(3, segments);
412
+ const segs = normalizeMeshCount(segments, 3);
386
413
  const positions = [];
387
414
  const normals = [];
388
415
  const uvs = [];
@@ -412,9 +439,10 @@ export function createRingMeshGeometry(innerRadius = 0.25, outerRadius = 0.5, se
412
439
  // Builds a UV sphere of `radius` centered at the origin, with `widthSegments` longitudinal and
413
440
  // `heightSegments` latitudinal divisions. Normals are the unit position direction; UVs map
414
441
  // longitude to u and latitude to v.
442
+ // Segment counts must be finite; fractional values are floored after clamping to 3x2.
415
443
  export function createSphereMeshGeometry(radius = 0.5, widthSegments = 32, heightSegments = 16) {
416
- const wSeg = Math.max(3, widthSegments);
417
- const hSeg = Math.max(2, heightSegments);
444
+ const wSeg = normalizeMeshCount(widthSegments, 3);
445
+ const hSeg = normalizeMeshCount(heightSegments, 2);
418
446
  const positions = [];
419
447
  const normals = [];
420
448
  const uvs = [];
@@ -448,15 +476,17 @@ export function createSphereMeshGeometry(radius = 0.5, widthSegments = 32, heigh
448
476
  return buildCanonicalMeshGeometry(positions, normals, uvs, indices);
449
477
  }
450
478
  // Builds a regular tetrahedron (4 triangular faces) inscribed in a sphere of `radius`.
479
+ // `detail` must be finite; a fractional value is floored after clamping to 0..5.
451
480
  export function createTetrahedronMeshGeometry(radius = 0.5, detail = 0) {
452
481
  return createPolyhedronMeshGeometry(TETRAHEDRON_VERTS, TETRAHEDRON_FACES, radius, detail);
453
482
  }
454
483
  // Builds a torus knot: a torus-shaped curve wound `p` times around the Z axis and `q` times
455
484
  // around the tube axis. `radius` is the distance from the center to the tube center line;
456
485
  // `tube` is the tube radius. Normals and UVs are generated per the standard torus knot geometry.
486
+ // Segment counts must be finite; fractional values are floored after clamping to 3.
457
487
  export function createTorusKnotMeshGeometry(radius = 0.5, tube = 0.15, tubularSegments = 64, radialSegments = 8, p = 2, q = 3) {
458
- const tSeg = Math.max(3, tubularSegments);
459
- const rSeg = Math.max(3, radialSegments);
488
+ const tSeg = normalizeMeshCount(tubularSegments, 3);
489
+ const rSeg = normalizeMeshCount(radialSegments, 3);
460
490
  const positions = [];
461
491
  const normals = [];
462
492
  const uvs = [];
@@ -517,7 +547,9 @@ export function createTorusKnotMeshGeometry(radius = 0.5, tube = 0.15, tubularSe
517
547
  const b = a + 1;
518
548
  const c = a + rowStride;
519
549
  const d = c + 1;
520
- indices.push(a, c, b, b, c, d);
550
+ // Reversed for the same reason as the capsule: this builder sweeps its tube rings the
551
+ // opposite way round from the sphere, so the same index order faced inward.
552
+ indices.push(a, b, c, b, d, c);
521
553
  }
522
554
  }
523
555
  return buildCanonicalMeshGeometry(positions, normals, uvs, indices);
@@ -525,9 +557,10 @@ export function createTorusKnotMeshGeometry(radius = 0.5, tube = 0.15, tubularSe
525
557
  // Builds a torus in the XY plane: a ring of `radius` (center to tube center) with a tube of
526
558
  // `tube` radius, divided into `radialSegments` around the ring and `tubularSegments` around the
527
559
  // tube. Normals point radially outward from the tube center.
560
+ // Segment counts must be finite; fractional values are floored after clamping to 3.
528
561
  export function createTorusMeshGeometry(radius = 0.5, tube = 0.2, radialSegments = 24, tubularSegments = 48) {
529
- const rSeg = Math.max(3, radialSegments);
530
- const tSeg = Math.max(3, tubularSegments);
562
+ const rSeg = normalizeMeshCount(radialSegments, 3);
563
+ const tSeg = normalizeMeshCount(tubularSegments, 3);
531
564
  const positions = [];
532
565
  const normals = [];
533
566
  const uvs = [];
@@ -584,17 +617,81 @@ function addDisc(positions, normals, uvs, indices, segments, radius, y, directio
584
617
  for (let s = 0; s < segments; s++) {
585
618
  const a = ringStart + s;
586
619
  const b = ringStart + s + 1;
620
+ // Right-hand rule over the winding: for an up-facing disc the ring must be walked b-then-a to
621
+ // put (p1 - p0) x (p2 - p0) along +Y, which is the direction the authored normals already point.
587
622
  if (direction > 0) {
588
- indices.push(center, a, b);
623
+ indices.push(center, b, a);
589
624
  }
590
625
  else {
591
- indices.push(center, b, a);
626
+ indices.push(center, a, b);
592
627
  }
593
628
  }
594
629
  }
630
+ function normalizeMeshCount(value, min, max = Number.POSITIVE_INFINITY) {
631
+ if (!Number.isFinite(value))
632
+ throw new RangeError('mesh segment and subdivision counts must be finite');
633
+ return Math.floor(Math.max(min, Math.min(value, max)));
634
+ }
595
635
  // Interleaves separate position/normal/uv0 arrays into the canonical 12-float record, allocates
596
636
  // the MeshGeometry (indices auto-promote past 65k), then fills tangents from the UV gradient and
597
637
  // the cached local bounds. The single finalize path every builder funnels through.
638
+ // Spherical UV for one direction on the unit sphere: u is longitude in 0..1, v is latitude with 0 at
639
+ // the top. Shared by every builder that projects onto a sphere so the two call sites cannot drift.
640
+ function sphericalU(nx, nz) {
641
+ return 0.5 + Math.atan2(nz, nx) / (Math.PI * 2);
642
+ }
643
+ function sphericalV(ny) {
644
+ return 0.5 - Math.asin(Math.max(-1, Math.min(1, ny))) / Math.PI;
645
+ }
646
+ // ★ THE SPHERICAL-UV BUILDERS EXPECT A REPEATING ADDRESSING MODE. Every builder that routes through
647
+ // faceSphericalU — icosphere, and the polyhedra (dodecahedron, icosahedron, octahedron, tetrahedron)
648
+ // via createPolyhedronMeshGeometry — emits u > 1 on the faces that cross the longitude seam. That is a
649
+ // property of a wrapped parameterisation, not an accident: no assignment confined to 0..1 can describe
650
+ // a face crossing the seam continuously. Sampled with a REPEAT wrap those values land where they should;
651
+ // sampled with clamp-to-edge, the part past 1 collapses onto the edge texel. Flight's default sampler is
652
+ // clamp-to-edge (texture/src/sampler.ts) and createTilingSampler is the repeating one, so a texture
653
+ // applied to these solids wants the latter.
654
+ //
655
+ // The three corner longitudes of one face, with both spherical-mapping degeneracies resolved.
656
+ //
657
+ // SEAM: longitude wraps at u = 0/1, so a face with corners either side gets values like 0.99 and 0.01
658
+ // and interpolates BACKWARDS across the whole texture — the entire map crushed into one triangle.
659
+ // Lifting the low corners past 1 makes the face interpolate forward through the seam instead. A seam
660
+ // face therefore carries u > 1 BY DESIGN: no parameterisation confined to 0..1 can express a face
661
+ // crossing the seam continuously, and sampling wraps it.
662
+ //
663
+ // POLE: a vertex on the Y axis has zero horizontal radius, so atan2 gives it an arbitrary longitude
664
+ // (0, hence u = 0.5) unrelated to the face it belongs to. It has no longitude of its own to preserve —
665
+ // every meridian meets there — so it takes the average of its face-mates and the face stays narrow.
666
+ //
667
+ // Order matters: the face-mates are seam-corrected FIRST, then the pole averages the corrected values.
668
+ // Averaging raw 0.99 and 0.01 would put the pole at 0.5, on the far side of the sphere from both.
669
+ //
670
+ // Emitting per-face vertices is what makes any of this possible — a shared vertex could only carry one
671
+ // u — but it is not sufficient on its own: the values still have to be chosen, which is the half that
672
+ // was missing.
673
+ function faceSphericalU(a, b, c) {
674
+ const directions = [a, b, c];
675
+ const isPole = directions.map((d) => Math.hypot(d[0], d[2]) <= POLE_EPSILON);
676
+ const u = [sphericalU(a[0], a[2]), sphericalU(b[0], b[2]), sphericalU(c[0], c[2])];
677
+ const defined = [0, 1, 2].filter((i) => !isPole[i]);
678
+ if (defined.length > 0) {
679
+ const min = Math.min(...defined.map((i) => u[i]));
680
+ const max = Math.max(...defined.map((i) => u[i]));
681
+ if (max - min > 0.5) {
682
+ for (const i of defined)
683
+ if (u[i] < 0.5)
684
+ u[i] += 1;
685
+ }
686
+ const mean = defined.reduce((sum, i) => sum + u[i], 0) / defined.length;
687
+ for (let i = 0; i < 3; i++)
688
+ if (isPole[i])
689
+ u[i] = mean;
690
+ }
691
+ return u;
692
+ }
693
+ // A direction within this of the Y axis has no meaningful longitude: every meridian meets at a pole.
694
+ const POLE_EPSILON = 1e-6;
598
695
  function buildCanonicalMeshGeometry(positions, normals, uvs, indices) {
599
696
  const vertexCount = positions.length / 3;
600
697
  const vertices = new Float32Array(vertexCount * CANONICAL_FLOATS_PER_VERTEX);
@@ -617,92 +714,10 @@ function buildCanonicalMeshGeometry(positions, normals, uvs, indices) {
617
714
  layout: CANONICAL_VERTEX_LAYOUT,
618
715
  vertices: vertices,
619
716
  });
620
- computeCanonicalMeshTangents(vertices, indexArray);
717
+ computeMeshGeometryTangents(geometry, geometry);
621
718
  refreshMeshGeometryBounds(geometry);
622
719
  return geometry;
623
720
  }
624
- // Primitive builders own their topology and already emit separate records at normal and UV seams.
625
- // Keeping this trusted-topology path local avoids pulling the general, topology-repairing tangent
626
- // authoring routine into applications that only create Flight primitives.
627
- function computeCanonicalMeshTangents(vertices, indices) {
628
- const vertexCount = vertices.length / CANONICAL_FLOATS_PER_VERTEX;
629
- const tangents = new Float32Array(vertexCount * 3);
630
- const bitangents = new Float32Array(vertexCount * 3);
631
- for (let element = 0; element + 2 < indices.length; element += 3) {
632
- const i0 = indices[element];
633
- const i1 = indices[element + 1];
634
- const i2 = indices[element + 2];
635
- const p0 = i0 * CANONICAL_FLOATS_PER_VERTEX;
636
- const p1 = i1 * CANONICAL_FLOATS_PER_VERTEX;
637
- const p2 = i2 * CANONICAL_FLOATS_PER_VERTEX;
638
- const x1 = vertices[p1] - vertices[p0];
639
- const y1 = vertices[p1 + 1] - vertices[p0 + 1];
640
- const z1 = vertices[p1 + 2] - vertices[p0 + 2];
641
- const x2 = vertices[p2] - vertices[p0];
642
- const y2 = vertices[p2 + 1] - vertices[p0 + 1];
643
- const z2 = vertices[p2 + 2] - vertices[p0 + 2];
644
- const du1 = vertices[p1 + 10] - vertices[p0 + 10];
645
- const dv1 = vertices[p1 + 11] - vertices[p0 + 11];
646
- const du2 = vertices[p2 + 10] - vertices[p0 + 10];
647
- const dv2 = vertices[p2 + 11] - vertices[p0 + 11];
648
- const determinant = du1 * dv2 - du2 * dv1;
649
- const reciprocal = determinant === 0 ? 0 : 1 / determinant;
650
- const tx = (dv2 * x1 - dv1 * x2) * reciprocal;
651
- const ty = (dv2 * y1 - dv1 * y2) * reciprocal;
652
- const tz = (dv2 * z1 - dv1 * z2) * reciprocal;
653
- const bx = (du1 * x2 - du2 * x1) * reciprocal;
654
- const by = (du1 * y2 - du2 * y1) * reciprocal;
655
- const bz = (du1 * z2 - du2 * z1) * reciprocal;
656
- for (let corner = 0; corner < 3; corner++) {
657
- const vertex = corner === 0 ? i0 : corner === 1 ? i1 : i2;
658
- const tangent = vertex * 3;
659
- tangents[tangent] += tx;
660
- tangents[tangent + 1] += ty;
661
- tangents[tangent + 2] += tz;
662
- bitangents[tangent] += bx;
663
- bitangents[tangent + 1] += by;
664
- bitangents[tangent + 2] += bz;
665
- }
666
- }
667
- for (let vertex = 0; vertex < vertexCount; vertex++) {
668
- const base = vertex * CANONICAL_FLOATS_PER_VERTEX;
669
- const tangent = vertex * 3;
670
- const nx = vertices[base + 3];
671
- const ny = vertices[base + 4];
672
- const nz = vertices[base + 5];
673
- const normalDot = nx * tangents[tangent] + ny * tangents[tangent + 1] + nz * tangents[tangent + 2];
674
- let tx = tangents[tangent] - nx * normalDot;
675
- let ty = tangents[tangent + 1] - ny * normalDot;
676
- let tz = tangents[tangent + 2] - nz * normalDot;
677
- let length = Math.hypot(tx, ty, tz);
678
- if (length === 0) {
679
- if (Math.abs(nx) < Math.abs(nz)) {
680
- tx = 0;
681
- ty = nz;
682
- tz = -ny;
683
- }
684
- else {
685
- tx = ny;
686
- ty = -nx;
687
- tz = 0;
688
- }
689
- length = Math.hypot(tx, ty, tz) || 1;
690
- }
691
- tx /= length;
692
- ty /= length;
693
- tz /= length;
694
- vertices[base + 6] = tx;
695
- vertices[base + 7] = ty;
696
- vertices[base + 8] = tz;
697
- vertices[base + 9] =
698
- (ny * tz - nz * ty) * bitangents[tangent] +
699
- (nz * tx - nx * tz) * bitangents[tangent + 1] +
700
- (nx * ty - ny * tx) * bitangents[tangent + 2] <
701
- 0
702
- ? -1
703
- : 1;
704
- }
705
- }
706
721
  const CANONICAL_FLOATS_PER_VERTEX = 12;
707
722
  // The canonical interleaved PBR vertex layout shared by every builder: position + normal +
708
723
  // tangent(w = handedness) + uv0, stride 48 bytes.
@@ -813,11 +828,14 @@ const DODECAHEDRON_VERTS = [
813
828
  [_phi, 0, -_d],
814
829
  [_phi, 0, _d],
815
830
  ];
831
+ // Nine of these faces were wound the opposite way from the other twenty-seven, which put their
832
+ // geometric face normals against the outward vertex normals the builder authors. Only those nine
833
+ // are reversed; the winding here is per-face data, not a convention the whole table shares.
816
834
  const DODECAHEDRON_FACES = [
817
835
  [3, 11, 7],
818
836
  [3, 7, 15],
819
837
  [3, 15, 13],
820
- [7, 19, 11],
838
+ [7, 11, 19],
821
839
  [7, 11, 9],
822
840
  [7, 9, 19],
823
841
  [15, 7, 19],
@@ -832,21 +850,21 @@ const DODECAHEDRON_FACES = [
832
850
  [11, 17, 1],
833
851
  [11, 1, 9],
834
852
  [9, 1, 5],
835
- [5, 1, 17],
853
+ [5, 17, 1],
836
854
  [5, 17, 4],
837
- [5, 4, 14],
838
- [9, 5, 14],
839
- [9, 14, 12],
840
- [9, 12, 0],
855
+ [5, 14, 4],
856
+ [9, 14, 5],
857
+ [9, 12, 14],
858
+ [9, 0, 12],
841
859
  [1, 0, 12],
842
860
  [1, 12, 4],
843
- [1, 4, 17],
861
+ [1, 17, 4],
844
862
  [6, 18, 8],
845
863
  [6, 8, 10],
846
864
  [6, 10, 2],
847
865
  [18, 19, 8],
848
- [19, 7, 8],
849
- [7, 15, 8],
866
+ [19, 8, 7],
867
+ [7, 8, 15],
850
868
  [2, 10, 16],
851
869
  [10, 8, 0],
852
870
  [0, 8, 12],