trid3d 0.0.0-stage → 0.1.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,634 @@
1
+ import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { clear, drawInstance, edgeSegments } from "./draw.js";
3
+ import { parseColor } from "./color.js";
4
+ import { newell, cornerNormals, creaseOf } from "./normals.js";
5
+ import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
6
+
7
+ export { newell };
8
+
9
+ /** Direction light comes from when the scene doesn't set one: above, left, in front. */
10
+ export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
11
+
12
+ /** How bright a face turned fully away from the light still is (0..1). */
13
+ export const DEFAULT_AMBIENT = 0.25;
14
+
15
+ /**
16
+ * Material: how an object's faces look. Objects without one are drawn
17
+ * as wireframes using their `color` and `lineWidth`.
18
+ *
19
+ * @typedef {Object} Material
20
+ * @property {string} [fill] - Face color, any CSS color (default: the scene's color)
21
+ * @property {boolean} [shade=true] - Darken faces that turn away from the light
22
+ * @property {boolean} [wireframe=false] - Also draw the face outlines, with the object's `color` and `lineWidth`
23
+ * @property {boolean} [doubleSided=false] - Also draw faces seen from behind
24
+ * @property {boolean|number} [smooth=false] - Blend the light across faces so curved
25
+ * shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
26
+ * @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
27
+ * built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
28
+ * @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
29
+ * @property {Object[]} [effects] - Effects applied in order, like `[toon(), rim()]`.
30
+ * See trid/render/effects.js. The canvas renderer runs them once per face.
31
+ */
32
+
33
+ /**
34
+ * Draws a scene with the canvas 2D context.
35
+ *
36
+ * - Objects without a `material` are wireframes. If no object has one,
37
+ * they're drawn exactly like `drawInstance` does, as fast as before.
38
+ * - Objects with a `material` draw their mesh's `faces`. Faces and
39
+ * wireframe lines from every object are sorted far to near and drawn
40
+ * in that order (painter's algorithm), so near things cover far ones.
41
+ * - Faces seen from behind are skipped unless `doubleSided` is set.
42
+ * A face's front is the side its vertices go clockwise from.
43
+ *
44
+ * The painter's algorithm is an approximation: faces that cross each
45
+ * other, or a long face next to small ones, can be drawn in the wrong order.
46
+ * `maxFaceSize` reduces this by splitting big faces into small triangles.
47
+ * For exact results, use a renderer with a depth buffer (WebGL).
48
+ *
49
+ * A renderer is any object with a `render(scene)` method, so another
50
+ * one (for example WebGL) can replace this one through `new Scene(canvas, { renderer })`.
51
+ */
52
+ export class CanvasRenderer {
53
+ /**
54
+ * @param {Object} [opts]
55
+ * @param {number|null} [opts.maxFaceSize=null] - Split faces bigger than this
56
+ * fraction of their mesh's size (for example 0.05) into smaller triangles,
57
+ * so they sort better. Off by default: more faces means slower drawing.
58
+ */
59
+ constructor({ maxFaceSize = null } = {}) {
60
+ this.maxFaceSize = maxFaceSize;
61
+ this._shades = new Map();
62
+ this._splits = new WeakMap();
63
+ this._colors = new Map();
64
+ this._start = performance.now();
65
+ this._patterns = new Map(); // texture source → canvas pattern
66
+ this._warned = new WeakSet();
67
+ this._frame = 0;
68
+ }
69
+
70
+ /**
71
+ * Clears the canvas and draws every object of the scene.
72
+ *
73
+ * @param {import("../scene.js").Scene} scene
74
+ */
75
+ render(scene) {
76
+ const { ctx, canvas, camera } = scene;
77
+ const w = canvas.width, h = canvas.height;
78
+ clear(ctx, w, h, scene.background);
79
+ this._frame++;
80
+
81
+ // 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);
85
+ }
86
+ return;
87
+ }
88
+
89
+ const near = camera.near ?? DEFAULT_NEAR;
90
+ const light = normalize(scene.light ?? DEFAULT_LIGHT);
91
+ const ambient = scene.ambient ?? DEFAULT_AMBIENT;
92
+ const toScreen = (p) => cameraToScreen(p, camera, w, h);
93
+ const items = [];
94
+ // For effects
95
+ const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
96
+ const time = (performance.now() - this._start) / 1000;
97
+ const background = this._rgba(ctx, scene.background);
98
+
99
+ for (const obj of scene.objects) {
100
+ // 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;
102
+ const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
103
+ const world = verts.map((v) => localToWorld(v, obj));
104
+ const cam = world.map((p) => worldToCamera(p, camera));
105
+ const color = obj.color ?? scene.color;
106
+ const lineWidth = obj.lineWidth ?? scene.lineWidth;
107
+ const mat = obj.material;
108
+
109
+ if (!mat) {
110
+ for (const [i, j] of edgeSegments(obj.mesh.edges)) {
111
+ const seg = clipNear(cam[i], cam[j], near);
112
+ if (!seg) continue;
113
+ items.push({
114
+ depth: (seg[0].z + seg[1].z) / 2,
115
+ line: [toScreen(seg[0]), toScreen(seg[1])],
116
+ color, lineWidth,
117
+ });
118
+ }
119
+ continue;
120
+ }
121
+
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
+ const sourceFaces = obj.mesh.faces ?? [];
130
+ const base = obj.mesh.vertices.length;
131
+ const normalAt = (F, i) => {
132
+ if (i < base) return corners[F][sourceFaces[F].indexOf(i)];
133
+ const [a, b] = split.mids[i - base];
134
+ const na = normalAt(F, a), nb = normalAt(F, b);
135
+ return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
136
+ };
137
+ 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));
140
+ const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
141
+ return ambient + (1 - ambient) * Math.max(0, d);
142
+ };
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
+ const flatLight = (face, back) => {
163
+ const nw = normalize(newell(face.map((i) => world[i])));
164
+ const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
165
+ return ambient + (1 - ambient) * Math.max(0, d);
166
+ };
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));
172
+
173
+ const faces = split ? split.faces : sourceFaces;
174
+ for (let f = 0; f < faces.length; f++) {
175
+ const face = faces[f];
176
+ if (face.length < 3) continue;
177
+ const pts = face.map((i) => cam[i]);
178
+
179
+ // Seen from behind? (camera sits at the origin of camera space)
180
+ const n = newell(pts);
181
+ 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;
184
+
185
+ const clipped = clipPolygon(pts, near);
186
+ if (clipped.length < 3) continue;
187
+
188
+ // Which edges are real outlines (split faces have extra inner edges); null = all
189
+ const edges = split && clipped === pts ? split.masks[f] : null;
190
+
191
+ if (tex && clipped === pts) {
192
+ const F = split ? split.origins[f] : f;
193
+ const ts = face.map((i) => uvAt(F, i));
194
+ if (ts.every(Boolean)) {
195
+ let lit;
196
+ if (!shade) lit = face.map(() => 1);
197
+ else if (corners) lit = face.map((i) => lightAt(normalAt(F, i), back));
198
+ else lit = face.map(() => flatLight(face, back));
199
+ const screen = pts.map(toScreen);
200
+ const n = face.length;
201
+ for (let k = 1; k < n - 1; k++) {
202
+ const fan = (k === 1 ? 1 : 0) | 2 | (k === n - 2 ? 4 : 0);
203
+ items.push({
204
+ depth: c.z,
205
+ 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 },
207
+ lit: [lit[0], lit[k], lit[k + 1]],
208
+ outline, lineWidth,
209
+ edges: n === 3 ? edges : fan,
210
+ });
211
+ }
212
+ continue;
213
+ }
214
+ }
215
+
216
+ if (effects) {
217
+ // Normal: the face's own, or the average of its corners when smooth
218
+ let nw;
219
+ if (corners) {
220
+ const F = split ? split.origins[f] : f;
221
+ const sum = face.map((i) => toWorldNormal(normalAt(F, i)))
222
+ .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
+ nw = normalize(sum);
224
+ } else {
225
+ nw = normalize(newell(face.map((i) => world[i])));
226
+ }
227
+ if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
228
+
229
+ const position = centroid(face.map((i) => world[i]));
230
+ const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
231
+ const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
232
+ const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
233
+ const base = this._rgba(ctx, fill);
234
+ const s = {
235
+ base, normal: nw, position, local: centroid(face.map((i) => verts[i])),
236
+ view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
237
+ uv: centerUV(face.map((i) => uvAt(split ? split.origins[f] : f, i))),
238
+ depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
239
+ rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
240
+ };
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 ?? {});
243
+ const k = col.map((x) => Math.min(1, Math.max(0, x)));
244
+ items.push({
245
+ depth: c.z,
246
+ poly: clipped.map(toScreen),
247
+ fill: { css: `rgba(${Math.round(k[0] * 255)}, ${Math.round(k[1] * 255)}, ${Math.round(k[2] * 255)}, ${k[3]})`, opaque: k[3] >= 1 },
248
+ outline, lineWidth, edges,
249
+ });
250
+ continue;
251
+ }
252
+
253
+ if (corners) {
254
+ const F = split ? split.origins[f] : f;
255
+ const lit = face.map((i) => lightAt(normalAt(F, i), back));
256
+ if (clipped === pts) {
257
+ // One gradient per triangle: brightness changes linearly across a triangle
258
+ const screen = pts.map(toScreen);
259
+ const n = face.length;
260
+ for (let k = 1; k < n - 1; k++) {
261
+ const tri = [screen[0], screen[k], screen[k + 1]];
262
+ const fan = (k === 1 ? 1 : 0) | 2 | (k === n - 2 ? 4 : 0);
263
+ items.push({
264
+ depth: c.z,
265
+ poly: tri,
266
+ fill: this._gradient(ctx, fill, tri, [lit[0], lit[k], lit[k + 1]]),
267
+ outline, lineWidth,
268
+ edges: n === 3 ? edges : fan,
269
+ });
270
+ }
271
+ continue;
272
+ }
273
+ // Cut by the near plane: use the average brightness
274
+ const avg = lit.reduce((a, b) => a + b, 0) / lit.length;
275
+ items.push({ depth: c.z, poly: clipped.map(toScreen), fill: this._shade(ctx, fill, avg), outline, lineWidth, edges });
276
+ continue;
277
+ }
278
+
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
+
287
+ items.push({
288
+ depth: c.z,
289
+ poly: clipped.map(toScreen),
290
+ fill: this._shade(ctx, fill, brightness),
291
+ outline, lineWidth,
292
+ edges,
293
+ });
294
+ }
295
+ }
296
+
297
+ items.sort((a, b) => b.depth - a.depth);
298
+
299
+ for (const it of items) {
300
+ ctx.beginPath();
301
+ if (it.line) {
302
+ ctx.moveTo(it.line[0].x, it.line[0].y);
303
+ ctx.lineTo(it.line[1].x, it.line[1].y);
304
+ ctx.lineWidth = it.lineWidth;
305
+ ctx.strokeStyle = it.color;
306
+ ctx.stroke();
307
+ continue;
308
+ }
309
+
310
+ if (it.texture) {
311
+ this._drawTextured(ctx, it);
312
+ if (it.outline) {
313
+ ctx.beginPath();
314
+ ctx.moveTo(it.poly[0].x, it.poly[0].y);
315
+ for (let k = 1; k < it.poly.length; k++) ctx.lineTo(it.poly[k].x, it.poly[k].y);
316
+ ctx.closePath();
317
+ }
318
+ } else {
319
+ ctx.moveTo(it.poly[0].x, it.poly[0].y);
320
+ for (let k = 1; k < it.poly.length; k++) ctx.lineTo(it.poly[k].x, it.poly[k].y);
321
+ ctx.closePath();
322
+ ctx.fillStyle = it.fill.css;
323
+ ctx.fill();
324
+ }
325
+
326
+ if (it.outline) {
327
+ ctx.lineWidth = it.lineWidth;
328
+ ctx.strokeStyle = it.outline;
329
+ if (it.edges !== null) {
330
+ // Only the edges that belong to the original face
331
+ const p = it.poly;
332
+ ctx.beginPath();
333
+ for (let k = 0; k < p.length; k++) {
334
+ if (!(it.edges & (1 << k))) continue;
335
+ const q = p[(k + 1) % p.length];
336
+ ctx.moveTo(p[k].x, p[k].y);
337
+ ctx.lineTo(q.x, q.y);
338
+ }
339
+ }
340
+ ctx.stroke();
341
+ } else if (it.fill?.opaque) {
342
+ // A thin stroke in the face's own color hides the hairline gaps between faces
343
+ ctx.lineWidth = 1;
344
+ ctx.strokeStyle = it.fill.css;
345
+ ctx.stroke();
346
+ }
347
+ }
348
+
349
+ // 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;
351
+ }
352
+
353
+ /**
354
+ * Splits a mesh's big faces into triangles no longer than
355
+ * maxFaceSize × the mesh's size. Cached until the mesh's faces change.
356
+ *
357
+ * @returns {{ faces:number[][], masks:(number|null)[], mids:number[][] }}
358
+ * `mids[k]` is the pair of vertices whose midpoint is vertex `vertices.length + k`.
359
+ * `masks[f]` has bit k set when edge k of face f is part of an original face
360
+ * outline (null when the face wasn't split).
361
+ */
362
+ _split(mesh) {
363
+ const faces0 = mesh.faces ?? [];
364
+ const cached = this._splits.get(mesh);
365
+ if (cached && !mesh.dirty && cached.source === faces0 && cached.count === faces0.length && cached.size === this.maxFaceSize) {
366
+ return cached;
367
+ }
368
+
369
+ const pos = mesh.vertices.map((v) => ({ x: v.x, y: v.y, z: v.z }));
370
+ let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
371
+ for (const v of pos) {
372
+ min = { x: Math.min(min.x, v.x), y: Math.min(min.y, v.y), z: Math.min(min.z, v.z) };
373
+ max = { x: Math.max(max.x, v.x), y: Math.max(max.y, v.y), z: Math.max(max.z, v.z) };
374
+ }
375
+ const limit = this.maxFaceSize * Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z);
376
+ const limit2 = limit * limit;
377
+ const len2 = (a, b) => (pos[a].x - pos[b].x) ** 2 + (pos[a].y - pos[b].y) ** 2 + (pos[a].z - pos[b].z) ** 2;
378
+
379
+ const mids = [];
380
+ const midOf = new Map(); // shared midpoints, so neighbours split the same way
381
+ const midpoint = (a, b) => {
382
+ const key = a < b ? a + "," + b : b + "," + a;
383
+ let m = midOf.get(key);
384
+ if (m === undefined) {
385
+ m = pos.length;
386
+ pos.push({ x: (pos[a].x + pos[b].x) / 2, y: (pos[a].y + pos[b].y) / 2, z: (pos[a].z + pos[b].z) / 2 });
387
+ mids.push([a, b]);
388
+ midOf.set(key, m);
389
+ }
390
+ return m;
391
+ };
392
+
393
+ const faces = [], masks = [], origins = [];
394
+ const MAX_DEPTH = 10;
395
+ for (let f = 0; f < faces0.length; f++) {
396
+ const face = faces0[f];
397
+ if (face.length < 3) continue;
398
+ const n = face.length;
399
+ let big = false;
400
+ for (let i = 0; i < n && !big; i++) big = len2(face[i], face[(i + 1) % n]) > limit2;
401
+ if (!big || !(limit > 0)) {
402
+ faces.push(face);
403
+ masks.push(null);
404
+ origins.push(f);
405
+ continue;
406
+ }
407
+
408
+ // Fan into triangles; mask bits mark edges on the face's outline (ab = 1, bc = 2, ca = 4)
409
+ const stack = [];
410
+ for (let i = 1; i < n - 1; i++) {
411
+ const mask = (i === 1 ? 1 : 0) | 2 | (i === n - 2 ? 4 : 0);
412
+ stack.push([face[0], face[i], face[i + 1], mask, 0]);
413
+ }
414
+
415
+ // Cut each triangle at the middle of its longest edge until it's small enough
416
+ while (stack.length) {
417
+ const [a, b, c, mask, depth] = stack.pop();
418
+ const ab = len2(a, b), bc = len2(b, c), ca = len2(c, a);
419
+ const longest = Math.max(ab, bc, ca);
420
+ if (longest <= limit2 || depth >= MAX_DEPTH) {
421
+ faces.push([a, b, c]);
422
+ masks.push(mask);
423
+ origins.push(f);
424
+ continue;
425
+ }
426
+ // Rotate so the longest edge is a-b, keeping the mask in step
427
+ let t = [a, b, c], m = mask;
428
+ if (bc === longest) { t = [b, c, a]; m = ((mask >> 1) | (mask << 2)) & 7; }
429
+ else if (ca === longest) { t = [c, a, b]; m = ((mask >> 2) | (mask << 1)) & 7; }
430
+ const [p, q, r] = t;
431
+ const mid = midpoint(p, q);
432
+ const onAB = m & 1, onBC = (m >> 1) & 1, onCA = (m >> 2) & 1;
433
+ stack.push([p, mid, r, onAB | (onCA << 2), depth + 1]); // p-mid, mid-r (inner), r-p
434
+ stack.push([mid, q, r, onAB | (onBC << 1), depth + 1]); // mid-q, q-r, r-mid (inner)
435
+ }
436
+ }
437
+
438
+ const out = { faces, masks, mids, origins, source: faces0, count: faces0.length, size: this.maxFaceSize };
439
+ this._splits.set(mesh, out);
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * A fill for a triangle whose corners have different brightness:
445
+ * a linear gradient along the direction brightness changes fastest.
446
+ * Exact, because brightness changes linearly across a triangle.
447
+ */
448
+ _gradient(ctx, color, tri, lit) {
449
+ const g = gradientLine(tri, lit);
450
+ if (!g) return this._shade(ctx, color, (Math.min(...lit) + Math.max(...lit)) / 2);
451
+ const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
452
+ const a = this._shade(ctx, color, g.lo), b = this._shade(ctx, color, g.hi);
453
+ grad.addColorStop(0, a.css);
454
+ grad.addColorStop(1, b.css);
455
+ return { css: grad, opaque: a.opaque };
456
+ }
457
+
458
+ /**
459
+ * Draws a textured triangle: the image as a pattern mapped onto the
460
+ * triangle's corners, tinted by the fill color, then darkened by the light.
461
+ */
462
+ _drawTextured(ctx, it) {
463
+ const { src, uvs, pixelated, tint } = it.texture;
464
+ const { w: W, h: H } = textureSize(src);
465
+ const [p0, p1, p2] = it.poly;
466
+
467
+ // Image pixels of each corner (v goes up, image rows go down)
468
+ const U = uvs.map((t) => t.u * W), V = uvs.map((t) => (1 - t.v) * H);
469
+ const du1 = U[1] - U[0], dv1 = V[1] - V[0], du2 = U[2] - U[0], dv2 = V[2] - V[0];
470
+ const det = du1 * dv2 - du2 * dv1;
471
+
472
+ const path = (poly) => {
473
+ ctx.beginPath();
474
+ ctx.moveTo(poly[0].x, poly[0].y);
475
+ for (let k = 1; k < poly.length; k++) ctx.lineTo(poly[k].x, poly[k].y);
476
+ ctx.closePath();
477
+ };
478
+
479
+ // Slightly enlarged so neighbouring triangles leave no hairline gap. The image,
480
+ // tint and light all use this shape: the next triangle paints over the overlap
481
+ // with its own image and light, so nothing gets darkened twice.
482
+ const shape = grow(it.poly, 0.5);
483
+ path(shape);
484
+ if (Math.abs(det) > 1e-12) {
485
+ // The transform that sends image pixel (U, V) to the screen
486
+ const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
487
+ const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
488
+ const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
489
+ const pattern = this._pattern(ctx, src);
490
+ 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
+ ctx.imageSmoothingEnabled = !pixelated;
492
+ ctx.fillStyle = pattern;
493
+ } else {
494
+ ctx.fillStyle = tint;
495
+ }
496
+ ctx.fill();
497
+ ctx.imageSmoothingEnabled = true;
498
+
499
+ // Tint: multiply by the fill color
500
+ const t = this._rgba(ctx, tint);
501
+ if (t[0] < 1 || t[1] < 1 || t[2] < 1) {
502
+ ctx.globalCompositeOperation = "multiply";
503
+ ctx.fillStyle = tint;
504
+ ctx.fill();
505
+ ctx.globalCompositeOperation = "source-over";
506
+ }
507
+
508
+ // Light: black on top, as see-through as the face is bright
509
+ const lo = Math.min(...it.lit), hi = Math.max(...it.lit);
510
+ if (lo >= 0.999) return;
511
+ const g = gradientLine(it.poly, it.lit);
512
+ if (g) {
513
+ const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
514
+ grad.addColorStop(0, `rgba(0, 0, 0, ${1 - g.lo})`);
515
+ grad.addColorStop(1, `rgba(0, 0, 0, ${1 - g.hi})`);
516
+ ctx.fillStyle = grad;
517
+ } else {
518
+ ctx.fillStyle = `rgba(0, 0, 0, ${1 - (lo + hi) / 2})`;
519
+ }
520
+ ctx.fill();
521
+ }
522
+
523
+ /** A repeating pattern for a texture (made again each frame for canvases and videos). */
524
+ _pattern(ctx, src) {
525
+ 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 };
528
+ this._patterns.set(src, p);
529
+ }
530
+ return p.pattern;
531
+ }
532
+
533
+ /** [r, g, b, a] from 0 to 1 for a CSS color, cached. */
534
+ _rgba(ctx, css) {
535
+ let c = this._colors.get(css);
536
+ if (!c) {
537
+ const [r, g, b, a] = parseColor(ctx, css);
538
+ c = [r / 255, g / 255, b / 255, a];
539
+ this._colors.set(css, c);
540
+ }
541
+ return c;
542
+ }
543
+
544
+ /** CSS color for `color` at a brightness, cached (brightness is rounded to 1/255). */
545
+ _shade(ctx, color, brightness) {
546
+ const level = Math.round(brightness * 255);
547
+ const key = color + "|" + level;
548
+ let out = this._shades.get(key);
549
+ if (!out) {
550
+ const [r, g, b, a] = parseColor(ctx, color);
551
+ const k = level / 255;
552
+ out = {
553
+ css: `rgba(${Math.round(r * k)}, ${Math.round(g * k)}, ${Math.round(b * k)}, ${a})`,
554
+ opaque: a >= 1,
555
+ };
556
+ this._shades.set(key, out);
557
+ }
558
+ return out;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Where a linear gradient must start and end so its value matches `lit`
564
+ * at the triangle's three corners. Null when the values are all the same.
565
+ */
566
+ function gradientLine(tri, lit) {
567
+ const [p0, p1, p2] = tri;
568
+ const d1x = p1.x - p0.x, d1y = p1.y - p0.y, d2x = p2.x - p0.x, d2y = p2.y - p0.y;
569
+ const db1 = lit[1] - lit[0], db2 = lit[2] - lit[0];
570
+ const det = d1x * d2y - d1y * d2x;
571
+ const lo = Math.min(...lit), hi = Math.max(...lit);
572
+ if (Math.abs(det) < 1e-9 || hi - lo < 1 / 255) return null;
573
+
574
+ // Value = lit[0] + g · (point - p0)
575
+ const gx = (db1 * d2y - db2 * d1y) / det;
576
+ const gy = (db2 * d1x - db1 * d2x) / det;
577
+ const g2 = gx * gx + gy * gy;
578
+ const s = (lo - lit[0]) / g2, e = (hi - lit[0]) / g2;
579
+ return { x0: p0.x + gx * s, y0: p0.y + gy * s, x1: p0.x + gx * e, y1: p0.y + gy * e, lo, hi };
580
+ }
581
+
582
+ /** A polygon pushed outward from its center by `px` pixels. */
583
+ function grow(poly, px) {
584
+ const cx = poly.reduce((a, p) => a + p.x, 0) / poly.length;
585
+ const cy = poly.reduce((a, p) => a + p.y, 0) / poly.length;
586
+ return poly.map((p) => {
587
+ const dx = p.x - cx, dy = p.y - cy;
588
+ const len = Math.hypot(dx, dy) || 1;
589
+ return { x: p.x + (dx / len) * px, y: p.y + (dy / len) * px };
590
+ });
591
+ }
592
+
593
+ /** Average of texture coordinates, or 0, 0 when some are missing. */
594
+ function centerUV(ts) {
595
+ if (!ts.every(Boolean)) return { u: 0, v: 0 };
596
+ return { u: ts.reduce((a, t) => a + t.u, 0) / ts.length, v: ts.reduce((a, t) => a + t.v, 0) / ts.length };
597
+ }
598
+
599
+ /** The mesh's vertices followed by the midpoints listed in `mids`. */
600
+ function withMidpoints(vertices, mids) {
601
+ const out = vertices.slice();
602
+ for (const [a, b] of mids) {
603
+ const p = out[a], q = out[b];
604
+ out.push({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, z: (p.z + q.z) / 2 });
605
+ }
606
+ return out;
607
+ }
608
+
609
+ function centroid(pts) {
610
+ let x = 0, y = 0, z = 0;
611
+ for (const p of pts) { x += p.x; y += p.y; z += p.z; }
612
+ return { x: x / pts.length, y: y / pts.length, z: z / pts.length };
613
+ }
614
+
615
+ function normalize(v) {
616
+ const len = Math.hypot(v.x, v.y, v.z) || 1;
617
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
618
+ }
619
+
620
+ /** Cuts a camera-space polygon at z = near, keeping the part in front. */
621
+ function clipPolygon(pts, near) {
622
+ if (pts.every((p) => p.z >= near)) return pts;
623
+ const out = [];
624
+ for (let i = 0; i < pts.length; i++) {
625
+ const a = pts[i], b = pts[(i + 1) % pts.length];
626
+ const aIn = a.z >= near, bIn = b.z >= near;
627
+ if (aIn) out.push(a);
628
+ if (aIn !== bIn) {
629
+ const t = (near - a.z) / (b.z - a.z);
630
+ out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: near });
631
+ }
632
+ }
633
+ return out;
634
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Turns any CSS color into [r, g, b, a], using the canvas to understand it.
3
+ * Unknown colors come out black.
4
+ *
5
+ * @param {CanvasRenderingContext2D} ctx - Any 2D context (only used to read the color)
6
+ * @param {string} color - Any CSS color
7
+ * @returns {number[]} [r, g, b, a] with r, g, b in 0..255 and a in 0..1
8
+ */
9
+ export function parseColor(ctx, color) {
10
+ ctx.save();
11
+ ctx.fillStyle = "#000000";
12
+ ctx.fillStyle = color;
13
+ const s = String(ctx.fillStyle);
14
+ ctx.restore();
15
+
16
+ if (s[0] === "#") {
17
+ const hex = s.slice(1);
18
+ return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)).concat(1);
19
+ }
20
+ const nums = (s.match(/[\d.]+/g) ?? []).map(Number);
21
+ return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0, nums[3] ?? 1];
22
+ }