trid3d 0.1.1 → 0.3.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.
@@ -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,11 +1,28 @@
1
- import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
2
- import { clear, drawInstance, edgeSegments } from "./draw.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";
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";
8
+ import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
6
9
 
7
10
  export { newell };
8
11
 
12
+ /**
13
+ * An object's materials as a list (`material` can be one material or a
14
+ * list of them, picked per face by `mesh.faceMaterials`). Null when the
15
+ * object is a wireframe.
16
+ *
17
+ * @returns {Material[]|null}
18
+ */
19
+ export function materialsOf(obj) {
20
+ const m = obj.material;
21
+ if (!m) return null;
22
+ if (!Array.isArray(m)) return [m];
23
+ return m.length && m[0] ? m : null;
24
+ }
25
+
9
26
  /** Direction light comes from when the scene doesn't set one: above, left, in front. */
10
27
  export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
11
28
 
@@ -16,6 +33,10 @@ export const DEFAULT_AMBIENT = 0.25;
16
33
  * Material: how an object's faces look. Objects without one are drawn
17
34
  * as wireframes using their `color` and `lineWidth`.
18
35
  *
36
+ * An object's `material` can also be a list: each face then uses the
37
+ * entry `mesh.faceMaterials[face]` points at (the first one by default).
38
+ * Built-in cubes and boxes number their sides 0 to 5.
39
+ *
19
40
  * @typedef {Object} Material
20
41
  * @property {string} [fill] - Face color, any CSS color (default: the scene's color)
21
42
  * @property {boolean} [shade=true] - Darken faces that turn away from the light
@@ -25,7 +46,14 @@ export const DEFAULT_AMBIENT = 0.25;
25
46
  * shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
26
47
  * @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
27
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
28
51
  * @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
52
+ * @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
53
+ * from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
54
+ * @property {number|{u:number,v:number}} [textureRepeat=1] - How many times the image repeats across
55
+ * @property {{u:number,v:number}} [textureOffset] - Moves the image (1 = its whole width or height)
56
+ * @property {number} [textureRotation=0] - Turns the image around its center, in radians
29
57
  * @property {Object[]} [effects] - Effects applied in order, like `[toon(), rim()]`.
30
58
  * See trid/render/effects.js. The canvas renderer runs them once per face.
31
59
  */
@@ -62,7 +90,7 @@ export class CanvasRenderer {
62
90
  this._splits = new WeakMap();
63
91
  this._colors = new Map();
64
92
  this._start = performance.now();
65
- this._patterns = new Map(); // texture source → canvas pattern
93
+ this._patterns = new WeakMap(); // texture source → canvas pattern (freed with the source)
66
94
  this._warned = new WeakSet();
67
95
  this._frame = 0;
68
96
  }
@@ -78,11 +106,15 @@ export class CanvasRenderer {
78
106
  clear(ctx, w, h, scene.background);
79
107
  this._frame++;
80
108
 
109
+ // Every object with a mesh, groups' children included, with its world matrix
110
+ const drawn = worldObjects(scene).filter((o) => o.mesh);
111
+
81
112
  // Wireframes only: nothing to sort
82
- if (!scene.objects.some((o) => o.material)) {
83
- for (const obj of scene.objects) {
84
- 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);
85
116
  }
117
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
86
118
  return;
87
119
  }
88
120
 
@@ -96,18 +128,18 @@ export class CanvasRenderer {
96
128
  const time = (performance.now() - this._start) / 1000;
97
129
  const background = this._rgba(ctx, scene.background);
98
130
 
99
- for (const obj of scene.objects) {
131
+ for (const obj of drawn) {
132
+ const mats = materialsOf(obj);
100
133
  // Split big faces (cached per mesh); extra vertices are midpoints of existing ones
101
- const split = obj.material && this.maxFaceSize ? this._split(obj.mesh) : null;
134
+ const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
102
135
  const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
103
- const world = verts.map((v) => localToWorld(v, obj));
136
+ const world = verts.map((v) => transformPoint(obj.world, v));
104
137
  const cam = world.map((p) => worldToCamera(p, camera));
105
138
  const color = obj.color ?? scene.color;
106
139
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
107
- const mat = obj.material;
108
140
 
109
- if (!mat) {
110
- for (const [i, j] of edgeSegments(obj.mesh.edges)) {
141
+ if (!mats) {
142
+ for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
111
143
  const seg = clipNear(cam[i], cam[j], near);
112
144
  if (!seg) continue;
113
145
  items.push({
@@ -119,68 +151,90 @@ export class CanvasRenderer {
119
151
  continue;
120
152
  }
121
153
 
122
- const fill = mat.fill ?? (mat.texture ? "#ffffff" : scene.color);
123
- const shade = mat.shade !== false;
124
- const outline = mat.wireframe ? color : null;
125
-
126
- // Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
127
- const crease = shade ? creaseOf(mat.smooth) : null;
128
- const corners = crease ? cornerNormals(obj.mesh, crease) : null;
129
154
  const sourceFaces = obj.mesh.faces ?? [];
130
155
  const base = obj.mesh.vertices.length;
131
- const normalAt = (F, i) => {
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
+ };
163
+
164
+ // Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
165
+ const normalAt = (corners, F, i) => {
132
166
  if (i < base) return corners[F][sourceFaces[F].indexOf(i)];
133
167
  const [a, b] = split.mids[i - base];
134
- const na = normalAt(F, a), nb = normalAt(F, b);
168
+ const na = normalAt(corners, F, a), nb = normalAt(corners, F, b);
135
169
  return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
136
170
  };
137
171
  const lightAt = (n, back) => {
138
- // Same steps as the vertices: undo the scale, then rotate (keeps normals right on stretched objects)
139
- 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);
140
173
  const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
141
174
  return ambient + (1 - ambient) * Math.max(0, d);
142
175
  };
143
-
144
- // Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
145
- const meshUVs = obj.mesh.uvs, faceUVs = obj.mesh.faceUVs;
146
- let tex = null;
147
- if (mat.texture && textureReady(mat.texture)) {
148
- if (faceUVs && meshUVs) tex = mat.texture;
149
- else if (!this._warned.has(obj.mesh)) {
150
- this._warned.add(obj.mesh);
151
- console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
152
- }
153
- }
154
- const uvAt = (F, i) => {
155
- const t = faceUVs?.[F];
156
- if (!t || !meshUVs) return null;
157
- if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
158
- const [a, b] = split.mids[i - base];
159
- const ta = uvAt(F, a), tb = uvAt(F, b);
160
- return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
161
- };
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
-
168
- // Effects run once per face, at its center (see trid/render/effects.js)
169
- const effects = mat.effects?.length ? mat.effects : null;
170
- const toWorldNormal = (n) =>
171
- 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);
182
+
183
+ // Everything a material needs, worked out once per object and frame
184
+ const prepare = (mat) => {
185
+ const shade = mat.shade !== false;
186
+ const crease = shade ? creaseOf(mat.smooth) : null;
187
+ // Texture coordinates: the mesh's own, or made from its shape (mat.mapping)
188
+ const { uvs: meshUVs, faceUVs } = uvsFor(obj.mesh, mat.mapping);
189
+ const xf = uvTransform(mat);
190
+ const rawUV = (F, i) => {
191
+ const t = faceUVs?.[F];
192
+ if (!t || !meshUVs) return null;
193
+ if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
194
+ const [a, b] = split.mids[i - base];
195
+ const ta = rawUV(F, a), tb = rawUV(F, b);
196
+ return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
197
+ };
198
+ let tex = null;
199
+ if (mat.texture && textureReady(mat.texture)) {
200
+ if (faceUVs && meshUVs) tex = mat.texture;
201
+ else if (!this._warned.has(obj.mesh)) {
202
+ this._warned.add(obj.mesh);
203
+ console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
204
+ }
205
+ }
206
+ return {
207
+ mat, shade, tex,
208
+ fill: mat.fill ?? (mat.texture ? "#ffffff" : scene.color),
209
+ outline: mat.wireframe ? color : null,
210
+ corners: crease ? cornerNormals(obj.mesh, crease) : null,
211
+ effects: mat.effects?.length ? mat.effects : null,
212
+ uvAt: (F, i) => applyUVTransform(xf, rawUV(F, i)),
213
+ };
214
+ };
215
+ // Which material each face uses (mesh.faceMaterials), prepared when first needed
216
+ const looks = new Array(mats.length);
217
+ const faceMats = obj.mesh.faceMaterials;
218
+ const lookOf = (F) => {
219
+ let k = faceMats?.[F] ?? 0;
220
+ if (!(k >= 0 && k < mats.length && mats[k])) k = 0;
221
+ return (looks[k] ??= prepare(mats[k]));
222
+ };
172
223
 
173
224
  const faces = split ? split.faces : sourceFaces;
174
225
  for (let f = 0; f < faces.length; f++) {
175
226
  const face = faces[f];
176
227
  if (face.length < 3) continue;
228
+ const F = split ? split.origins[f] : f; // the mesh face this one comes from
229
+ const L = lookOf(F);
230
+ const { fill, shade, outline, corners } = L;
177
231
  const pts = face.map((i) => cam[i]);
178
232
 
179
233
  // Seen from behind? (camera sits at the origin of camera space)
180
234
  const n = newell(pts);
181
235
  const c = centroid(pts);
182
- const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
183
- if (back && !mat.doubleSided) continue;
236
+ const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
237
+ if (back && !L.mat.doubleSided) continue;
184
238
 
185
239
  const clipped = clipPolygon(pts, near);
186
240
  if (clipped.length < 3) continue;
@@ -188,13 +242,13 @@ export class CanvasRenderer {
188
242
  // Which edges are real outlines (split faces have extra inner edges); null = all
189
243
  const edges = split && clipped === pts ? split.masks[f] : null;
190
244
 
191
- if (tex && clipped === pts) {
192
- const F = split ? split.origins[f] : f;
193
- const ts = face.map((i) => uvAt(F, i));
245
+ // Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
246
+ if (L.tex && clipped === pts) {
247
+ const ts = face.map((i) => L.uvAt(F, i));
194
248
  if (ts.every(Boolean)) {
195
249
  let lit;
196
250
  if (!shade) lit = face.map(() => 1);
197
- else if (corners) lit = face.map((i) => lightAt(normalAt(F, i), back));
251
+ else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
198
252
  else lit = face.map(() => flatLight(face, back));
199
253
  const screen = pts.map(toScreen);
200
254
  const n = face.length;
@@ -203,7 +257,7 @@ export class CanvasRenderer {
203
257
  items.push({
204
258
  depth: c.z,
205
259
  poly: [screen[0], screen[k], screen[k + 1]],
206
- texture: { src: tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!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 },
207
261
  lit: [lit[0], lit[k], lit[k + 1]],
208
262
  outline, lineWidth,
209
263
  edges: n === 3 ? edges : fan,
@@ -213,16 +267,16 @@ export class CanvasRenderer {
213
267
  }
214
268
  }
215
269
 
216
- if (effects) {
270
+ // Effects run once per face, at its center (see trid/render/effects.js)
271
+ if (L.effects) {
217
272
  // Normal: the face's own, or the average of its corners when smooth
218
273
  let nw;
219
274
  if (corners) {
220
- const F = split ? split.origins[f] : f;
221
- const sum = face.map((i) => toWorldNormal(normalAt(F, i)))
275
+ const sum = face.map((i) => toWorldNormal(normalAt(corners, F, i)))
222
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 });
223
277
  nw = normalize(sum);
224
278
  } else {
225
- nw = normalize(newell(face.map((i) => world[i])));
279
+ nw = flatNormal(face);
226
280
  }
227
281
  if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
228
282
 
@@ -230,16 +284,16 @@ export class CanvasRenderer {
230
284
  const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
231
285
  const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
232
286
  const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
233
- const base = this._rgba(ctx, fill);
287
+ const rgba = this._rgba(ctx, fill);
234
288
  const s = {
235
- base, normal: nw, position, local: centroid(face.map((i) => verts[i])),
289
+ base: rgba, normal: nw, position, local: centroid(face.map((i) => verts[i])),
236
290
  view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
237
- uv: centerUV(face.map((i) => uvAt(split ? split.origins[f] : f, i))),
291
+ uv: centerUV(face.map((i) => L.uvAt(F, i))),
238
292
  depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
239
293
  rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
240
294
  };
241
- let col = [base[0] * lit, base[1] * lit, base[2] * lit, base[3]];
242
- for (const e of effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
295
+ let col = [rgba[0] * lit, rgba[1] * lit, rgba[2] * lit, rgba[3]];
296
+ for (const e of L.effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
243
297
  const k = col.map((x) => Math.min(1, Math.max(0, x)));
244
298
  items.push({
245
299
  depth: c.z,
@@ -251,8 +305,7 @@ export class CanvasRenderer {
251
305
  }
252
306
 
253
307
  if (corners) {
254
- const F = split ? split.origins[f] : f;
255
- const lit = face.map((i) => lightAt(normalAt(F, i), back));
308
+ const lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
256
309
  if (clipped === pts) {
257
310
  // One gradient per triangle: brightness changes linearly across a triangle
258
311
  const screen = pts.map(toScreen);
@@ -276,14 +329,7 @@ export class CanvasRenderer {
276
329
  continue;
277
330
  }
278
331
 
279
- let brightness = 1;
280
- if (shade) {
281
- const nw = normalize(newell(face.map((i) => world[i])));
282
- const s = back ? -1 : 1;
283
- const d = s * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
284
- brightness = ambient + (1 - ambient) * Math.max(0, d);
285
- }
286
-
332
+ const brightness = shade ? flatLight(face, back) : 1;
287
333
  items.push({
288
334
  depth: c.z,
289
335
  poly: clipped.map(toScreen),
@@ -347,7 +393,7 @@ export class CanvasRenderer {
347
393
  }
348
394
 
349
395
  // Meshes marked as changed have been redrawn from their new data
350
- 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;
351
397
  }
352
398
 
353
399
  /**
@@ -460,7 +506,7 @@ export class CanvasRenderer {
460
506
  * triangle's corners, tinted by the fill color, then darkened by the light.
461
507
  */
462
508
  _drawTextured(ctx, it) {
463
- const { src, uvs, pixelated, tint } = it.texture;
509
+ const { src, uvs, pixelated, live, tint } = it.texture;
464
510
  const { w: W, h: H } = textureSize(src);
465
511
  const [p0, p1, p2] = it.poly;
466
512
 
@@ -486,7 +532,7 @@ export class CanvasRenderer {
486
532
  const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
487
533
  const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
488
534
  const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
489
- const pattern = this._pattern(ctx, src);
535
+ const pattern = this._pattern(ctx, src, live);
490
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]]));
491
537
  ctx.imageSmoothingEnabled = !pixelated;
492
538
  ctx.fillStyle = pattern;
@@ -520,11 +566,15 @@ export class CanvasRenderer {
520
566
  ctx.fill();
521
567
  }
522
568
 
523
- /** A repeating pattern for a texture (made again each frame for canvases and videos). */
524
- _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) {
525
574
  let p = this._patterns.get(src);
526
- if (!p || (textureIsLive(src) && p.frame !== this._frame)) {
527
- 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 };
528
578
  this._patterns.set(src, p);
529
579
  }
530
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.
@@ -59,11 +60,11 @@ export function line(ctx, p1, p2, color, width = 4) {
59
60
  * - An edge with 3+ indices is a closed loop: the last index
60
61
  * connects back to the first
61
62
  *
62
- * @param {number[][]} edges - Edge definitions using vertex indices
63
+ * @param {number[][]} [edges] - Edge definitions using vertex indices (none: no segments)
63
64
  * @returns {Generator<[number, number]>} Index pairs to connect
64
65
  */
65
66
  export function* edgeSegments(edges) {
66
- for (const e of edges) {
67
+ for (const e of edges ?? []) {
67
68
  const closed = e.length > 2;
68
69
  const count = closed ? e.length : e.length - 1;
69
70
  for (let i = 0; i < count; i++) {
@@ -72,6 +73,17 @@ export function* edgeSegments(edges) {
72
73
  }
73
74
  }
74
75
 
76
+ /**
77
+ * The lines to draw for a mesh shown as a wireframe: its `edges`, or the
78
+ * outline of each face when it has no `edges` (a face-only mesh).
79
+ *
80
+ * @param {{ edges?: number[][], faces?: number[][] }} mesh
81
+ * @returns {number[][]} Edge definitions (faces are closed loops)
82
+ */
83
+ export function wireEdges(mesh) {
84
+ return mesh.edges ?? mesh.faces ?? [];
85
+ }
86
+
75
87
  /**
76
88
  * Draws a wireframe object by connecting projected vertices
77
89
  * using index-based edge definitions.
@@ -115,7 +127,7 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
115
127
  *
116
128
  * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
117
129
  * @param {{
118
- * mesh:{ vertices:{x:number,y:number,z:number}[], edges:number[][] },
130
+ * mesh:{ vertices:{x:number,y:number,z:number}[], edges?:number[][], faces?:number[][] },
119
131
  * pos:{x:number,y:number,z:number},
120
132
  * rot:{x:number,y:number,z:number},
121
133
  * scale:{x:number,y:number,z:number}
@@ -125,16 +137,19 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
125
137
  * @param {number} h - Canvas height
126
138
  * @param {string} color - Wireframe color
127
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
128
142
  */
129
- export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
143
+ export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3, world) {
130
144
  const near = camera.near ?? DEFAULT_NEAR;
131
- 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));
132
147
 
133
148
  ctx.lineWidth = lineWidth;
134
149
  ctx.strokeStyle = color;
135
150
 
136
151
  ctx.beginPath();
137
- for (const [i, j] of edgeSegments(inst.mesh.edges)) {
152
+ for (const [i, j] of edgeSegments(wireEdges(inst.mesh))) {
138
153
  const seg = clipNear(ptsCam[i], ptsCam[j], near);
139
154
  if (!seg) continue;
140
155
  const a = cameraToScreen(seg[0], camera, w, h);