trid3d 0.4.0 → 0.6.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,215 @@
1
+ /**
2
+ * Catmull-Clark subdivision: a rough cage of a few faces becomes a smooth
3
+ * surface, the way modelers use a subdivision surface modifier.
4
+ */
5
+ import { newell } from "../render/normals.js";
6
+ import { samePlace, defaultEps, distinctCorners } from "./cleanup.js";
7
+
8
+ const edgeKey = (a, b) => (a < b ? `${a},${b}` : `${b},${a}`);
9
+
10
+ /**
11
+ * One level of Catmull-Clark on points of any size (3D positions or 2D
12
+ * texture coordinates): every face becomes one quad per corner.
13
+ *
14
+ * Sharp edges (in `sharp`, plus open borders and edges shared by 3 faces or
15
+ * more) stay creased: their new points are midpoints, a vertex on 2 of them
16
+ * slides only along them, and a vertex on 3 or more stays put (a corner).
17
+ * Sharp edges stay sharp in the result.
18
+ *
19
+ * @param {number[][]} P Points
20
+ * @param {number[][]} F Faces (indices into P)
21
+ * @param {Set<string>} sharp Sharp edges as "a,b" keys (a < b)
22
+ * @param {boolean} smooth false: only split the faces
23
+ */
24
+ function step(P, F, sharp, smooth) {
25
+ const dims = P[0]?.length ?? 0;
26
+ const avg = (pts) => {
27
+ const out = new Array(dims).fill(0);
28
+ for (const p of pts) for (let d = 0; d < dims; d++) out[d] += p[d] / pts.length;
29
+ return out;
30
+ };
31
+ const facePts = F.map((f) => avg(f.map((i) => P[i])));
32
+
33
+ // Edges, with the faces on each side
34
+ const edges = new Map();
35
+ F.forEach((f, n) => f.forEach((a, c) => {
36
+ const b = f[(c + 1) % f.length];
37
+ const k = edgeKey(a, b);
38
+ const e = edges.get(k);
39
+ if (e) e.faces.push(n);
40
+ else edges.set(k, { a: Math.min(a, b), b: Math.max(a, b), faces: [n] });
41
+ }));
42
+ const isSharp = (k, e) => sharp.has(k) || e.faces.length !== 2;
43
+
44
+ // New points: the old ones moved, then one per face, then one per edge
45
+ const vFaces = P.map(() => []), vEdges = P.map(() => []);
46
+ F.forEach((f, n) => f.forEach((i) => vFaces[i].push(n)));
47
+ for (const [k, e] of edges) {
48
+ vEdges[e.a].push(k);
49
+ vEdges[e.b].push(k);
50
+ }
51
+ const out = P.map((p, i) => {
52
+ if (!smooth || !vFaces[i].length) return p.slice();
53
+ const creased = vEdges[i].filter((k) => isSharp(k, edges.get(k)));
54
+ // A corner: 3 sharp edges or more, or the corner of an open sheet (one face only)
55
+ if (creased.length > 2 || (creased.length === 2 && vFaces[i].length === 1)) return p.slice();
56
+ if (creased.length === 2) {
57
+ const [a, b] = creased.map((k) => { const e = edges.get(k); return P[e.a === i ? e.b : e.a]; });
58
+ return p.map((x, d) => 0.75 * x + 0.125 * (a[d] + b[d]));
59
+ }
60
+ const n = vFaces[i].length;
61
+ const q = avg(vFaces[i].map((f) => facePts[f]));
62
+ const r = avg(vEdges[i].map((k) => { const e = edges.get(k); return avg([P[e.a], P[e.b]]); }));
63
+ return p.map((x, d) => (q[d] + 2 * r[d] + (n - 3) * x) / n);
64
+ });
65
+ const faceBase = out.length;
66
+ out.push(...facePts);
67
+ const edgeIndex = new Map();
68
+ for (const [k, e] of edges) {
69
+ const pt = smooth && !isSharp(k, e) ? avg([P[e.a], P[e.b], facePts[e.faces[0]], facePts[e.faces[1]]]) : avg([P[e.a], P[e.b]]);
70
+ edgeIndex.set(k, out.push(pt) - 1);
71
+ }
72
+
73
+ // One quad per corner, in the face's own direction
74
+ const faces = [], parent = [];
75
+ F.forEach((f, n) => {
76
+ const k = f.length;
77
+ for (let c = 0; c < k; c++) {
78
+ const prev = f[(c + k - 1) % k], next = f[(c + 1) % k];
79
+ faces.push([f[c], edgeIndex.get(edgeKey(f[c], next)), faceBase + n, edgeIndex.get(edgeKey(prev, f[c]))]);
80
+ parent.push(n);
81
+ }
82
+ });
83
+
84
+ // Sharp edges are split in two, both halves sharp
85
+ const nextSharp = new Set();
86
+ for (const k of sharp) {
87
+ const e = edges.get(k);
88
+ if (!e) continue;
89
+ const m = edgeIndex.get(k);
90
+ nextSharp.add(edgeKey(e.a, m));
91
+ nextSharp.add(edgeKey(m, e.b));
92
+ }
93
+ return { P: out, F: faces, sharp: nextSharp, parent };
94
+ }
95
+
96
+ /**
97
+ * Smooths a mesh by subdividing it (Catmull-Clark): each level splits every
98
+ * face into one quad per corner (about 4 times the faces) and rounds the
99
+ * surface, so a rough cage of a few boxes becomes a smooth organic shape.
100
+ * Returns a new mesh; the original isn't changed.
101
+ *
102
+ * - Creases: edges sharper than `creaseAngle` (degrees), and edges in
103
+ * `creases`, stay hard. Open borders stay where they are (smoothed along
104
+ * the border only).
105
+ * - Texture coordinates are smoothed with the surface, keeping their seams
106
+ * (`uvs: "linear"` only splits them). Faces without them drop them all.
107
+ * - Materials: each new face keeps its face's material. The mesh's own
108
+ * normals are dropped (the renderers compute new ones).
109
+ * - Vertices at the same place are treated as one, so an unwelded mesh is
110
+ * still smoothed as one surface; the result is welded.
111
+ *
112
+ * @example
113
+ * // A rough cage: a box with a box on top, smoothed twice
114
+ * const cage = mergeMeshes([makeShape("box", { w: 0.6, h: 0.4, d: 0.4 }), { mesh: makeShape("cube", { size: 0.3 }), pos: { x: 0, y: 0.35, z: 0 } }]);
115
+ * const body = subdivideMesh(cage, { levels: 2 });
116
+ *
117
+ * @param {import("../index.d.ts").Mesh} mesh
118
+ * @param {{ levels?: number, smooth?: boolean, creaseAngle?: number, creases?: number[][], uvs?: "smooth"|"linear" }} [opts]
119
+ * @returns {import("../index.d.ts").Mesh}
120
+ */
121
+ export function subdivideMesh(mesh, { levels = 1, smooth = true, creaseAngle, creases, uvs: uvMode = "smooth" } = {}) {
122
+ const vs = mesh?.vertices ?? [];
123
+ const ok = (i) => Number.isInteger(i) && i >= 0 && i < vs.length && Number.isFinite(vs[i].x + vs[i].y + vs[i].z);
124
+
125
+ // Vertices at the same place become one point
126
+ const rep = samePlace(vs, defaultEps(vs));
127
+ const index = new Map();
128
+ const P = [];
129
+ const at = (i) => {
130
+ const r = rep[i];
131
+ if (!index.has(r)) index.set(r, P.push([vs[r].x, vs[r].y, vs[r].z]) - 1);
132
+ return index.get(r);
133
+ };
134
+
135
+ // Faces that can be subdivided (others are dropped), with their texture corners and material
136
+ const F = [], src = [];
137
+ (mesh?.faces ?? []).forEach((f, n) => {
138
+ if (!Array.isArray(f) || !f.every(ok)) return;
139
+ const keep = distinctCorners(f.map(at));
140
+ if (keep.length < 3 || new Set(keep.map(([v]) => v)).size !== keep.length) return;
141
+ F.push(keep.map(([v]) => v));
142
+ src.push({ n, corners: keep.map(([, c]) => c) });
143
+ });
144
+ if (!F.length) return { vertices: [], faces: [] };
145
+
146
+ // Sharp edges: given ones (pairs or polylines of vertex indices), and ones sharper than the angle
147
+ let sharp = new Set();
148
+ for (const line of creases ?? []) {
149
+ if (!Array.isArray(line)) continue;
150
+ for (let i = 0; i + 1 < line.length; i++) {
151
+ if (!ok(line[i]) || !ok(line[i + 1])) continue;
152
+ const a = at(line[i]), b = at(line[i + 1]);
153
+ if (a !== b) sharp.add(edgeKey(a, b));
154
+ }
155
+ }
156
+ if (Number.isFinite(creaseAngle)) {
157
+ const minDot = Math.cos((creaseAngle * Math.PI) / 180);
158
+ const normals = F.map((f) => {
159
+ const n = newell(f.map((i) => ({ x: P[i][0], y: P[i][1], z: P[i][2] })));
160
+ const l = Math.hypot(n.x, n.y, n.z) || 1;
161
+ return { x: n.x / l, y: n.y / l, z: n.z / l };
162
+ });
163
+ const onEdge = new Map();
164
+ F.forEach((f, n) => f.forEach((a, c) => {
165
+ const k = edgeKey(a, f[(c + 1) % f.length]);
166
+ (onEdge.get(k) ?? onEdge.set(k, []).get(k)).push(n);
167
+ }));
168
+ for (const [k, fs] of onEdge) {
169
+ if (fs.length !== 2) continue;
170
+ const [p, q] = [normals[fs[0]], normals[fs[1]]];
171
+ if (p.x * q.x + p.y * q.y + p.z * q.z < minDot - 1e-9) sharp.add(k);
172
+ }
173
+ }
174
+
175
+ // Texture coordinates: their own surface, joined where corners share a vertex and the same coordinates
176
+ const hasUV = Array.isArray(mesh.uvs) && Array.isArray(mesh.faceUVs) && src.every(({ n, corners }) => {
177
+ const c = mesh.faceUVs[n];
178
+ return Array.isArray(c) && c.length === mesh.faces[n].length && corners.every((k) => mesh.uvs[c[k]] && Number.isFinite(mesh.uvs[c[k]].u + mesh.uvs[c[k]].v));
179
+ });
180
+ let UP = null, UF = null;
181
+ if (hasUV) {
182
+ UP = [];
183
+ const uvIndex = new Map();
184
+ UF = src.map(({ n, corners }, f) => corners.map((k, c) => {
185
+ const uv = mesh.uvs[mesh.faceUVs[n][k]];
186
+ const key = `${F[f][c]}/${uv.u.toFixed(9)},${uv.v.toFixed(9)}`;
187
+ if (!uvIndex.has(key)) uvIndex.set(key, UP.push([uv.u, uv.v]) - 1);
188
+ return uvIndex.get(key);
189
+ }));
190
+ }
191
+
192
+ // Subdivide, keeping track of which original face each new face comes from
193
+ let origin = src.map((_, f) => f);
194
+ let geo = { P, F, sharp };
195
+ let tex = hasUV ? { P: UP, F: UF, sharp: new Set() } : null;
196
+ const count = Math.max(0, Math.min(6, Math.round(levels)));
197
+ for (let l = 0; l < count; l++) {
198
+ const g = step(geo.P, geo.F, geo.sharp, smooth);
199
+ if (tex) tex = step(tex.P, tex.F, tex.sharp, smooth && uvMode !== "linear");
200
+ origin = g.parent.map((p) => origin[p]);
201
+ geo = g;
202
+ }
203
+
204
+ const out = {
205
+ vertices: geo.P.map(([x, y, z]) => ({ x, y, z })),
206
+ faces: geo.F,
207
+ };
208
+ if (tex) {
209
+ out.uvs = tex.P.map(([u, v]) => ({ u, v }));
210
+ out.faceUVs = tex.F;
211
+ }
212
+ if (mesh.faceMaterials) out.faceMaterials = origin.map((f) => mesh.faceMaterials[src[f].n] ?? 0);
213
+ if (mesh.materialNames) out.materialNames = [...mesh.materialNames];
214
+ return out;
215
+ }
@@ -0,0 +1,169 @@
1
+ import { triangulate } from "../render/shapes.js";
2
+ import { vec, add, sub, mul, dot, cross, rotateAround, signedArea2 } from "./vec.js";
3
+ import { across, cleanPath, pathFrames } from "./frames.js";
4
+
5
+ const EMPTY = () => ({ vertices: [], edges: [], faces: [], uvs: [], faceUVs: [] });
6
+
7
+ /**
8
+ * A size along the path: a number, a list of numbers spread evenly from the
9
+ * start (first) to the end (last), or a function of t (0 at the start, 1 at the end).
10
+ */
11
+ function sizeAt(opt, t, fallback) {
12
+ let v = fallback;
13
+ if (typeof opt === "function") v = opt(t);
14
+ else if (typeof opt === "number") v = opt;
15
+ else if (Array.isArray(opt) && opt.length) {
16
+ const x = t * (opt.length - 1), i = Math.min(opt.length - 2, Math.floor(x));
17
+ v = opt.length === 1 ? opt[0] : opt[i] + (opt[i + 1] - opt[i]) * (x - i);
18
+ }
19
+ return Number.isFinite(v) ? v : fallback;
20
+ }
21
+
22
+ /** A circle of `n` points of radius 1, counter-clockwise, starting at the bottom. */
23
+ function circle(n) {
24
+ return Array.from({ length: n }, (_, j) => {
25
+ const a = -Math.PI / 2 + (j / n) * Math.PI * 2;
26
+ return { x: Math.cos(a), y: Math.sin(a) };
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Moves a cross-section along a 3D path: tails, horns, tentacles, handles,
32
+ * pipes, cables, rails, picture frames. The section is a 2D outline: its x
33
+ * goes to the path's side and its y to the path's "up". By default the
34
+ * section is carried along the path without spinning (so it never flips at
35
+ * bends); with `up`, its y stays pointed that way (roads, rails, frames).
36
+ *
37
+ * Without `profile`, the section is a circle of `radius` (a tube). An end
38
+ * whose size is 0 closes into a point (a horn, a tail tip).
39
+ *
40
+ * Faces are clockwise from outside, like the built-in shapes. Texture
41
+ * coordinates: u goes around the section (by length), v along the path (by
42
+ * length). Caps map the section's box.
43
+ *
44
+ * @example
45
+ * // A tail: thick at the base, a point at the tip
46
+ * const tail = sweep(spline(controlPoints, { count: 40 }), { radius: (t) => 0.08 * (1 - t) });
47
+ *
48
+ * @param {{x:number, y:number, z:number}[]} path Points the section follows (use `spline` for a smooth path)
49
+ * @param {object} [opts]
50
+ * @returns {import("../index.d.ts").Mesh}
51
+ */
52
+ export function sweep(path, opts = {}) {
53
+ const {
54
+ profile, radius = 0.1, scale = 1, segments = 16, twist = 0,
55
+ closed = false, caps = true, up = null, miter = true, profileClosed = true,
56
+ } = opts;
57
+
58
+ // The path, without bad points and repeats
59
+ const { pts, loop } = cleanPath(path, closed);
60
+
61
+ // The section: counter-clockwise when closed, so the outside is on its right
62
+ const ring = profile ? profileClosed !== false : true;
63
+ const sec = [];
64
+ for (const q of profile ?? circle(Math.max(3, Math.floor(segments)))) {
65
+ if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y)) continue;
66
+ const last = sec[sec.length - 1];
67
+ if (!last || Math.hypot(q.x - last.x, q.y - last.y) > 1e-12) sec.push({ x: q.x, y: q.y });
68
+ }
69
+ if (ring && sec.length > 2 && Math.hypot(sec[0].x - sec[sec.length - 1].x, sec[0].y - sec[sec.length - 1].y) <= 1e-12) sec.pop();
70
+ if (ring && signedArea2(sec) < 0) sec.reverse();
71
+ const m = sec.length;
72
+ const n = pts.length;
73
+ if (n < 2 || m < (ring ? 3 : 2)) return EMPTY();
74
+
75
+ // Frames along the path; t (0 to 1) for sizes and twist, by length
76
+ const { dirs, tangents, ups, along, total } = pathFrames(pts, loop, up);
77
+ const sides = loop ? n : n - 1;
78
+ const tOf = (i) => along[i] / total;
79
+
80
+ // At a corner, the section is stretched toward the bend so the tube keeps its thickness (miter)
81
+ const miters = tangents.map((t, i) => {
82
+ const din = loop ? dirs[(i + n - 1) % n] : dirs[i - 1];
83
+ const dout = dirs[i];
84
+ if (!miter || !din || !dout) return null;
85
+ const k = Math.min(3, 1 / Math.max(dot(t, dout), 1 / 3));
86
+ const bend = across(sub(dout, din), t);
87
+ return k > 1 + 1e-9 && bend ? { bend, k } : null;
88
+ });
89
+
90
+ // On a loop, the twist is rounded to whole turns so the ends still meet
91
+ const turn = loop ? Math.round((twist || 0) / (2 * Math.PI)) * 2 * Math.PI : twist || 0;
92
+
93
+ // Rings of vertices; a ring of size 0 is a single point
94
+ const vertices = [];
95
+ const rings = pts.map((p, i) => {
96
+ const t = tOf(i);
97
+ const k = sizeAt(scale, t, 1) * (profile ? 1 : sizeAt(radius, t, 0.1));
98
+ if (Math.abs(k) < 1e-9) {
99
+ const tip = vertices.push(vec(p.x, p.y, p.z)) - 1;
100
+ return new Array(m).fill(tip);
101
+ }
102
+ const tan = tangents[i];
103
+ const nrm = rotateAround(ups[i], tan, turn * t);
104
+ const side = cross(nrm, tan);
105
+ const mt = miters[i];
106
+ return sec.map((q) => {
107
+ let o = add(mul(side, q.x * k), mul(nrm, q.y * k));
108
+ if (mt) o = add(o, mul(mt.bend, (mt.k - 1) * dot(o, mt.bend)));
109
+ return vertices.push(add(p, o)) - 1;
110
+ });
111
+ });
112
+ const isTip = (r) => r[0] === r[m - 1] && m > 1 && r[0] === r[1];
113
+
114
+ // Texture grid: an extra column for the section's seam, an extra row for the path's
115
+ const secLen = [0];
116
+ const secSides = ring ? m : m - 1;
117
+ for (let j = 0; j < secSides; j++) secLen.push(secLen[j] + Math.hypot(sec[(j + 1) % m].x - sec[j].x, sec[(j + 1) % m].y - sec[j].y));
118
+ const secTotal = secLen[secSides] || 1;
119
+ const cols = secSides + 1, rows = sides + 1;
120
+ const uvs = [];
121
+ for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) uvs.push({ u: secLen[j] / secTotal, v: along[i] / total });
122
+ const uvAt = (i, j) => i * cols + j;
123
+
124
+ // Sides: one quad per section side and path step (a triangle next to a point)
125
+ const faces = [], faceUVs = [];
126
+ for (let i = 0; i < sides; i++) {
127
+ const a = rings[i], b = rings[(i + 1) % n];
128
+ for (let j = 0; j < secSides; j++) {
129
+ const j1 = (j + 1) % m;
130
+ const corners = [[a[j], uvAt(i, j)], [a[j1], uvAt(i, j + 1)], [b[j1], uvAt(i + 1, j + 1)], [b[j], uvAt(i + 1, j)]];
131
+ const keep = corners.filter(([v], c) => v !== corners[(c + 3) % 4][0]);
132
+ if (keep.length < 3) continue;
133
+ faces.push(keep.map(([v]) => v));
134
+ faceUVs.push(keep.map(([, u]) => u));
135
+ }
136
+ }
137
+
138
+ // Caps: the start faces back along the path, the end forward
139
+ if (!loop && ring && caps !== false) {
140
+ const xs = sec.map((q) => q.x), ys = sec.map((q) => q.y);
141
+ const x0 = Math.min(...xs), y0 = Math.min(...ys);
142
+ const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
143
+ const first = uvs.length;
144
+ for (const q of sec) uvs.push({ u: (q.x - x0) / w, v: (q.y - y0) / h });
145
+ const tris = triangulate(sec);
146
+ for (const [r, flip] of [[rings[0], true], [rings[n - 1], false]]) {
147
+ if (isTip(r)) continue;
148
+ for (const tri of tris) {
149
+ const t = flip ? [tri[2], tri[1], tri[0]] : tri;
150
+ faces.push(t.map((j) => r[j]));
151
+ faceUVs.push(t.map((j) => first + j));
152
+ }
153
+ }
154
+ }
155
+
156
+ // Edges: each section, and a line along the path from each section point
157
+ const edges = [];
158
+ for (const r of rings) {
159
+ if (isTip(r)) continue;
160
+ if (ring) edges.push(r.slice());
161
+ else for (let j = 0; j < m - 1; j++) edges.push([r[j], r[j + 1]]);
162
+ }
163
+ for (let j = 0; j < m; j++) {
164
+ if (loop) edges.push(rings.map((r) => r[j]));
165
+ else for (let i = 0; i < n - 1; i++) if (rings[i][j] !== rings[i + 1][j]) edges.push([rings[i][j], rings[i + 1][j]]);
166
+ }
167
+
168
+ return { vertices, edges, faces, uvs, faceUVs };
169
+ }
@@ -0,0 +1,35 @@
1
+ /** Small vector helpers shared by the modeling tools (not exported from the library). */
2
+
3
+ export const vec = (x = 0, y = 0, z = 0) => ({ x, y, z });
4
+ export const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z });
5
+ export const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
6
+ export const mul = (a, k) => ({ x: a.x * k, y: a.y * k, z: a.z * k });
7
+ export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
8
+ export const cross = (a, b) => ({ x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x });
9
+ export const len = (a) => Math.hypot(a.x, a.y, a.z);
10
+ export const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
11
+
12
+ /** `a` at length 1, or `fallback` when it has no length. */
13
+ export function norm(a, fallback = null) {
14
+ const l = len(a);
15
+ return l > 1e-12 ? mul(a, 1 / l) : fallback;
16
+ }
17
+
18
+ /** `a` turned by `angle` radians around the unit axis `k` (Rodrigues). */
19
+ export function rotateAround(a, k, angle) {
20
+ const c = Math.cos(angle), s = Math.sin(angle);
21
+ return add(add(mul(a, c), mul(cross(k, a), s)), mul(k, dot(k, a) * (1 - c)));
22
+ }
23
+
24
+ /** A valid 3D point: three finite numbers. */
25
+ export const isPoint = (p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z);
26
+
27
+ /** Twice the signed area of a 2D outline: > 0 when counter-clockwise (x right, y up). */
28
+ export function signedArea2(pts) {
29
+ let a = 0;
30
+ for (let i = 0; i < pts.length; i++) {
31
+ const p = pts[i], q = pts[(i + 1) % pts.length];
32
+ a += p.x * q.y - q.x * p.y;
33
+ }
34
+ return a;
35
+ }
@@ -125,7 +125,7 @@ export class CanvasRenderer {
125
125
  // For effects
126
126
  const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
127
127
  const time = (performance.now() - this._start) / 1000;
128
- const background = this._rgba(ctx, scene.background);
128
+ const background = this._rgba(ctx, scene.background ?? "transparent");
129
129
 
130
130
  for (const obj of drawn) {
131
131
  const mats = materialsOf(obj);
@@ -2,19 +2,29 @@ import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } f
2
2
  import { transformPoint } from "../math/matrix.js";
3
3
 
4
4
  /**
5
- * Clears the entire canvas with a solid background color.
5
+ * Clears the entire canvas, then paints it with a background color.
6
6
  * This should usually be called once per frame before drawing.
7
7
  *
8
+ * A see-through color (`"transparent"`, or `rgba(...)` with an alpha below 1),
9
+ * or no color (`null`), lets the page behind the canvas show through.
10
+ *
8
11
  * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
9
12
  * @param {number} w - Canvas width
10
13
  * @param {number} h - Canvas height
11
- * @param {string} color - Background color (CSS color)
14
+ * @param {string|null} [color] - Background color (CSS color); none: left see-through
12
15
  */
13
16
  export function clear(ctx, w, h, color) {
17
+ ctx.clearRect(0, 0, w, h);
18
+ if (!isVisible(color)) return;
14
19
  ctx.fillStyle = color;
15
20
  ctx.fillRect(0, 0, w, h);
16
21
  }
17
22
 
23
+ /** Whether a background color paints anything (not null, empty or "transparent"). */
24
+ function isVisible(color) {
25
+ return color != null && color !== "" && String(color).trim().toLowerCase() !== "transparent";
26
+ }
27
+
18
28
  /**
19
29
  * Draws a square point centered at a given 2D position.
20
30
  * Useful for debugging or drawing vertices.
@@ -91,7 +91,7 @@ export function runCanvasPost(scene, list, start) {
91
91
  canvas,
92
92
  rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
93
93
  };
94
- frame.background = frame.rgba(scene.background ?? "#000000");
94
+ frame.background = frame.rgba(scene.background ?? "transparent");
95
95
  for (const e of effects) {
96
96
  ctx.save();
97
97
  try {