trid3d 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Texture mapping: texture coordinates made from a mesh's shape, for
3
+ * meshes that don't have any (or to replace the ones they have).
4
+ *
5
+ * Every mode fits the image to the mesh's bounding box:
6
+ *
7
+ * - "planar": the image is projected straight through the mesh along one axis
8
+ * - "box": each face takes the image from the side of the box it faces most
9
+ * - "sphere": wrapped around like a world map
10
+ * - "cylinder": wrapped around the sides like a label; the ends get a planar image
11
+ *
12
+ * A mapping is a mode name, or an object with options:
13
+ *
14
+ * mapping: "box"
15
+ * mapping: { type: "cylinder", axis: "x" }
16
+ *
17
+ * @typedef {"uv"|"planar"|"box"|"sphere"|"cylinder"} MappingType
18
+ * @typedef {MappingType | { type: MappingType, axis?: "x"|"y"|"z" }} Mapping
19
+ */
20
+
21
+ const TAU = Math.PI * 2;
22
+
23
+ /** Mapping modes accepted by `computeUVs` and `material.mapping`. */
24
+ export const MAPPINGS = ["uv", "planar", "box", "sphere", "cylinder"];
25
+
26
+ /**
27
+ * Texture coordinates for a mesh's faces, made from its shape.
28
+ * The mesh isn't changed. Every face corner gets its own entry in `uvs`.
29
+ *
30
+ * @param {{ vertices:{x:number,y:number,z:number}[], faces?:number[][] }} mesh
31
+ * @param {Mapping} mapping
32
+ * @returns {{ uvs:{u:number,v:number}[], faceUVs:number[][] }}
33
+ * @throws {Error} For an unknown mapping
34
+ */
35
+ export function computeUVs(mesh, mapping) {
36
+ const { type, axis } = mappingOf(mapping);
37
+ const faces = mesh.faces ?? [];
38
+ const uvs = [], faceUVs = [];
39
+ if (type === "uv") {
40
+ return { uvs: mesh.uvs ?? [], faceUVs: mesh.faceUVs ?? [] };
41
+ }
42
+
43
+ // Sphere and cylinder wrap around Y; other axes are turned to Y first.
44
+ // Cyclic swaps are rotations, so the image isn't mirrored.
45
+ const turn = axis === "x" ? (p) => ({ x: p.z, y: p.x, z: p.y })
46
+ : axis === "z" ? (p) => ({ x: p.y, y: p.z, z: p.x })
47
+ : (p) => p;
48
+ const wraps = type === "sphere" || type === "cylinder";
49
+ const verts = wraps ? mesh.vertices.map(turn) : mesh.vertices;
50
+ const box = bounds(verts);
51
+ const center = { x: (box.min.x + box.max.x) / 2, y: (box.min.y + box.max.y) / 2, z: (box.min.z + box.max.z) / 2 };
52
+ const size = { x: box.max.x - box.min.x || 1, y: box.max.y - box.min.y || 1, z: box.max.z - box.min.z || 1 };
53
+
54
+ // A flat image seen from outside along +side of an axis ("y" with sign 1: from above)
55
+ const flat = (p, ax, sign) => {
56
+ const nx = (p.x - box.min.x) / size.x, ny = (p.y - box.min.y) / size.y, nz = (p.z - box.min.z) / size.z;
57
+ if (ax === "x") return { u: sign > 0 ? nz : 1 - nz, v: ny };
58
+ if (ax === "y") return { u: nx, v: sign > 0 ? nz : 1 - nz };
59
+ return { u: sign > 0 ? 1 - nx : nx, v: ny };
60
+ };
61
+
62
+ for (const f of faces) {
63
+ const p = f.map((i) => verts[i]);
64
+ let ts;
65
+ if (type === "planar") {
66
+ // From the front: along the axis, seen from its negative side
67
+ const ax = axis ?? "z";
68
+ ts = p.map((q) => flat(q, ax, -1));
69
+ } else if (type === "box") {
70
+ const n = faceNormal(p);
71
+ const ax = dominant(n);
72
+ ts = p.map((q) => flat(q, ax, Math.sign(n[ax]) || 1));
73
+ } else {
74
+ const n = faceNormal(p);
75
+ if (type === "cylinder" && Math.abs(n.y) > 0.7) {
76
+ // An end cap: planar from above or below
77
+ ts = p.map((q) => flat(q, "y", Math.sign(n.y)));
78
+ } else {
79
+ ts = p.map((q) => {
80
+ const d = { x: q.x - center.x, y: q.y - center.y, z: q.z - center.z };
81
+ const around = Math.hypot(d.x, d.z);
82
+ const u = around < 1e-9 ? NaN : (((Math.atan2(d.z, d.x) / TAU) % 1) + 1) % 1;
83
+ const v = type === "sphere"
84
+ ? 0.5 + Math.atan2(d.y, around) / Math.PI
85
+ : (q.y - box.min.y) / size.y;
86
+ return { u, v };
87
+ });
88
+ fixSeam(ts);
89
+ }
90
+ }
91
+ faceUVs.push(ts.map((t) => uvs.push(t) - 1));
92
+ }
93
+ return { uvs, faceUVs };
94
+ }
95
+
96
+ /**
97
+ * Replaces a mesh's texture coordinates with ones made from its shape,
98
+ * in place. Handy for OBJ files without any, or before exporting.
99
+ *
100
+ * @param {Object} mesh
101
+ * @param {Mapping} mapping
102
+ * @returns The same mesh
103
+ */
104
+ export function mapUVs(mesh, mapping) {
105
+ const { uvs, faceUVs } = computeUVs(mesh, mapping);
106
+ mesh.uvs = uvs;
107
+ mesh.faceUVs = faceUVs;
108
+ mesh.dirty = true;
109
+ return mesh;
110
+ }
111
+
112
+ /** The mapping as `{ type, axis }`, checked. */
113
+ export function mappingOf(mapping) {
114
+ const m = typeof mapping === "string" ? { type: mapping } : { ...(mapping ?? {}) };
115
+ m.type ??= "uv";
116
+ if (!MAPPINGS.includes(m.type)) throw new Error(`Unknown texture mapping: "${m.type}"`);
117
+ if (m.axis != null && !["x", "y", "z"].includes(m.axis)) throw new Error(`Unknown mapping axis: "${m.axis}"`);
118
+ return m;
119
+ }
120
+
121
+ /** A short string naming a mapping, for caches ("" for the mesh's own coordinates). */
122
+ export function mappingKey(mapping) {
123
+ if (mapping == null) return "";
124
+ const { type, axis } = mappingOf(mapping);
125
+ return type === "uv" ? "" : type + (axis ? ":" + axis : "");
126
+ }
127
+
128
+ const cache = new WeakMap(); // mesh → Map(key → { counts, vertices, faces, result })
129
+
130
+ /**
131
+ * Texture coordinates a renderer should use for a mesh: its own, or
132
+ * computed for `mapping` and cached until the mesh changes.
133
+ *
134
+ * @returns {{ uvs:{u:number,v:number}[]|undefined, faceUVs:(number[]|null)[]|undefined }}
135
+ */
136
+ export function uvsFor(mesh, mapping) {
137
+ const key = mappingKey(mapping);
138
+ if (!key) return { uvs: mesh.uvs, faceUVs: mesh.faceUVs };
139
+ let byKey = cache.get(mesh);
140
+ if (!byKey) cache.set(mesh, (byKey = new Map()));
141
+ const counts = `${mesh.vertices.length}/${mesh.faces?.length ?? 0}`;
142
+ let c = byKey.get(key);
143
+ if (!c || mesh.dirty || c.counts !== counts || c.vertices !== mesh.vertices || c.faces !== mesh.faces) {
144
+ c = { counts, vertices: mesh.vertices, faces: mesh.faces, result: computeUVs(mesh, mapping) };
145
+ byKey.set(key, c);
146
+ }
147
+ return c.result;
148
+ }
149
+
150
+ /**
151
+ * The 2D transform a material's `textureRepeat`, `textureOffset` and
152
+ * `textureRotation` describe, as [a, b, c, d, e, f]:
153
+ * u' = a·u + c·v + e, v' = b·u + d·v + f. Null when it changes nothing.
154
+ *
155
+ * Rotation turns the image around its center, then repeat scales it
156
+ * (2 = the image twice across), then offset moves it.
157
+ */
158
+ export function uvTransform(mat) {
159
+ const r = mat.textureRepeat ?? 1, o = mat.textureOffset, a = mat.textureRotation ?? 0;
160
+ const ru = typeof r === "number" ? r : r.u ?? 1, rv = typeof r === "number" ? r : r.v ?? 1;
161
+ const ou = o?.u ?? 0, ov = o?.v ?? 0;
162
+ if (ru === 1 && rv === 1 && !ou && !ov && !a) return null;
163
+ const c = Math.cos(a), s = Math.sin(a);
164
+ // Rotate around (0.5, 0.5): R·(t - 0.5) + 0.5, then scale and move
165
+ return [
166
+ ru * c, rv * s,
167
+ -ru * s, rv * c,
168
+ ru * (0.5 - 0.5 * c + 0.5 * s) + ou,
169
+ rv * (0.5 - 0.5 * s - 0.5 * c) + ov,
170
+ ];
171
+ }
172
+
173
+ /** Applies a `uvTransform` to one texture coordinate. */
174
+ export function applyUVTransform(m, t) {
175
+ if (!m || !t) return t;
176
+ return { u: m[0] * t.u + m[2] * t.v + m[4], v: m[1] * t.u + m[3] * t.v + m[5] };
177
+ }
178
+
179
+ /**
180
+ * Corners that wrap past u = 1 get 1 added, so a face crossing the
181
+ * seam doesn't stretch the whole image across itself. Corners on the
182
+ * axis (u = NaN, like a sphere's poles) take the face's middle u.
183
+ */
184
+ function fixSeam(ts) {
185
+ const known = ts.filter((t) => !Number.isNaN(t.u));
186
+ if (!known.length) {
187
+ for (const t of ts) t.u = 0.5;
188
+ return;
189
+ }
190
+ const us = known.map((t) => t.u);
191
+ if (Math.max(...us) - Math.min(...us) > 0.5) {
192
+ for (const t of known) if (t.u < 0.5) t.u += 1;
193
+ }
194
+ const mid = known.reduce((a, t) => a + t.u, 0) / known.length;
195
+ for (const t of ts) if (Number.isNaN(t.u)) t.u = mid;
196
+ }
197
+
198
+ /** Outward normal of a clockwise face (not normalized). */
199
+ function faceNormal(p) {
200
+ let x = 0, y = 0, z = 0;
201
+ for (let i = 0; i < p.length; i++) {
202
+ const a = p[i], b = p[(i + 1) % p.length];
203
+ x += (a.y - b.y) * (a.z + b.z);
204
+ y += (a.z - b.z) * (a.x + b.x);
205
+ z += (a.x - b.x) * (a.y + b.y);
206
+ }
207
+ return { x, y, z };
208
+ }
209
+
210
+ /** The axis a vector points along most. */
211
+ function dominant(n) {
212
+ const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
213
+ return ay >= ax && ay >= az ? "y" : ax >= az ? "x" : "z";
214
+ }
215
+
216
+ function bounds(verts) {
217
+ const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
218
+ for (const v of verts) {
219
+ if (v.x < min.x) min.x = v.x; if (v.x > max.x) max.x = v.x;
220
+ if (v.y < min.y) min.y = v.y; if (v.y > max.y) max.y = v.y;
221
+ if (v.z < min.z) min.z = v.z; if (v.z > max.z) max.z = v.z;
222
+ }
223
+ if (!verts.length) return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } };
224
+ return { min, max };
225
+ }
@@ -1,7 +1,8 @@
1
1
  import { DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
- import { clear, edgeSegments } from "./draw.js";
2
+ import { clear, edgeSegments, wireEdges } from "./draw.js";
3
3
  import { parseColor } from "./color.js";
4
- import { DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./canvasRenderer.js";
4
+ import { DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./canvasRenderer.js";
5
+ import { uvsFor, uvTransform, mappingKey } from "./uvMapping.js";
5
6
  import { newell, cornerNormals, creaseOf } from "./normals.js";
6
7
  import { SURFACE_GLSL, buildEffects, effectsKey } from "./effects.js";
7
8
  import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
@@ -38,11 +39,12 @@ varying vec3 vNormal;
38
39
  varying vec3 vWorld;
39
40
  varying vec3 vLocal;
40
41
  varying vec2 vUV;
42
+ uniform mat3 uUVTransform; // textureRepeat, textureOffset, textureRotation
41
43
  ${COMMON}
42
44
  void main() {
43
45
  vNormal = rotXYZ(aNormal / uScale); // inverse scale keeps normals right on stretched objects
44
46
  vLocal = aPos;
45
- vUV = aUV;
47
+ vUV = (uUVTransform * vec3(aUV, 1.0)).xy;
46
48
  vWorld = toWorld(aPos);
47
49
  gl_Position = toClip(toCamera(vWorld));
48
50
  }
@@ -106,7 +108,7 @@ ${fx.calls}
106
108
  return { source, uniforms: fx.uniforms };
107
109
  }
108
110
 
109
- const FACE_UNIFORMS = ["uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
111
+ const FACE_UNIFORMS = ["uUVTransform", "uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
110
112
 
111
113
  // Each line segment is a thin rectangle facing the screen, `uWidth` pixels wide
112
114
  const LINE_VS = `
@@ -268,16 +270,26 @@ export class WebGLRenderer {
268
270
  const geo = this._geometry(obj.mesh);
269
271
  const lineColor = this._color(ctx, obj.color ?? scene.color);
270
272
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
271
- const mat = obj.material;
272
-
273
- if (mat) {
274
- const fill = this._color(ctx, mat.fill ?? (mat.texture ? "#ffffff" : scene.color));
275
- (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat });
276
- if (mat.wireframe) {
277
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.outlines, color: lineColor, width: lineWidth });
273
+ const mats = materialsOf(obj);
274
+
275
+ if (mats) {
276
+ // One draw per group of faces sharing a material (mesh.faceMaterials)
277
+ const groups = geo.groups.map((g) => {
278
+ const k = g.index >= 0 && g.index < mats.length && mats[g.index] ? g.index : 0;
279
+ return { ...g, mat: mats[k] };
280
+ });
281
+ for (const g of groups) {
282
+ const fill = this._color(ctx, g.mat.fill ?? (g.mat.texture ? "#ffffff" : scene.color));
283
+ (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat: g.mat, first: g.first, count: g.count });
278
284
  }
285
+ const lines = (buf) => (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf, color: lineColor, width: lineWidth });
286
+ if (groups.every((g) => g.mat.wireframe)) lines(this._lineBuffer(geo, "outlines", () => outlinePairs(geo.faceList ?? [])));
287
+ else for (const g of groups) if (g.mat.wireframe) lines(this._outlineBuffer(geo, obj.mesh, g));
279
288
  } else {
280
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.edges, color: lineColor, width: lineWidth });
289
+ (lineColor[3] < 1 ? clear2 : opaque).push({
290
+ kind: "line", obj, color: lineColor, width: lineWidth,
291
+ buf: this._lineBuffer(geo, "edges", () => edgeSegments(wireEdges(obj.mesh))),
292
+ });
281
293
  }
282
294
  }
283
295
 
@@ -310,8 +322,11 @@ export class WebGLRenderer {
310
322
  const isFace = d.kind === "face";
311
323
  const effects = isFace ? d.mat.effects ?? [] : null;
312
324
  const prog = isFace ? this._faceProgram(effects) : this._line;
313
- const buf = isFace ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth)) : d.buf;
314
- if (!buf.count) return;
325
+ const buf = isFace
326
+ ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth), d.mat.mapping)
327
+ : d.buf;
328
+ const first = d.first ?? 0, count = d.count ?? buf.count;
329
+ if (!count) return;
315
330
 
316
331
  gl.useProgram(prog.program);
317
332
  const u = prog.uniforms;
@@ -331,6 +346,8 @@ export class WebGLRenderer {
331
346
  const a = prog.attributes;
332
347
 
333
348
  if (isFace) {
349
+ const m = uvTransform(d.mat) ?? [1, 0, 0, 1, 0, 0];
350
+ gl.uniformMatrix3fv(u.uUVTransform, false, [m[0], m[1], 0, m[2], m[3], 0, m[4], m[5], 1]);
334
351
  gl.uniform3fv(u.uLight, light);
335
352
  gl.uniform1f(u.uAmbient, ambient);
336
353
  gl.uniform1f(u.uShade, d.mat.shade === false ? 0 : 1);
@@ -346,7 +363,9 @@ export class WebGLRenderer {
346
363
  gl.vertexAttribPointer(a.aPos, 3, gl.FLOAT, false, 32, 0);
347
364
  if (a.aNormal >= 0) gl.vertexAttribPointer(a.aNormal, 3, gl.FLOAT, false, 32, 12);
348
365
  if (a.aUV >= 0) gl.vertexAttribPointer(a.aUV, 2, gl.FLOAT, false, 32, 24);
349
- const tex = d.mat.texture ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh) : null;
366
+ const tex = d.mat.texture
367
+ ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh, !!uvsFor(d.obj.mesh, d.mat.mapping).faceUVs)
368
+ : null;
350
369
  gl.activeTexture(gl.TEXTURE0);
351
370
  gl.bindTexture(gl.TEXTURE_2D, tex);
352
371
  gl.uniform1i(u.uTexture, 0);
@@ -363,7 +382,7 @@ export class WebGLRenderer {
363
382
  }
364
383
  const locs = Object.values(a).filter((loc) => loc >= 0);
365
384
  for (const loc of locs) gl.enableVertexAttribArray(loc);
366
- gl.drawArrays(gl.TRIANGLES, 0, buf.count);
385
+ gl.drawArrays(gl.TRIANGLES, first, count);
367
386
  for (const loc of locs) gl.disableVertexAttribArray(loc);
368
387
  }
369
388
 
@@ -398,13 +417,13 @@ export class WebGLRenderer {
398
417
  * (canvases and videos: every frame). Null while it isn't ready,
399
418
  * or if the mesh has no texture coordinates.
400
419
  */
401
- _texture(src, pixelated, mesh) {
420
+ _texture(src, pixelated, mesh, hasUVs) {
402
421
  const gl = this.gl;
403
422
  if (!textureReady(src)) return null;
404
- if (!mesh.faceUVs) {
423
+ if (!hasUVs) {
405
424
  if (!this._warned.has(mesh)) {
406
425
  this._warned.add(mesh);
407
- console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
426
+ console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
408
427
  }
409
428
  return null;
410
429
  }
@@ -413,7 +432,7 @@ export class WebGLRenderer {
413
432
  const live = textureIsLive(src);
414
433
  if (!t || live || t.failed) {
415
434
  if (t?.failed) return null;
416
- const { w, h } = textureSize(src);
435
+ let { w, h } = textureSize(src);
417
436
  if (!t) {
418
437
  t = { tex: gl.createTexture(), frame: -1 };
419
438
  this._textures.set(src, t);
@@ -422,9 +441,23 @@ export class WebGLRenderer {
422
441
  if (t.frame !== this._frame) {
423
442
  t.frame = this._frame;
424
443
  gl.bindTexture(gl.TEXTURE_2D, t.tex);
444
+ // Bigger than the graphics card allows: upload a smaller copy
445
+ let pixels = src;
446
+ const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
447
+ if (w > max || h > max) {
448
+ const k = max / Math.max(w, h);
449
+ if (!t.small || live) t.small = shrink(src, Math.floor(w * k), Math.floor(h * k), t.small);
450
+ if (!t.warned) {
451
+ t.warned = true;
452
+ console.warn(`Trid: a ${w} × ${h} texture is bigger than this graphics card allows (${max}), so it's drawn at ${t.small.width} × ${t.small.height}`);
453
+ }
454
+ pixels = t.small;
455
+ w = t.small.width;
456
+ h = t.small.height;
457
+ }
425
458
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // images go top-down, v goes up
426
459
  try {
427
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
460
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
428
461
  } catch (err) {
429
462
  // Usually an image from another site without permission (CORS)
430
463
  console.warn("Trid: can't use this image as a texture: " + err.message);
@@ -465,59 +498,87 @@ export class WebGLRenderer {
465
498
  _geometry(mesh) {
466
499
  let geo = this._meshes.get(mesh);
467
500
  const stale = !geo || mesh.dirty || geo.vertices !== mesh.vertices || geo.faceList !== mesh.faces
468
- || geo.edgeList !== mesh.edges || geo.counts !== countsOf(mesh);
501
+ || geo.edgeList !== mesh.edges || geo.faceMats !== mesh.faceMaterials || geo.counts !== countsOf(mesh);
469
502
  if (!stale) return geo;
470
503
 
471
504
  if (geo) {
472
- for (const b of [geo.outlines, geo.edges, ...geo.faceBuffers.values()]) this._free(b);
505
+ for (const b of [...geo.lines.values(), ...geo.faceBuffers.values(), ...geo.groupOutlines.values()]) this._free(b);
473
506
  }
474
- const v = mesh.vertices;
475
507
  const faces = mesh.faces ?? [];
476
508
 
477
- // Outlines of the faces, each edge once
478
- const outline = new Set();
479
- const outlinePairs = [];
480
- for (const f of faces) {
481
- for (let k = 0; k < f.length; k++) {
482
- const a = f[k], b = f[(k + 1) % f.length];
483
- const key = a < b ? a + "," + b : b + "," + a;
484
- if (!outline.has(key)) {
485
- outline.add(key);
486
- outlinePairs.push([a, b]);
487
- }
488
- }
509
+ // Faces grouped by material index, in the order the face buffers store them
510
+ const byIndex = new Map();
511
+ faces.forEach((f, fi) => {
512
+ if (f.length < 3) return;
513
+ const k = mesh.faceMaterials?.[fi] ?? 0;
514
+ if (!byIndex.has(k)) byIndex.set(k, []);
515
+ byIndex.get(k).push(fi);
516
+ });
517
+ const groups = [];
518
+ let first = 0;
519
+ for (const k of [...byIndex.keys()].sort((a, b) => a - b)) {
520
+ const list = byIndex.get(k);
521
+ const count = list.reduce((n, fi) => n + (faces[fi].length - 2) * 3, 0);
522
+ groups.push({ index: k, faces: list, first, count });
523
+ first += count;
489
524
  }
490
525
 
491
526
  geo = {
492
527
  vertices: mesh.vertices,
493
528
  faceList: mesh.faces,
494
529
  edgeList: mesh.edges,
530
+ faceMats: mesh.faceMaterials,
495
531
  counts: countsOf(mesh),
496
- faceBuffers: new Map(), // one per shading mode, built when first needed
497
- outlines: this._upload(lineData(v, outlinePairs), 8),
498
- edges: this._upload(lineData(v, edgeSegments(mesh.edges ?? [])), 8),
532
+ groups,
533
+ groupOutlines: new Map(), // outlines of one group, when only some materials have `wireframe`
534
+ faceBuffers: new Map(), // one per shading mode and mapping, built when first needed
535
+ lines: new Map(), // "outlines" and "edges", built when first drawn (they're big on big models)
499
536
  };
500
537
  this._meshes.set(mesh, geo);
501
538
  return geo;
502
539
  }
503
540
 
541
+ /** A line buffer of the mesh, built the first time it's drawn. */
542
+ _lineBuffer(geo, name, pairs) {
543
+ let buf = geo.lines.get(name);
544
+ if (!buf) {
545
+ buf = this._upload(lineData(geo.vertices, pairs()), 8);
546
+ geo.lines.set(name, buf);
547
+ }
548
+ return buf;
549
+ }
550
+
551
+ /** Outlines of one group of faces (see `_geometry`), built when first needed. */
552
+ _outlineBuffer(geo, mesh, group) {
553
+ let buf = geo.groupOutlines.get(group.index);
554
+ if (!buf) {
555
+ const faces = mesh.faces ?? [];
556
+ buf = this._upload(lineData(mesh.vertices, outlinePairs(group.faces.map((fi) => faces[fi]))), 8);
557
+ geo.groupOutlines.set(group.index, buf);
558
+ }
559
+ return buf;
560
+ }
561
+
504
562
  /**
505
- * Triangles of a mesh's faces, each corner with its own position and
506
- * normal. Flat shading uses the face's normal at every corner; smooth
507
- * shading uses the corner normals.
563
+ * Triangles of a mesh's faces, each corner with its own position,
564
+ * normal and texture coordinate, grouped by material (`geo.groups`).
565
+ * Flat shading uses the face's normal at every corner; smooth shading
566
+ * uses the corner normals. `mapping` picks the texture coordinates.
508
567
  */
509
- _faceBuffer(geo, mesh, crease) {
510
- const key = crease ?? "flat";
568
+ _faceBuffer(geo, mesh, crease, mapping) {
569
+ const key = (crease ?? "flat") + "|" + mappingKey(mapping);
511
570
  let buf = geo.faceBuffers.get(key);
512
571
  if (buf) return buf;
513
572
 
514
573
  const v = mesh.vertices;
515
574
  const faces = mesh.faces ?? [];
516
575
  const corners = crease ? cornerNormals(mesh, crease) : null;
517
- const uvs = mesh.uvs, faceUVs = mesh.faceUVs;
518
- const tri = [];
519
- faces.forEach((f, fi) => {
520
- if (f.length < 3) return;
576
+ const { uvs, faceUVs } = uvsFor(mesh, mapping);
577
+ const total = geo.groups.reduce((n, g) => n + g.count, 0);
578
+ const tri = new Float32Array(total * 8);
579
+ let o = 0;
580
+ for (const fi of geo.groups.flatMap((g) => g.faces)) {
581
+ const f = faces[fi];
521
582
  let ns;
522
583
  if (corners) {
523
584
  ns = corners[fi];
@@ -532,10 +593,12 @@ export class WebGLRenderer {
532
593
  for (const c of [0, k, k + 1]) {
533
594
  const p = v[f[c]], n = ns[c];
534
595
  const t = ts ? uvs[ts[c]] : null;
535
- tri.push(p.x, p.y, p.z, n.x, n.y, n.z, t ? t.u : 0, t ? t.v : 0);
596
+ tri[o++] = p.x; tri[o++] = p.y; tri[o++] = p.z;
597
+ tri[o++] = n.x; tri[o++] = n.y; tri[o++] = n.z;
598
+ tri[o++] = t ? t.u : 0; tri[o++] = t ? t.v : 0;
536
599
  }
537
600
  }
538
- });
601
+ }
539
602
 
540
603
  buf = this._upload(tri, 8);
541
604
  geo.faceBuffers.set(key, buf);
@@ -546,7 +609,7 @@ export class WebGLRenderer {
546
609
  const gl = this.gl;
547
610
  const buffer = gl.createBuffer();
548
611
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
549
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
612
+ gl.bufferData(gl.ARRAY_BUFFER, data instanceof Float32Array ? data : new Float32Array(data), gl.STATIC_DRAW);
550
613
  this._buffers.add(buffer);
551
614
  return { buffer, count: data.length / floatsPerVertex };
552
615
  }
@@ -573,13 +636,44 @@ function countsOf(mesh) {
573
636
  return `${mesh.vertices.length}/${mesh.edges?.length ?? 0}/${mesh.faces?.length ?? 0}`;
574
637
  }
575
638
 
639
+ /** A smaller copy of an image, drawn on a canvas (reused when given one). */
640
+ function shrink(src, w, h, canvas = document.createElement("canvas")) {
641
+ canvas.width = w;
642
+ canvas.height = h;
643
+ canvas.getContext("2d").drawImage(src, 0, 0, w, h);
644
+ return canvas;
645
+ }
646
+
647
+ /** Each edge of the faces once, as [a, b] pairs. */
648
+ function outlinePairs(faces) {
649
+ const seen = new Set();
650
+ const pairs = [];
651
+ for (const f of faces) {
652
+ for (let k = 0; k < f.length; k++) {
653
+ const a = f[k], b = f[(k + 1) % f.length];
654
+ const key = a < b ? a * 4294967296 + b : b * 4294967296 + a; // one number per edge
655
+ if (!seen.has(key)) {
656
+ seen.add(key);
657
+ pairs.push([a, b]);
658
+ }
659
+ }
660
+ }
661
+ return pairs;
662
+ }
663
+
576
664
  /** Six corners (two triangles) per segment: A, B, then which corner. */
577
- function lineData(v, pairs) {
578
- const out = [];
665
+ function lineData(v, segments) {
666
+ const pairs = Array.isArray(segments) ? segments : [...segments]; // edgeSegments is a generator
667
+ const out = new Float32Array(pairs.length * 48);
579
668
  const corners = [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]];
669
+ let o = 0;
580
670
  for (const [i, j] of pairs) {
581
671
  const a = v[i], b = v[j];
582
- for (const [cx, cy] of corners) out.push(a.x, a.y, a.z, b.x, b.y, b.z, cx, cy);
672
+ for (const [cx, cy] of corners) {
673
+ out[o++] = a.x; out[o++] = a.y; out[o++] = a.z;
674
+ out[o++] = b.x; out[o++] = b.y; out[o++] = b.z;
675
+ out[o++] = cx; out[o++] = cy;
676
+ }
583
677
  }
584
678
  return out;
585
679
  }
package/trid/scene.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./render/canvasRenderer.js";
2
2
  import { loop } from "./render/loop.js";
3
3
  import { ToolManager } from "./tools/manager.js";
4
+ import { screenshot, record } from "./export/capture.js";
4
5
 
5
6
  /**
6
7
  * Holds a canvas, a camera and a list of objects, and draws them.
@@ -150,6 +151,28 @@ export class Scene {
150
151
  this._stop = null;
151
152
  }
152
153
 
154
+ /**
155
+ * Draws a new frame and returns it as an image file (PNG by default).
156
+ * The page's canvas doesn't change. Save it with `download`.
157
+ *
158
+ * @param {import("./export/capture.js").ScreenshotOptions} [opts]
159
+ * @returns {Promise<Blob>}
160
+ */
161
+ screenshot(opts) {
162
+ return screenshot(this, opts);
163
+ }
164
+
165
+ /**
166
+ * Starts recording the canvas as a video. Call `stop()` on the result,
167
+ * or pass `duration`, then save `await recording.done` with `download`.
168
+ *
169
+ * @param {import("./export/capture.js").RecordOptions} [opts]
170
+ * @returns {import("./export/capture.js").Recording}
171
+ */
172
+ record(opts) {
173
+ return record(this, opts);
174
+ }
175
+
153
176
  /**
154
177
  * Stops the loop, removes every tool (and its event listeners) and
155
178
  * frees the renderer's resources. Use it when the scene is no longer needed.
@@ -54,7 +54,8 @@ export class HoverHighlight {
54
54
  obj.color = this.color;
55
55
  // Swap in a copy, so other objects sharing the material don't change
56
56
  this._savedMaterial = obj.material;
57
- if (obj.material) obj.material = { ...obj.material, fill: this.color };
57
+ if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m && { ...m, fill: this.color });
58
+ else if (obj.material) obj.material = { ...obj.material, fill: this.color };
58
59
  }
59
60
 
60
61
  this.onChange?.(obj);
@@ -13,6 +13,9 @@ const MAX_PITCH = Math.PI / 2 - 0.01;
13
13
  * The tool drives `scene.camera` (x, y, z, yaw, pitch). Its own
14
14
  * `target`, `distance`, `yaw` and `pitch` can be changed at any time.
15
15
  * Don't use it together with another tool that moves the camera.
16
+ *
17
+ * With `autoRotate: true` the camera also turns around the target on its
18
+ * own, pausing while the user drags.
16
19
  */
17
20
  export class OrbitCamera {
18
21
  /**
@@ -28,6 +31,9 @@ export class OrbitCamera {
28
31
  * @param {boolean} [opts.rotate=true] - Allow orbiting
29
32
  * @param {boolean} [opts.pan=true] - Allow panning
30
33
  * @param {boolean} [opts.zoom=true] - Allow zooming
34
+ * @param {boolean} [opts.autoRotate=false] - Turn around the target on its own (paused while dragging)
35
+ * @param {number} [opts.autoRotateSpeed=0.5] - Auto-rotate speed in radians per second (negative: other way)
36
+ * @param {number} [opts.autoRotateDelay=0] - Seconds to wait after a drag before auto-rotating again
31
37
  */
32
38
  constructor({
33
39
  target = { x: 0, y: 0, z: 0 },
@@ -41,6 +47,9 @@ export class OrbitCamera {
41
47
  rotate = true,
42
48
  pan = true,
43
49
  zoom = true,
50
+ autoRotate = false,
51
+ autoRotateSpeed = 0.5,
52
+ autoRotateDelay = 0,
44
53
  } = {}) {
45
54
  this.target = target;
46
55
  this.distance = distance;
@@ -53,9 +62,13 @@ export class OrbitCamera {
53
62
  this.rotate = rotate;
54
63
  this.pan = pan;
55
64
  this.zoom = zoom;
65
+ this.autoRotate = autoRotate;
66
+ this.autoRotateSpeed = autoRotateSpeed;
67
+ this.autoRotateDelay = autoRotateDelay;
56
68
 
57
69
  this.blockContextMenu = true;
58
70
  this._mode = null;
71
+ this._idle = 0; // seconds since the last drag ended
59
72
  }
60
73
 
61
74
  attach(scene) {
@@ -117,6 +130,7 @@ export class OrbitCamera {
117
130
 
118
131
  pointerUp() {
119
132
  this._mode = null;
133
+ this._idle = 0;
120
134
  }
121
135
 
122
136
  wheel(e, scene) {
@@ -127,6 +141,10 @@ export class OrbitCamera {
127
141
  }
128
142
 
129
143
  update(dt, scene) {
144
+ if (this.autoRotate && !this._mode) {
145
+ this._idle += dt;
146
+ if (this._idle >= this.autoRotateDelay) this.yaw += this.autoRotateSpeed * dt;
147
+ }
130
148
  this.apply(scene.camera);
131
149
  }
132
150
  }