trid3d 0.1.0 → 0.2.0

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.
@@ -45,6 +45,8 @@ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
45
45
  [0, 3, 7, 4], // right
46
46
  [1, 5, 6, 2], // left
47
47
  ],
48
+ // Which material each face uses when `material` is a list: one per side, in the order above
49
+ faceMaterials: [0, 1, 2, 3, 4, 5],
48
50
  };
49
51
  return withFaceUVs(mesh);
50
52
  }
@@ -161,6 +163,268 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
161
163
  return { vertices, edges, faces, uvs, faceUVs };
162
164
  }
163
165
 
166
+ /**
167
+ * Spins a profile around the Y axis: vases, bottles, cups, columns, chess
168
+ * pieces. Cylinders, cones and tori are built with it.
169
+ *
170
+ * Walk the profile so the outside is on its right in the (r, y) plane:
171
+ * bottom to top for a vase. Faces are clockwise as seen from outside.
172
+ *
173
+ * Texture coordinates: u goes around, v follows the profile's length.
174
+ * Caps map the texture as a disc. Edges are the profile's rings plus one
175
+ * line along the profile per segment.
176
+ *
177
+ * @example
178
+ * // A vase
179
+ * const vase = lathe(
180
+ * [{ r: 0.2, y: -0.5 }, { r: 0.35, y: -0.2 }, { r: 0.15, y: 0.3 }, { r: 0.2, y: 0.5 }],
181
+ * { capBottom: true },
182
+ * );
183
+ *
184
+ * @param {{r:number, y:number}[]} profile - Points: distance from the Y axis and height
185
+ * @param {Object} [opts]
186
+ * @param {number} [opts.segments=32] - Points around the axis
187
+ * @param {boolean} [opts.capBottom=false] - Close the bottom with a flat cap
188
+ * @param {boolean} [opts.capTop=false] - Close the top with a flat cap
189
+ * @param {boolean} [opts.closed=false] - The last point joins the first (torus-like shapes)
190
+ * @returns {import("../index.d.ts").Mesh}
191
+ */
192
+ export function lathe(profile, { segments = 32, capBottom = false, capTop = false, closed = false } = {}) {
193
+ segments = Math.max(3, Math.floor(segments));
194
+ const vertices = [];
195
+ const edges = [];
196
+ const faces = [];
197
+ const uvs = [];
198
+ const faceUVs = [];
199
+ const uv = (u, v) => uvs.push({ u, v }) - 1;
200
+
201
+ // v along the profile, by length
202
+ const pts = closed ? [...profile, profile[0]] : profile;
203
+ const lengths = [0];
204
+ for (let i = 1; i < pts.length; i++) {
205
+ lengths.push(lengths[i - 1] + Math.hypot(pts[i].r - pts[i - 1].r, pts[i].y - pts[i - 1].y));
206
+ }
207
+ const total = lengths[lengths.length - 1] || 1;
208
+
209
+ for (const p of profile) {
210
+ for (let s = 0; s < segments; s++) {
211
+ const t = (s / segments) * Math.PI * 2;
212
+ vertices.push({ x: Math.cos(t) * p.r, y: p.y, z: Math.sin(t) * p.r });
213
+ }
214
+ }
215
+
216
+ const rows = pts.length - 1;
217
+ for (let r = 0; r < rows; r++) {
218
+ const a = r * segments;
219
+ const b = ((r + 1) % profile.length) * segments;
220
+ for (let s = 0; s < segments; s++) {
221
+ const s1 = (s + 1) % segments;
222
+ faces.push([a + s, b + s, b + s1, a + s1]);
223
+ const u0 = s / segments, u1 = (s + 1) / segments;
224
+ const v0 = lengths[r] / total, v1 = lengths[r + 1] / total;
225
+ faceUVs.push([uv(u0, v0), uv(u0, v1), uv(u1, v1), uv(u1, v0)]);
226
+ }
227
+ }
228
+
229
+ // Rings (skipped where the profile touches the axis) and lines along the profile
230
+ profile.forEach((p, r) => {
231
+ if (p.r > 1e-5) edges.push(Array.from({ length: segments }, (_, s) => r * segments + s));
232
+ });
233
+ for (let s = 0; s < segments; s++) {
234
+ if (closed && profile.length > 2) edges.push(profile.map((_, r) => r * segments + s));
235
+ else for (let r = 0; r < profile.length - 1; r++) edges.push([r * segments + s, (r + 1) * segments + s]);
236
+ }
237
+
238
+ const disc = (s) => {
239
+ const t = (s / segments) * Math.PI * 2;
240
+ return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
241
+ };
242
+ if (capBottom && !closed && profile[0].r > 0) {
243
+ const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
244
+ for (let s = 0; s < segments; s++) {
245
+ faces.push([c, s, (s + 1) % segments]);
246
+ faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
247
+ }
248
+ }
249
+ const last = profile.length - 1;
250
+ if (capTop && !closed && profile[last].r > 0) {
251
+ const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
252
+ const a = last * segments;
253
+ for (let s = 0; s < segments; s++) {
254
+ faces.push([c, a + (s + 1) % segments, a + s]);
255
+ faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
256
+ }
257
+ }
258
+
259
+ return { vertices, edges, faces, uvs, faceUVs };
260
+ }
261
+
262
+ /** A capped cylinder along Y, centered on the origin. */
263
+ function cylinder(r = 0.3, h = 0.8, segments = 32) {
264
+ return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
265
+ }
266
+
267
+ /** A cone along Y with its tip up, or a tapered cylinder when `rTop` > 0. */
268
+ function cone(r = 0.35, h = 0.8, segments = 32, rTop = 0) {
269
+ // A tiny top ring keeps the tip's faces non-degenerate for UVs and normals
270
+ const profile = [{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 1e-6, y: h / 2 }];
271
+ return lathe(profile, { segments, capBottom: true, capTop: rTop > 0 });
272
+ }
273
+
274
+ /** A ring in the XZ plane: `R` from the center to the middle of the tube. */
275
+ function torus(R = 0.35, tube = 0.12, segments = 48, tubeSegments = 20) {
276
+ tubeSegments = Math.max(3, Math.floor(tubeSegments));
277
+ const profile = [];
278
+ for (let i = 0; i < tubeSegments; i++) {
279
+ const p = (i / tubeSegments) * Math.PI * 2;
280
+ profile.push({ r: R + Math.cos(p) * tube, y: Math.sin(p) * tube });
281
+ }
282
+ return lathe(profile, { segments, closed: true });
283
+ }
284
+
285
+ /**
286
+ * A flat grid in the XZ plane facing +Y, `size` wide, with `div` cells per
287
+ * side. `height(x, z)` lifts each vertex (terrain). The texture covers the
288
+ * whole grid once.
289
+ */
290
+ function plane(size = 1, div = 8, height = null) {
291
+ div = Math.max(1, Math.floor(div));
292
+ const vertices = [];
293
+ const uvs = [];
294
+ const n = div + 1;
295
+ const k = (i, j) => j * n + i;
296
+ for (let j = 0; j < n; j++) {
297
+ for (let i = 0; i < n; i++) {
298
+ const x = (i / div - 0.5) * size, z = (j / div - 0.5) * size;
299
+ vertices.push({ x, y: height ? height(x, z) : 0, z });
300
+ uvs.push({ u: i / div, v: j / div });
301
+ }
302
+ }
303
+ const faces = [];
304
+ const edges = [];
305
+ for (let j = 0; j < div; j++) {
306
+ for (let i = 0; i < div; i++) faces.push([k(i + 1, j + 1), k(i + 1, j), k(i, j), k(i, j + 1)]);
307
+ }
308
+ for (let j = 0; j < n; j++) {
309
+ for (let i = 0; i < div; i++) {
310
+ edges.push([k(i, j), k(i + 1, j)]);
311
+ edges.push([k(j, i), k(j, i + 1)]);
312
+ }
313
+ }
314
+ // Grid vertices and UVs share indices
315
+ return { vertices, edges, faces, uvs, faceUVs: faces.map((f) => [...f]) };
316
+ }
317
+
318
+ /** Twice the signed area: > 0 when the points go counter-clockwise (x right, y up). */
319
+ function signedArea(pts) {
320
+ let a = 0;
321
+ for (let i = 0; i < pts.length; i++) {
322
+ const p = pts[i], q = pts[(i + 1) % pts.length];
323
+ a += p.x * q.y - q.x * p.y;
324
+ }
325
+ return a;
326
+ }
327
+
328
+ /**
329
+ * Cuts a simple polygon (no holes, sides don't cross) into triangles, by
330
+ * ear clipping. Works for concave outlines. Falls back to a fan if the
331
+ * outline crosses itself.
332
+ *
333
+ * @param {{x:number, y:number}[]} pts - The outline, counter-clockwise (x right, y up)
334
+ * @returns {number[][]} Index triples into `pts`, counter-clockwise too
335
+ */
336
+ export function triangulate(pts) {
337
+ const idx = pts.map((_, i) => i);
338
+ const tris = [];
339
+ const cross = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
340
+ const inside = (p, a, b, c) => cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0;
341
+
342
+ let guard = idx.length * idx.length;
343
+ while (idx.length > 3 && guard-- > 0) {
344
+ let clipped = false;
345
+ for (let i = 0; i < idx.length; i++) {
346
+ const i0 = idx[(i + idx.length - 1) % idx.length], i1 = idx[i], i2 = idx[(i + 1) % idx.length];
347
+ const a = pts[i0], b = pts[i1], c = pts[i2];
348
+ if (cross(a, b, c) <= 1e-12) continue; // reflex or flat corner
349
+ if (idx.some((j) => j !== i0 && j !== i1 && j !== i2 && inside(pts[j], a, b, c))) continue;
350
+ tris.push([i0, i1, i2]);
351
+ idx.splice(i, 1);
352
+ clipped = true;
353
+ break;
354
+ }
355
+ if (!clipped) break;
356
+ }
357
+ if (idx.length === 3) tris.push([idx[0], idx[1], idx[2]]);
358
+ else for (let i = 1; i < idx.length - 1; i++) tris.push([idx[0], idx[i], idx[i + 1]]);
359
+ return tris;
360
+ }
361
+
362
+ /**
363
+ * Turns a flat outline in the XY plane into a solid `depth` thick along Z,
364
+ * centered on the origin: blades, axes, keys, stars, letters, floor plans.
365
+ * The outline may go either way round and may be concave (no holes).
366
+ *
367
+ * Texture coordinates: the front and back map the outline's bounding box,
368
+ * the sides wrap u along the outline and v across the depth. Edges are the
369
+ * front and back outlines plus a line at each corner.
370
+ *
371
+ * @example
372
+ * const star = Array.from({ length: 10 }, (_, i) => {
373
+ * const a = (i / 10) * Math.PI * 2, r = i % 2 ? 0.2 : 0.5;
374
+ * return { x: Math.cos(a) * r, y: Math.sin(a) * r };
375
+ * });
376
+ * scene.add({ mesh: extrude(star, 0.15), material: { fill: "#fc5" } });
377
+ *
378
+ * @param {{x:number, y:number}[]} outline - The flat shape (3 points or more)
379
+ * @param {number} [depth=0.2] - Thickness along Z
380
+ * @returns {import("../index.d.ts").Mesh}
381
+ */
382
+ export function extrude(outline, depth = 0.2) {
383
+ const pts = signedArea(outline) < 0 ? [...outline].reverse() : outline;
384
+ const n = pts.length;
385
+ const vertices = [];
386
+ const faces = [];
387
+ const uvs = [];
388
+ const faceUVs = [];
389
+ const uv = (u, v) => uvs.push({ u, v }) - 1;
390
+
391
+ // 0..n-1 at the front (-z), n..2n-1 at the back (+z)
392
+ for (const p of pts) vertices.push({ x: p.x, y: p.y, z: -depth / 2 });
393
+ for (const p of pts) vertices.push({ x: p.x, y: p.y, z: depth / 2 });
394
+
395
+ const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
396
+ const x0 = Math.min(...xs), y0 = Math.min(...ys);
397
+ const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
398
+ const capUV = (i) => uv((pts[i].x - x0) / w, (pts[i].y - y0) / h);
399
+
400
+ // Caps: counter-clockwise outlines look clockwise from the back (+z), so the
401
+ // back cap keeps that order and the front cap (seen from -z) is reversed
402
+ for (const [a, b, c] of triangulate(pts)) {
403
+ faces.push([n + a, n + b, n + c]);
404
+ faceUVs.push([capUV(a), capUV(b), capUV(c)]);
405
+ faces.push([c, b, a]);
406
+ faceUVs.push([capUV(c), capUV(b), capUV(a)]);
407
+ }
408
+
409
+ // Sides, u by length along the outline
410
+ const len = [0];
411
+ for (let i = 0; i < n; i++) len.push(len[i] + Math.hypot(pts[(i + 1) % n].x - pts[i].x, pts[(i + 1) % n].y - pts[i].y));
412
+ const total = len[n] || 1;
413
+ for (let i = 0; i < n; i++) {
414
+ const j = (i + 1) % n;
415
+ const ua = len[i] / total, ub = len[i + 1] / total;
416
+ faces.push([n + j, n + i, i, j]);
417
+ faceUVs.push([uv(ub, 1), uv(ua, 1), uv(ua, 0), uv(ub, 0)]);
418
+ }
419
+
420
+ const edges = [
421
+ pts.map((_, i) => i),
422
+ pts.map((_, i) => n + i),
423
+ ...pts.map((_, i) => [i, n + i]),
424
+ ];
425
+ return { vertices, edges, faces, uvs, faceUVs };
426
+ }
427
+
164
428
  /**
165
429
  * Scales a mesh along the X, Y, and Z axes.
166
430
  *
@@ -192,32 +456,26 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
192
456
  }
193
457
 
194
458
  /**
195
- * Factory function that creates a predefined wireframe shape.
196
- *
197
- * This provides a unified API for constructing different meshes
198
- * (cube, box, sphere) while keeping the rendering pipeline generic.
199
- *
200
- * Supported shapes:
201
- * - "cube" → wireframe cube
202
- * - "box" → rectangular prism (box)
203
- * - "sphere" → wireframe sphere
459
+ * Creates a built-in shape, centered on the origin.
204
460
  *
205
- * Each shape type accepts its own configuration options via `opts`.
206
- * Unspecified options fall back to sensible defaults.
461
+ * | Kind | Options (defaults) |
462
+ * | --- | --- |
463
+ * | `"cube"` | `size` (0.5) |
464
+ * | `"box"` | `w`, `h`, `d` (0.6 each) |
465
+ * | `"sphere"` | `r` (0.45), `rings` (12), `segments` (24) |
466
+ * | `"cylinder"` | `r` (0.3), `h` (0.8), `segments` (32) |
467
+ * | `"cone"` | `r` (0.35), `h` (0.8), `segments` (32), `rTop` (0: pointed; > 0: tapered cylinder) |
468
+ * | `"torus"` | `R` (0.35, center to tube middle), `tube` (0.12), `segments` (48), `tubeSegments` (20) |
469
+ * | `"plane"` | `size` (1), `div` (8 cells per side), `height` ((x, z) => y, optional) |
207
470
  *
208
- * @param {string} kind - Shape type identifier
209
- * @param {Object} opts - Shape configuration options
210
- * @param {number} [opts.size] - Cube size
211
- * @param {number} [opts.w] - Box width
212
- * @param {number} [opts.h] - Box height
213
- * @param {number} [opts.d] - Box depth
214
- * @param {number} [opts.r] - Sphere radius
215
- * @param {number} [opts.rings] - Number of latitude rings (sphere)
216
- * @param {number} [opts.segments] - Segments per ring (sphere)
217
- *
218
- * @returns {{ vertices: {x:number,y:number,z:number}[], edges: number[][] }}
219
- * A wireframe mesh object
471
+ * Cylinders, cones and tori stand along Y; the plane lies in XZ facing +Y.
472
+ * Every shape has edges (for wireframes), faces (clockwise from outside)
473
+ * and texture coordinates. For other round or flat shapes, see `lathe`
474
+ * and `extrude`.
220
475
  *
476
+ * @param {string} kind - Shape name (case doesn't matter)
477
+ * @param {Object} [opts] - Size options for that shape (see the table)
478
+ * @returns {import("../index.d.ts").Mesh}
221
479
  * @throws {Error} If the shape type is not supported
222
480
  */
223
481
  export function makeShape(kind, opts = {}) {
@@ -225,6 +483,10 @@ export function makeShape(kind, opts = {}) {
225
483
  case "cube": return cubeWire(opts.size ?? 0.5);
226
484
  case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6);
227
485
  case "sphere":return sphereWire(opts.r ?? 0.45, opts.rings ?? 12, opts.segments ?? 24);
486
+ case "cylinder": return cylinder(opts.r, opts.h, opts.segments);
487
+ case "cone": return cone(opts.r, opts.h, opts.segments, opts.rTop);
488
+ case "torus": return torus(opts.R, opts.tube, opts.segments, opts.tubeSegments);
489
+ case "plane": return plane(opts.size, opts.div, opts.height);
228
490
  default: throw new Error("Unknown shape: " + kind);
229
491
  }
230
492
  }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Texture mapping: texture coordinates made from a mesh's shape, for
3
+ * meshes that don't have any (or to replace the ones they have).
4
+ *
5
+ * Every mode fits the image to the mesh's bounding box:
6
+ *
7
+ * - "planar": the image is projected straight through the mesh along one axis
8
+ * - "box": each face takes the image from the side of the box it faces most
9
+ * - "sphere": wrapped around like a world map
10
+ * - "cylinder": wrapped around the sides like a label; the ends get a planar image
11
+ *
12
+ * A mapping is a mode name, or an object with options:
13
+ *
14
+ * mapping: "box"
15
+ * mapping: { type: "cylinder", axis: "x" }
16
+ *
17
+ * @typedef {"uv"|"planar"|"box"|"sphere"|"cylinder"} MappingType
18
+ * @typedef {MappingType | { type: MappingType, axis?: "x"|"y"|"z" }} Mapping
19
+ */
20
+
21
+ const TAU = Math.PI * 2;
22
+
23
+ /** Mapping modes accepted by `computeUVs` and `material.mapping`. */
24
+ export const MAPPINGS = ["uv", "planar", "box", "sphere", "cylinder"];
25
+
26
+ /**
27
+ * Texture coordinates for a mesh's faces, made from its shape.
28
+ * The mesh isn't changed. Every face corner gets its own entry in `uvs`.
29
+ *
30
+ * @param {{ vertices:{x:number,y:number,z:number}[], faces?:number[][] }} mesh
31
+ * @param {Mapping} mapping
32
+ * @returns {{ uvs:{u:number,v:number}[], faceUVs:number[][] }}
33
+ * @throws {Error} For an unknown mapping
34
+ */
35
+ export function computeUVs(mesh, mapping) {
36
+ const { type, axis } = mappingOf(mapping);
37
+ const faces = mesh.faces ?? [];
38
+ const uvs = [], faceUVs = [];
39
+ if (type === "uv") {
40
+ return { uvs: mesh.uvs ?? [], faceUVs: mesh.faceUVs ?? [] };
41
+ }
42
+
43
+ // Sphere and cylinder wrap around Y; other axes are turned to Y first.
44
+ // Cyclic swaps are rotations, so the image isn't mirrored.
45
+ const turn = axis === "x" ? (p) => ({ x: p.z, y: p.x, z: p.y })
46
+ : axis === "z" ? (p) => ({ x: p.y, y: p.z, z: p.x })
47
+ : (p) => p;
48
+ const wraps = type === "sphere" || type === "cylinder";
49
+ const verts = wraps ? mesh.vertices.map(turn) : mesh.vertices;
50
+ const box = bounds(verts);
51
+ const center = { x: (box.min.x + box.max.x) / 2, y: (box.min.y + box.max.y) / 2, z: (box.min.z + box.max.z) / 2 };
52
+ const size = { x: box.max.x - box.min.x || 1, y: box.max.y - box.min.y || 1, z: box.max.z - box.min.z || 1 };
53
+
54
+ // A flat image seen from outside along +side of an axis ("y" with sign 1: from above)
55
+ const flat = (p, ax, sign) => {
56
+ const nx = (p.x - box.min.x) / size.x, ny = (p.y - box.min.y) / size.y, nz = (p.z - box.min.z) / size.z;
57
+ if (ax === "x") return { u: sign > 0 ? nz : 1 - nz, v: ny };
58
+ if (ax === "y") return { u: nx, v: sign > 0 ? nz : 1 - nz };
59
+ return { u: sign > 0 ? 1 - nx : nx, v: ny };
60
+ };
61
+
62
+ for (const f of faces) {
63
+ const p = f.map((i) => verts[i]);
64
+ let ts;
65
+ if (type === "planar") {
66
+ // From the front: along the axis, seen from its negative side
67
+ const ax = axis ?? "z";
68
+ ts = p.map((q) => flat(q, ax, -1));
69
+ } else if (type === "box") {
70
+ const n = faceNormal(p);
71
+ const ax = dominant(n);
72
+ ts = p.map((q) => flat(q, ax, Math.sign(n[ax]) || 1));
73
+ } else {
74
+ const n = faceNormal(p);
75
+ if (type === "cylinder" && Math.abs(n.y) > 0.7) {
76
+ // An end cap: planar from above or below
77
+ ts = p.map((q) => flat(q, "y", Math.sign(n.y)));
78
+ } else {
79
+ ts = p.map((q) => {
80
+ const d = { x: q.x - center.x, y: q.y - center.y, z: q.z - center.z };
81
+ const around = Math.hypot(d.x, d.z);
82
+ const u = around < 1e-9 ? NaN : (((Math.atan2(d.z, d.x) / TAU) % 1) + 1) % 1;
83
+ const v = type === "sphere"
84
+ ? 0.5 + Math.atan2(d.y, around) / Math.PI
85
+ : (q.y - box.min.y) / size.y;
86
+ return { u, v };
87
+ });
88
+ fixSeam(ts);
89
+ }
90
+ }
91
+ faceUVs.push(ts.map((t) => uvs.push(t) - 1));
92
+ }
93
+ return { uvs, faceUVs };
94
+ }
95
+
96
+ /**
97
+ * Replaces a mesh's texture coordinates with ones made from its shape,
98
+ * in place. Handy for OBJ files without any, or before exporting.
99
+ *
100
+ * @param {Object} mesh
101
+ * @param {Mapping} mapping
102
+ * @returns The same mesh
103
+ */
104
+ export function mapUVs(mesh, mapping) {
105
+ const { uvs, faceUVs } = computeUVs(mesh, mapping);
106
+ mesh.uvs = uvs;
107
+ mesh.faceUVs = faceUVs;
108
+ mesh.dirty = true;
109
+ return mesh;
110
+ }
111
+
112
+ /** The mapping as `{ type, axis }`, checked. */
113
+ export function mappingOf(mapping) {
114
+ const m = typeof mapping === "string" ? { type: mapping } : { ...(mapping ?? {}) };
115
+ m.type ??= "uv";
116
+ if (!MAPPINGS.includes(m.type)) throw new Error(`Unknown texture mapping: "${m.type}"`);
117
+ if (m.axis != null && !["x", "y", "z"].includes(m.axis)) throw new Error(`Unknown mapping axis: "${m.axis}"`);
118
+ return m;
119
+ }
120
+
121
+ /** A short string naming a mapping, for caches ("" for the mesh's own coordinates). */
122
+ export function mappingKey(mapping) {
123
+ if (mapping == null) return "";
124
+ const { type, axis } = mappingOf(mapping);
125
+ return type === "uv" ? "" : type + (axis ? ":" + axis : "");
126
+ }
127
+
128
+ const cache = new WeakMap(); // mesh → Map(key → { counts, vertices, faces, result })
129
+
130
+ /**
131
+ * Texture coordinates a renderer should use for a mesh: its own, or
132
+ * computed for `mapping` and cached until the mesh changes.
133
+ *
134
+ * @returns {{ uvs:{u:number,v:number}[]|undefined, faceUVs:(number[]|null)[]|undefined }}
135
+ */
136
+ export function uvsFor(mesh, mapping) {
137
+ const key = mappingKey(mapping);
138
+ if (!key) return { uvs: mesh.uvs, faceUVs: mesh.faceUVs };
139
+ let byKey = cache.get(mesh);
140
+ if (!byKey) cache.set(mesh, (byKey = new Map()));
141
+ const counts = `${mesh.vertices.length}/${mesh.faces?.length ?? 0}`;
142
+ let c = byKey.get(key);
143
+ if (!c || mesh.dirty || c.counts !== counts || c.vertices !== mesh.vertices || c.faces !== mesh.faces) {
144
+ c = { counts, vertices: mesh.vertices, faces: mesh.faces, result: computeUVs(mesh, mapping) };
145
+ byKey.set(key, c);
146
+ }
147
+ return c.result;
148
+ }
149
+
150
+ /**
151
+ * The 2D transform a material's `textureRepeat`, `textureOffset` and
152
+ * `textureRotation` describe, as [a, b, c, d, e, f]:
153
+ * u' = a·u + c·v + e, v' = b·u + d·v + f. Null when it changes nothing.
154
+ *
155
+ * Rotation turns the image around its center, then repeat scales it
156
+ * (2 = the image twice across), then offset moves it.
157
+ */
158
+ export function uvTransform(mat) {
159
+ const r = mat.textureRepeat ?? 1, o = mat.textureOffset, a = mat.textureRotation ?? 0;
160
+ const ru = typeof r === "number" ? r : r.u ?? 1, rv = typeof r === "number" ? r : r.v ?? 1;
161
+ const ou = o?.u ?? 0, ov = o?.v ?? 0;
162
+ if (ru === 1 && rv === 1 && !ou && !ov && !a) return null;
163
+ const c = Math.cos(a), s = Math.sin(a);
164
+ // Rotate around (0.5, 0.5): R·(t - 0.5) + 0.5, then scale and move
165
+ return [
166
+ ru * c, rv * s,
167
+ -ru * s, rv * c,
168
+ ru * (0.5 - 0.5 * c + 0.5 * s) + ou,
169
+ rv * (0.5 - 0.5 * s - 0.5 * c) + ov,
170
+ ];
171
+ }
172
+
173
+ /** Applies a `uvTransform` to one texture coordinate. */
174
+ export function applyUVTransform(m, t) {
175
+ if (!m || !t) return t;
176
+ return { u: m[0] * t.u + m[2] * t.v + m[4], v: m[1] * t.u + m[3] * t.v + m[5] };
177
+ }
178
+
179
+ /**
180
+ * Corners that wrap past u = 1 get 1 added, so a face crossing the
181
+ * seam doesn't stretch the whole image across itself. Corners on the
182
+ * axis (u = NaN, like a sphere's poles) take the face's middle u.
183
+ */
184
+ function fixSeam(ts) {
185
+ const known = ts.filter((t) => !Number.isNaN(t.u));
186
+ if (!known.length) {
187
+ for (const t of ts) t.u = 0.5;
188
+ return;
189
+ }
190
+ const us = known.map((t) => t.u);
191
+ if (Math.max(...us) - Math.min(...us) > 0.5) {
192
+ for (const t of known) if (t.u < 0.5) t.u += 1;
193
+ }
194
+ const mid = known.reduce((a, t) => a + t.u, 0) / known.length;
195
+ for (const t of ts) if (Number.isNaN(t.u)) t.u = mid;
196
+ }
197
+
198
+ /** Outward normal of a clockwise face (not normalized). */
199
+ function faceNormal(p) {
200
+ let x = 0, y = 0, z = 0;
201
+ for (let i = 0; i < p.length; i++) {
202
+ const a = p[i], b = p[(i + 1) % p.length];
203
+ x += (a.y - b.y) * (a.z + b.z);
204
+ y += (a.z - b.z) * (a.x + b.x);
205
+ z += (a.x - b.x) * (a.y + b.y);
206
+ }
207
+ return { x, y, z };
208
+ }
209
+
210
+ /** The axis a vector points along most. */
211
+ function dominant(n) {
212
+ const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
213
+ return ay >= ax && ay >= az ? "y" : ax >= az ? "x" : "z";
214
+ }
215
+
216
+ function bounds(verts) {
217
+ const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
218
+ for (const v of verts) {
219
+ if (v.x < min.x) min.x = v.x; if (v.x > max.x) max.x = v.x;
220
+ if (v.y < min.y) min.y = v.y; if (v.y > max.y) max.y = v.y;
221
+ if (v.z < min.z) min.z = v.z; if (v.z > max.z) max.z = v.z;
222
+ }
223
+ if (!verts.length) return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } };
224
+ return { min, max };
225
+ }