trid3d 0.2.0 → 0.3.1

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.
@@ -0,0 +1,152 @@
1
+ import { rotateXYZ } from "./transform.js";
2
+
3
+ /**
4
+ * Rotation matrices and move / rotate / scale operations on an object's
5
+ * transform ({ pos, rot, scale }, applied scale → rotate X, Y, Z → move).
6
+ *
7
+ * Matrices are 3×3, row-major (`m[row][col]`), applied as M · v. They're
8
+ * built from `rotateXYZ` itself, so they always match what the renderers draw.
9
+ *
10
+ * @typedef {[[number,number,number],[number,number,number],[number,number,number]]} Mat3
11
+ * @typedef {{x:number, y:number, z:number}} Vec3
12
+ * @typedef {{ pos:Vec3, rot:Vec3, scale:Vec3 }} Transform
13
+ *
14
+ * @typedef {{ mode:"move", delta:Vec3 }
15
+ * | { mode:"rotate", axis:Vec3, angle:number, center:Vec3 }
16
+ * | { mode:"scale", axis:Vec3|null, factor:number, center:Vec3 }} TransformOp
17
+ * An operation in world space. Rotate: a right-handed turn of `angle`
18
+ * radians around the unit `axis`, about `center`. Scale: `factor` about
19
+ * `center`, uniform when `axis` is null, otherwise along that unit axis.
20
+ */
21
+
22
+ const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
23
+ const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z });
24
+ const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
25
+ const mul = (a, k) => ({ x: a.x * k, y: a.y * k, z: a.z * k });
26
+
27
+ /**
28
+ * The matrix `rotateXYZ(p, rot)` applies.
29
+ *
30
+ * @param {Vec3} rot - Angles in radians (X, then Y, then Z)
31
+ * @returns {Mat3}
32
+ */
33
+ export function matFromEuler(rot) {
34
+ const cx = rotateXYZ({ x: 1, y: 0, z: 0 }, rot);
35
+ const cy = rotateXYZ({ x: 0, y: 1, z: 0 }, rot);
36
+ const cz = rotateXYZ({ x: 0, y: 0, z: 1 }, rot);
37
+ return [
38
+ [cx.x, cy.x, cz.x],
39
+ [cx.y, cy.y, cz.y],
40
+ [cx.z, cy.z, cz.z],
41
+ ];
42
+ }
43
+
44
+ /**
45
+ * Angles for `rotateXYZ` (an object's `rot`) from a rotation matrix: the
46
+ * inverse of `matFromEuler`. Looking straight along Y (gimbal lock), X and
47
+ * Z turn the same way, so the whole turn goes into Z.
48
+ *
49
+ * @param {Mat3} m - A rotation matrix
50
+ * @returns {Vec3} Angles in radians
51
+ */
52
+ export function eulerFromMat(m) {
53
+ // rotateXYZ's matrix is Rz·Ry·Rx: m[2][0] = sin(y), m[2][1] = cos(y)·sin(x), m[1][0] = sin(z)·cos(y)
54
+ const sy = Math.max(-1, Math.min(1, m[2][0]));
55
+ const y = Math.asin(sy);
56
+ if (Math.abs(sy) < 0.99999) {
57
+ return { x: Math.atan2(m[2][1], m[2][2]), y, z: Math.atan2(m[1][0], m[0][0]) };
58
+ }
59
+ return { x: 0, y, z: Math.atan2(-m[0][1], m[1][1]) };
60
+ }
61
+
62
+ /**
63
+ * A right-handed turn of `angle` radians around a unit `axis` (Rodrigues).
64
+ *
65
+ * @param {Vec3} axis - Unit vector
66
+ * @param {number} angle - Radians
67
+ * @returns {Mat3}
68
+ */
69
+ export function matFromAxisAngle(axis, angle) {
70
+ const { x, y, z } = axis, c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
71
+ return [
72
+ [t * x * x + c, t * x * y - s * z, t * x * z + s * y],
73
+ [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
74
+ [t * x * z - s * y, t * y * z + s * x, t * z * z + c],
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * Matrix product a · b (b is applied first).
80
+ *
81
+ * @param {Mat3} a
82
+ * @param {Mat3} b
83
+ * @returns {Mat3}
84
+ */
85
+ export function mulMat(a, b) {
86
+ return [0, 1, 2].map((i) => [0, 1, 2].map((j) => a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]));
87
+ }
88
+
89
+ /**
90
+ * Matrix times vector, m · v.
91
+ *
92
+ * @param {Mat3} m
93
+ * @param {Vec3} v
94
+ * @returns {Vec3}
95
+ */
96
+ export function mulVec(m, v) {
97
+ return {
98
+ x: m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z,
99
+ y: m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z,
100
+ z: m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Applies a world-space operation to a transform and returns the new one
106
+ * (the input isn't changed). `TransformGizmo` reports these.
107
+ *
108
+ * - move: adds `delta` to `pos`
109
+ * - rotate: turns the object around `center`: its position orbits and its
110
+ * orientation turns, both by the same rotation
111
+ * - scale: scales about `center`. Stretching a rotated object along a world
112
+ * axis can't be stored as `scale` (it would shear), so it stretches the
113
+ * object's own axis that points most along it
114
+ *
115
+ * @example
116
+ * Object.assign(obj, applyTransformOp(obj, { mode: "rotate", axis: { x: 0, y: 1, z: 0 }, angle: Math.PI / 2, center: { x: 0, y: 0, z: 0 } }));
117
+ *
118
+ * @param {Transform} t - The transform before the operation
119
+ * @param {TransformOp} op
120
+ * @returns {Transform}
121
+ */
122
+ export function applyTransformOp(t, op) {
123
+ switch (op.mode) {
124
+ case "move":
125
+ return { pos: add(t.pos, op.delta), rot: { ...t.rot }, scale: { ...t.scale } };
126
+ case "rotate": {
127
+ const r = matFromAxisAngle(op.axis, op.angle);
128
+ return {
129
+ pos: add(op.center, mulVec(r, sub(t.pos, op.center))),
130
+ rot: eulerFromMat(mulMat(r, matFromEuler(t.rot))),
131
+ scale: { ...t.scale },
132
+ };
133
+ }
134
+ case "scale": {
135
+ const f = op.factor;
136
+ if (!op.axis) {
137
+ return { pos: add(op.center, mul(sub(t.pos, op.center), f)), rot: { ...t.rot }, scale: mul(t.scale, f) };
138
+ }
139
+ const a = op.axis;
140
+ const pos = add(t.pos, mul(a, (f - 1) * dot(sub(t.pos, op.center), a)));
141
+ // The object's own axis most aligned with the stretch (columns of its rotation)
142
+ const m = matFromEuler(t.rot);
143
+ const along = [0, 1, 2].map((j) => Math.abs(m[0][j] * a.x + m[1][j] * a.y + m[2][j] * a.z));
144
+ const j = along.indexOf(Math.max(...along));
145
+ const scale = { ...t.scale };
146
+ scale["xyz"[j]] *= f;
147
+ return { pos, rot: { ...t.rot }, scale };
148
+ }
149
+ default:
150
+ throw new Error("applyTransformOp: unknown mode " + op?.mode);
151
+ }
152
+ }
@@ -1,8 +1,10 @@
1
- import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
1
+ import { worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
3
+ import { worldObjects } from "../sceneGraph.js";
2
4
  import { clear, drawInstance, edgeSegments, wireEdges } from "./draw.js";
3
5
  import { parseColor } from "./color.js";
4
6
  import { newell, cornerNormals, creaseOf } from "./normals.js";
5
- import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
7
+ import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
6
8
  import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
7
9
 
8
10
  export { newell };
@@ -44,6 +46,8 @@ export const DEFAULT_AMBIENT = 0.25;
44
46
  * shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
45
47
  * @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
46
48
  * built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
49
+ * @property {boolean} [liveTexture] - Read the texture again every frame (default: only for videos).
50
+ * For a canvas redrawn every frame; for one redrawn now and then, call `updateTexture(canvas)` instead
47
51
  * @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
48
52
  * @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
49
53
  * from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
@@ -86,7 +90,7 @@ export class CanvasRenderer {
86
90
  this._splits = new WeakMap();
87
91
  this._colors = new Map();
88
92
  this._start = performance.now();
89
- this._patterns = new Map(); // texture source → canvas pattern
93
+ this._patterns = new WeakMap(); // texture source → canvas pattern (freed with the source)
90
94
  this._warned = new WeakSet();
91
95
  this._frame = 0;
92
96
  }
@@ -102,11 +106,15 @@ export class CanvasRenderer {
102
106
  clear(ctx, w, h, scene.background);
103
107
  this._frame++;
104
108
 
109
+ // Every object with a mesh, groups' children included, with its world matrix
110
+ const drawn = worldObjects(scene).filter((o) => o.mesh);
111
+
105
112
  // Wireframes only: nothing to sort
106
- if (!scene.objects.some((o) => materialsOf(o))) {
107
- for (const obj of scene.objects) {
108
- drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth);
113
+ if (!drawn.some((o) => materialsOf(o))) {
114
+ for (const obj of drawn) {
115
+ drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth, obj.world);
109
116
  }
117
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
110
118
  return;
111
119
  }
112
120
 
@@ -120,12 +128,12 @@ export class CanvasRenderer {
120
128
  const time = (performance.now() - this._start) / 1000;
121
129
  const background = this._rgba(ctx, scene.background);
122
130
 
123
- for (const obj of scene.objects) {
131
+ for (const obj of drawn) {
124
132
  const mats = materialsOf(obj);
125
133
  // Split big faces (cached per mesh); extra vertices are midpoints of existing ones
126
134
  const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
127
135
  const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
128
- const world = verts.map((v) => localToWorld(v, obj));
136
+ const world = verts.map((v) => transformPoint(obj.world, v));
129
137
  const cam = world.map((p) => worldToCamera(p, camera));
130
138
  const color = obj.color ?? scene.color;
131
139
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
@@ -145,6 +153,13 @@ export class CanvasRenderer {
145
153
 
146
154
  const sourceFaces = obj.mesh.faces ?? [];
147
155
  const base = obj.mesh.vertices.length;
156
+ // Normals turn with the object (stretched and mirrored ones too); a mirror reverses face winding
157
+ const nm = normalMatrix(obj.world);
158
+ const flip = determinantMat4(obj.world) < 0 ? -1 : 1;
159
+ const flatNormal = (face) => {
160
+ const n = normalize(newell(face.map((i) => world[i])));
161
+ return { x: n.x * flip, y: n.y * flip, z: n.z * flip };
162
+ };
148
163
 
149
164
  // Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
150
165
  const normalAt = (corners, F, i) => {
@@ -154,18 +169,16 @@ export class CanvasRenderer {
154
169
  return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
155
170
  };
156
171
  const lightAt = (n, back) => {
157
- // Same steps as the vertices: undo the scale, then rotate (keeps normals right on stretched objects)
158
- const r = normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
172
+ const r = transformNormal(nm, n);
159
173
  const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
160
174
  return ambient + (1 - ambient) * Math.max(0, d);
161
175
  };
162
176
  const flatLight = (face, back) => {
163
- const nw = normalize(newell(face.map((i) => world[i])));
177
+ const nw = flatNormal(face);
164
178
  const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
165
179
  return ambient + (1 - ambient) * Math.max(0, d);
166
180
  };
167
- const toWorldNormal = (n) =>
168
- normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
181
+ const toWorldNormal = (n) => transformNormal(nm, n);
169
182
 
170
183
  // Everything a material needs, worked out once per object and frame
171
184
  const prepare = (mat) => {
@@ -220,7 +233,7 @@ export class CanvasRenderer {
220
233
  // Seen from behind? (camera sits at the origin of camera space)
221
234
  const n = newell(pts);
222
235
  const c = centroid(pts);
223
- const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
236
+ const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
224
237
  if (back && !L.mat.doubleSided) continue;
225
238
 
226
239
  const clipped = clipPolygon(pts, near);
@@ -244,7 +257,7 @@ export class CanvasRenderer {
244
257
  items.push({
245
258
  depth: c.z,
246
259
  poly: [screen[0], screen[k], screen[k + 1]],
247
- texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, tint: fill },
260
+ texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, live: textureIsLive(L.tex, L.mat), tint: fill },
248
261
  lit: [lit[0], lit[k], lit[k + 1]],
249
262
  outline, lineWidth,
250
263
  edges: n === 3 ? edges : fan,
@@ -263,7 +276,7 @@ export class CanvasRenderer {
263
276
  .reduce((a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }), { x: 0, y: 0, z: 0 });
264
277
  nw = normalize(sum);
265
278
  } else {
266
- nw = normalize(newell(face.map((i) => world[i])));
279
+ nw = flatNormal(face);
267
280
  }
268
281
  if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
269
282
 
@@ -380,7 +393,7 @@ export class CanvasRenderer {
380
393
  }
381
394
 
382
395
  // Meshes marked as changed have been redrawn from their new data
383
- for (const obj of scene.objects) if (obj.mesh.dirty) obj.mesh.dirty = false;
396
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
384
397
  }
385
398
 
386
399
  /**
@@ -493,7 +506,7 @@ export class CanvasRenderer {
493
506
  * triangle's corners, tinted by the fill color, then darkened by the light.
494
507
  */
495
508
  _drawTextured(ctx, it) {
496
- const { src, uvs, pixelated, tint } = it.texture;
509
+ const { src, uvs, pixelated, live, tint } = it.texture;
497
510
  const { w: W, h: H } = textureSize(src);
498
511
  const [p0, p1, p2] = it.poly;
499
512
 
@@ -519,7 +532,7 @@ export class CanvasRenderer {
519
532
  const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
520
533
  const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
521
534
  const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
522
- const pattern = this._pattern(ctx, src);
535
+ const pattern = this._pattern(ctx, src, live);
523
536
  pattern.setTransform(new DOMMatrix([a, b, c, d, p0.x - a * U[0] - c * V[0], p0.y - b * U[0] - d * V[0]]));
524
537
  ctx.imageSmoothingEnabled = !pixelated;
525
538
  ctx.fillStyle = pattern;
@@ -553,11 +566,15 @@ export class CanvasRenderer {
553
566
  ctx.fill();
554
567
  }
555
568
 
556
- /** A repeating pattern for a texture (made again each frame for canvases and videos). */
557
- _pattern(ctx, src) {
569
+ /**
570
+ * A repeating pattern for a texture. A pattern is a copy of the image, so
571
+ * it's made again after `updateTexture`, and every frame for live ones.
572
+ */
573
+ _pattern(ctx, src, live) {
558
574
  let p = this._patterns.get(src);
559
- if (!p || (textureIsLive(src) && p.frame !== this._frame)) {
560
- p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame };
575
+ const version = textureVersion(src);
576
+ if (!p || p.version !== version || (live && p.frame !== this._frame)) {
577
+ p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame, version };
561
578
  this._patterns.set(src, p);
562
579
  }
563
580
  return p.pattern;
@@ -1,4 +1,5 @@
1
1
  import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint } from "../math/matrix.js";
2
3
 
3
4
  /**
4
5
  * Clears the entire canvas with a solid background color.
@@ -136,10 +137,13 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
136
137
  * @param {number} h - Canvas height
137
138
  * @param {string} color - Wireframe color
138
139
  * @param {number} lineWidth - Wireframe width
140
+ * @param {number[]} [world] - World matrix to place it with (e.g. `obj.world` inside a group).
141
+ * Default: the instance's own pos / rot / scale
139
142
  */
140
- export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
143
+ export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3, world) {
141
144
  const near = camera.near ?? DEFAULT_NEAR;
142
- const ptsCam = inst.mesh.vertices.map(v => worldToCamera(localToWorld(v, inst), camera));
145
+ const toWorld = world ? (v) => transformPoint(world, v) : (v) => localToWorld(v, inst);
146
+ const ptsCam = inst.mesh.vertices.map((v) => worldToCamera(toWorld(v), camera));
143
147
 
144
148
  ctx.lineWidth = lineWidth;
145
149
  ctx.strokeStyle = color;
@@ -0,0 +1,257 @@
1
+ import { localToWorld, rotateXYZ } from "../math/transform.js";
2
+ import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
3
+ import { forEachPlaced, worldMatrix } from "../sceneGraph.js";
4
+
5
+ /** Deep copy of one mesh field: arrays and plain objects are copied, the rest kept. */
6
+ const copy = (x) => (Array.isArray(x) ? x.map(copy) : x && typeof x === "object" ? { ...x } : x);
7
+
8
+ /** A transform with every part filled in. */
9
+ const fullTransform = ({ pos, rot, scale } = {}) => ({
10
+ pos: { x: 0, y: 0, z: 0, ...pos },
11
+ rot: { x: 0, y: 0, z: 0, ...rot },
12
+ scale: { x: 1, y: 1, z: 1, ...scale },
13
+ });
14
+
15
+ /** A copy of a mesh with a world matrix baked in (normals turned, faces flipped for mirrors). */
16
+ function bake(mesh, world) {
17
+ const out = cloneMesh(mesh);
18
+ for (const v of out.vertices) Object.assign(v, transformPoint(world, v));
19
+ if (out.normals) {
20
+ const nm = normalMatrix(world);
21
+ out.normals = out.normals.map((n) => transformNormal(nm, n));
22
+ }
23
+ if (determinantMat4(world) < 0 && out.faces) {
24
+ const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
25
+ out.faces = flip(out.faces);
26
+ if (out.faceUVs) out.faceUVs = flip(out.faceUVs);
27
+ if (out.faceNormals) out.faceNormals = flip(out.faceNormals);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * Deep copy of a mesh: vertices, edges, faces, texture coordinates, normals
34
+ * and materials. Change the copy without touching the original (or the other
35
+ * objects that share it).
36
+ *
37
+ * @template M
38
+ * @param {M} mesh
39
+ * @returns {M} A new mesh
40
+ */
41
+ export function cloneMesh(mesh) {
42
+ const out = {};
43
+ for (const k of Object.keys(mesh)) if (k !== "dirty") out[k] = copy(mesh[k]);
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Bakes a transform into a mesh's vertices, in place: scale, then rotate
49
+ * (X, then Y, then Z), then move, the same order objects use. Its own normals
50
+ * turn with it, and a mirroring scale (an odd number of negative factors)
51
+ * reverses the faces so they still face outward.
52
+ *
53
+ * Pass a scene object to bake its `pos`, `rot` and `scale` (then reset them
54
+ * on the object, or it's transformed twice).
55
+ *
56
+ * @example
57
+ * // A handle standing on the side of a mug
58
+ * transformMesh(handle, { pos: { x: 0.3, y: 0, z: 0 }, rot: { x: 0, y: 0, z: Math.PI / 2 } });
59
+ *
60
+ * @template M
61
+ * @param {M} mesh - Mesh to change
62
+ * @param {{ pos?:{x:number,y:number,z:number}, rot?:{x:number,y:number,z:number}, scale?:{x:number,y:number,z:number} }} transform
63
+ * Parts left out don't change anything
64
+ * @returns {M} The same mesh
65
+ */
66
+ export function transformMesh(mesh, transform) {
67
+ const t = fullTransform(transform);
68
+ for (const v of mesh.vertices) Object.assign(v, localToWorld(v, t));
69
+
70
+ // Normals: divide by the scale (keeps them perpendicular to stretched faces), turn, then normalize
71
+ if (mesh.normals) {
72
+ const s = t.scale;
73
+ mesh.normals = mesh.normals.map((n) => {
74
+ const r = rotateXYZ({ x: n.x / (s.x || 1), y: n.y / (s.y || 1), z: n.z / (s.z || 1) }, t.rot);
75
+ const len = Math.hypot(r.x, r.y, r.z) || 1;
76
+ return { x: r.x / len, y: r.y / len, z: r.z / len };
77
+ });
78
+ }
79
+
80
+ // A mirror turns clockwise faces counter-clockwise: reverse them (and their corner lists)
81
+ if (t.scale.x * t.scale.y * t.scale.z < 0 && mesh.faces) {
82
+ const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
83
+ mesh.faces = flip(mesh.faces);
84
+ if (mesh.faceUVs) mesh.faceUVs = flip(mesh.faceUVs);
85
+ if (mesh.faceNormals) mesh.faceNormals = flip(mesh.faceNormals);
86
+ }
87
+
88
+ mesh.dirty = true;
89
+ return mesh;
90
+ }
91
+
92
+ /**
93
+ * Moves every vertex to `fn(vertex, index)`, in place: noise on a rock,
94
+ * waves on a plane, a bend or a twist.
95
+ *
96
+ * The mesh's own normals (from an OBJ file) no longer fit the new shape, so
97
+ * they're removed and the renderers compute new ones.
98
+ *
99
+ * @example
100
+ * // A lumpy rock
101
+ * displaceMesh(makeShape("sphere"), (v) => {
102
+ * const k = 1 + 0.3 * (fbm3(v.x * 3, v.y * 3, v.z * 3) - 0.5);
103
+ * return { x: v.x * k, y: v.y * k, z: v.z * k };
104
+ * });
105
+ *
106
+ * @template M
107
+ * @param {M} mesh - Mesh to change
108
+ * @param {(v:{x:number,y:number,z:number}, index:number) => {x:number,y:number,z:number}} fn - New position of a vertex
109
+ * @returns {M} The same mesh
110
+ */
111
+ export function displaceMesh(mesh, fn) {
112
+ mesh.vertices.forEach((v, i) => {
113
+ const p = fn({ x: v.x, y: v.y, z: v.z }, i);
114
+ v.x = p.x;
115
+ v.y = p.y;
116
+ v.z = p.z;
117
+ });
118
+ delete mesh.normals;
119
+ delete mesh.faceNormals;
120
+ mesh.dirty = true;
121
+ return mesh;
122
+ }
123
+
124
+ /**
125
+ * Joins meshes into one: one object to draw instead of many, or one mesh to
126
+ * export. Faces, edges, texture coordinates, normals and materials are kept,
127
+ * with their indices shifted.
128
+ *
129
+ * An object (`{ mesh, pos, rot, scale }`, added to a scene or not) in the
130
+ * list is merged where it stands, with its transform baked in (its mesh
131
+ * isn't changed). A group is merged with all its children, placed by the
132
+ * group's own transform (not its parents').
133
+ *
134
+ * With `materialPerMesh`, the faces of the i-th mesh use material i, so an
135
+ * object with `material: [blade, guard, grip]` paints each part its own way.
136
+ * Otherwise each mesh's `faceMaterials` are kept; material names (from OBJ
137
+ * files) are joined into one list when every mesh with materials has them.
138
+ *
139
+ * The meshes are copied, never changed.
140
+ *
141
+ * @example
142
+ * const sword = mergeMeshes([blade, guard, grip], { materialPerMesh: true });
143
+ * scene.add({ mesh: sword, material: [{ fill: "#ccd" }, { fill: "#c90" }, { fill: "#642" }] });
144
+ *
145
+ * @param {Object[]} items - Meshes, or scene objects with a `mesh`
146
+ * @param {Object} [opts]
147
+ * @param {boolean} [opts.materialPerMesh=false] - Faces of the i-th mesh use material i
148
+ * @returns {import("../index.d.ts").Mesh} A new mesh
149
+ */
150
+ export function mergeMeshes(items, { materialPerMesh = false } = {}) {
151
+ // Meshes as they are; objects and groups baked where they stand, one entry per item
152
+ const meshes = items.map((it) => {
153
+ if (it.vertices) return it;
154
+ const parts = [];
155
+ forEachPlaced([it], () => null, (obj, world) => {
156
+ if (obj.mesh) parts.push(bake(obj.mesh, world));
157
+ });
158
+ return parts.length === 1 ? parts[0] : mergeMeshes(parts);
159
+ });
160
+ const some = (k) => meshes.some((m) => m[k]);
161
+
162
+ const out = { vertices: [], faces: [] };
163
+ const hasEdges = some("edges");
164
+ const hasUVs = some("faceUVs") && some("uvs");
165
+ const hasNormals = some("faceNormals") && some("normals");
166
+ const hasMats = materialPerMesh || some("faceMaterials");
167
+ const withMats = meshes.filter((m) => m.faceMaterials);
168
+ const names = !materialPerMesh && withMats.length > 0 && withMats.every((m) => m.materialNames) ? [] : null;
169
+ if (hasEdges) out.edges = [];
170
+ if (hasUVs) Object.assign(out, { uvs: [], faceUVs: [] });
171
+ if (hasNormals) Object.assign(out, { normals: [], faceNormals: [] });
172
+ if (hasMats) out.faceMaterials = [];
173
+
174
+ meshes.forEach((m, mi) => {
175
+ const vo = out.vertices.length, uo = out.uvs?.length ?? 0, no = out.normals?.length ?? 0;
176
+ const shift = (list, o) => list.map((i) => i + o);
177
+ out.vertices.push(...m.vertices.map((v) => ({ x: v.x, y: v.y, z: v.z })));
178
+ // A mesh without edges keeps showing its face outlines next to meshes that have some
179
+ if (hasEdges) out.edges.push(...(m.edges ?? m.faces ?? []).map((e) => shift(e, vo)));
180
+ if (hasUVs) out.uvs.push(...(m.uvs ?? []).map((t) => ({ u: t.u, v: t.v })));
181
+ if (hasNormals) out.normals.push(...(m.normals ?? []).map((n) => ({ x: n.x, y: n.y, z: n.z })));
182
+
183
+ // Material names: this mesh's index k → the index of its name in the joined list
184
+ const remap = names && m.materialNames
185
+ ? m.materialNames.map((n) => (names.includes(n) ? names.indexOf(n) : names.push(n) - 1))
186
+ : null;
187
+
188
+ (m.faces ?? []).forEach((f, fi) => {
189
+ out.faces.push(shift(f, vo));
190
+ if (hasUVs) {
191
+ const fu = m.uvs && m.faceUVs?.[fi];
192
+ out.faceUVs.push(fu ? shift(fu, uo) : null);
193
+ }
194
+ if (hasNormals) {
195
+ const fn = m.normals && m.faceNormals?.[fi];
196
+ out.faceNormals.push(fn ? shift(fn, no) : null);
197
+ }
198
+ if (hasMats) {
199
+ const k = m.faceMaterials?.[fi] ?? 0;
200
+ out.faceMaterials.push(materialPerMesh ? mi : remap ? (remap[k] ?? 0) : k);
201
+ }
202
+ });
203
+ });
204
+
205
+ if (names) out.materialNames = names;
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * The box around one or more meshes: `min` and `max` corners, plus its
211
+ * `center` and `size`. Scene objects (and a whole Scene) are measured where
212
+ * they stand, with their transform.
213
+ *
214
+ * @example
215
+ * const { center, size } = meshBounds(scene);
216
+ * orbit.target = center;
217
+ * orbit.distance = Math.max(size.x, size.y, size.z) * 2;
218
+ *
219
+ * @param {Object|Object[]} input - A mesh, a scene object, a list of them, or a Scene
220
+ * @returns {{ min:{x:number,y:number,z:number}, max:{x:number,y:number,z:number},
221
+ * center:{x:number,y:number,z:number}, size:{x:number,y:number,z:number} } | null}
222
+ * null when there are no vertices
223
+ */
224
+ export function meshBounds(input) {
225
+ const items = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : [input];
226
+ const min = { x: Infinity, y: Infinity, z: Infinity };
227
+ const max = { x: -Infinity, y: -Infinity, z: -Infinity };
228
+ let any = false;
229
+ // Plain meshes as they are; objects (and their children) where they're drawn
230
+ const placed = [];
231
+ const objects = [];
232
+ for (const it of items) {
233
+ if (it?.vertices) placed.push({ mesh: it, world: null });
234
+ else if (it) objects.push(it);
235
+ }
236
+ forEachPlaced(objects, (root) => (root.parent ? worldMatrix(root.parent) : null), (obj, world) => {
237
+ if (obj.mesh?.vertices) placed.push({ mesh: obj.mesh, world });
238
+ });
239
+ for (const { mesh, world } of placed) {
240
+ for (const v of mesh.vertices) {
241
+ const p = world ? transformPoint(world, v) : v;
242
+ any = true;
243
+ if (p.x < min.x) min.x = p.x;
244
+ if (p.y < min.y) min.y = p.y;
245
+ if (p.z < min.z) min.z = p.z;
246
+ if (p.x > max.x) max.x = p.x;
247
+ if (p.y > max.y) max.y = p.y;
248
+ if (p.z > max.z) max.z = p.z;
249
+ }
250
+ }
251
+ if (!any) return null;
252
+ return {
253
+ min, max,
254
+ center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 },
255
+ size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
256
+ };
257
+ }