trid3d 0.2.0 → 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.
@@ -1,8 +1,10 @@
1
- import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
1
+ import { worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
3
+ import { worldObjects } from "../sceneGraph.js";
2
4
  import { clear, drawInstance, edgeSegments, wireEdges } from "./draw.js";
3
5
  import { parseColor } from "./color.js";
4
6
  import { newell, cornerNormals, creaseOf } from "./normals.js";
5
- import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
7
+ import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
6
8
  import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
7
9
 
8
10
  export { newell };
@@ -44,6 +46,8 @@ export const DEFAULT_AMBIENT = 0.25;
44
46
  * shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
45
47
  * @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
46
48
  * built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
49
+ * @property {boolean} [liveTexture] - Read the texture again every frame (default: only for videos).
50
+ * For a canvas redrawn every frame; for one redrawn now and then, call `updateTexture(canvas)` instead
47
51
  * @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
48
52
  * @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
49
53
  * from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
@@ -86,7 +90,7 @@ export class CanvasRenderer {
86
90
  this._splits = new WeakMap();
87
91
  this._colors = new Map();
88
92
  this._start = performance.now();
89
- this._patterns = new Map(); // texture source → canvas pattern
93
+ this._patterns = new WeakMap(); // texture source → canvas pattern (freed with the source)
90
94
  this._warned = new WeakSet();
91
95
  this._frame = 0;
92
96
  }
@@ -102,11 +106,15 @@ export class CanvasRenderer {
102
106
  clear(ctx, w, h, scene.background);
103
107
  this._frame++;
104
108
 
109
+ // Every object with a mesh, groups' children included, with its world matrix
110
+ const drawn = worldObjects(scene).filter((o) => o.mesh);
111
+
105
112
  // Wireframes only: nothing to sort
106
- if (!scene.objects.some((o) => materialsOf(o))) {
107
- for (const obj of scene.objects) {
108
- drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth);
113
+ if (!drawn.some((o) => materialsOf(o))) {
114
+ for (const obj of drawn) {
115
+ drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth, obj.world);
109
116
  }
117
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
110
118
  return;
111
119
  }
112
120
 
@@ -120,12 +128,12 @@ export class CanvasRenderer {
120
128
  const time = (performance.now() - this._start) / 1000;
121
129
  const background = this._rgba(ctx, scene.background);
122
130
 
123
- for (const obj of scene.objects) {
131
+ for (const obj of drawn) {
124
132
  const mats = materialsOf(obj);
125
133
  // Split big faces (cached per mesh); extra vertices are midpoints of existing ones
126
134
  const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
127
135
  const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
128
- const world = verts.map((v) => localToWorld(v, obj));
136
+ const world = verts.map((v) => transformPoint(obj.world, v));
129
137
  const cam = world.map((p) => worldToCamera(p, camera));
130
138
  const color = obj.color ?? scene.color;
131
139
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
@@ -145,6 +153,13 @@ export class CanvasRenderer {
145
153
 
146
154
  const sourceFaces = obj.mesh.faces ?? [];
147
155
  const base = obj.mesh.vertices.length;
156
+ // Normals turn with the object (stretched and mirrored ones too); a mirror reverses face winding
157
+ const nm = normalMatrix(obj.world);
158
+ const flip = determinantMat4(obj.world) < 0 ? -1 : 1;
159
+ const flatNormal = (face) => {
160
+ const n = normalize(newell(face.map((i) => world[i])));
161
+ return { x: n.x * flip, y: n.y * flip, z: n.z * flip };
162
+ };
148
163
 
149
164
  // Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
150
165
  const normalAt = (corners, F, i) => {
@@ -154,18 +169,16 @@ export class CanvasRenderer {
154
169
  return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
155
170
  };
156
171
  const lightAt = (n, back) => {
157
- // Same steps as the vertices: undo the scale, then rotate (keeps normals right on stretched objects)
158
- const r = normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
172
+ const r = transformNormal(nm, n);
159
173
  const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
160
174
  return ambient + (1 - ambient) * Math.max(0, d);
161
175
  };
162
176
  const flatLight = (face, back) => {
163
- const nw = normalize(newell(face.map((i) => world[i])));
177
+ const nw = flatNormal(face);
164
178
  const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
165
179
  return ambient + (1 - ambient) * Math.max(0, d);
166
180
  };
167
- const toWorldNormal = (n) =>
168
- normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
181
+ const toWorldNormal = (n) => transformNormal(nm, n);
169
182
 
170
183
  // Everything a material needs, worked out once per object and frame
171
184
  const prepare = (mat) => {
@@ -220,7 +233,7 @@ export class CanvasRenderer {
220
233
  // Seen from behind? (camera sits at the origin of camera space)
221
234
  const n = newell(pts);
222
235
  const c = centroid(pts);
223
- const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
236
+ const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
224
237
  if (back && !L.mat.doubleSided) continue;
225
238
 
226
239
  const clipped = clipPolygon(pts, near);
@@ -244,7 +257,7 @@ export class CanvasRenderer {
244
257
  items.push({
245
258
  depth: c.z,
246
259
  poly: [screen[0], screen[k], screen[k + 1]],
247
- texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, tint: fill },
260
+ texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, live: textureIsLive(L.tex, L.mat), tint: fill },
248
261
  lit: [lit[0], lit[k], lit[k + 1]],
249
262
  outline, lineWidth,
250
263
  edges: n === 3 ? edges : fan,
@@ -263,7 +276,7 @@ export class CanvasRenderer {
263
276
  .reduce((a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }), { x: 0, y: 0, z: 0 });
264
277
  nw = normalize(sum);
265
278
  } else {
266
- nw = normalize(newell(face.map((i) => world[i])));
279
+ nw = flatNormal(face);
267
280
  }
268
281
  if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
269
282
 
@@ -380,7 +393,7 @@ export class CanvasRenderer {
380
393
  }
381
394
 
382
395
  // Meshes marked as changed have been redrawn from their new data
383
- for (const obj of scene.objects) if (obj.mesh.dirty) obj.mesh.dirty = false;
396
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
384
397
  }
385
398
 
386
399
  /**
@@ -493,7 +506,7 @@ export class CanvasRenderer {
493
506
  * triangle's corners, tinted by the fill color, then darkened by the light.
494
507
  */
495
508
  _drawTextured(ctx, it) {
496
- const { src, uvs, pixelated, tint } = it.texture;
509
+ const { src, uvs, pixelated, live, tint } = it.texture;
497
510
  const { w: W, h: H } = textureSize(src);
498
511
  const [p0, p1, p2] = it.poly;
499
512
 
@@ -519,7 +532,7 @@ export class CanvasRenderer {
519
532
  const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
520
533
  const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
521
534
  const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
522
- const pattern = this._pattern(ctx, src);
535
+ const pattern = this._pattern(ctx, src, live);
523
536
  pattern.setTransform(new DOMMatrix([a, b, c, d, p0.x - a * U[0] - c * V[0], p0.y - b * U[0] - d * V[0]]));
524
537
  ctx.imageSmoothingEnabled = !pixelated;
525
538
  ctx.fillStyle = pattern;
@@ -553,11 +566,15 @@ export class CanvasRenderer {
553
566
  ctx.fill();
554
567
  }
555
568
 
556
- /** A repeating pattern for a texture (made again each frame for canvases and videos). */
557
- _pattern(ctx, src) {
569
+ /**
570
+ * A repeating pattern for a texture. A pattern is a copy of the image, so
571
+ * it's made again after `updateTexture`, and every frame for live ones.
572
+ */
573
+ _pattern(ctx, src, live) {
558
574
  let p = this._patterns.get(src);
559
- if (!p || (textureIsLive(src) && p.frame !== this._frame)) {
560
- p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame };
575
+ const version = textureVersion(src);
576
+ if (!p || p.version !== version || (live && p.frame !== this._frame)) {
577
+ p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame, version };
561
578
  this._patterns.set(src, p);
562
579
  }
563
580
  return p.pattern;
@@ -1,4 +1,5 @@
1
1
  import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { transformPoint } from "../math/matrix.js";
2
3
 
3
4
  /**
4
5
  * Clears the entire canvas with a solid background color.
@@ -136,10 +137,13 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
136
137
  * @param {number} h - Canvas height
137
138
  * @param {string} color - Wireframe color
138
139
  * @param {number} lineWidth - Wireframe width
140
+ * @param {number[]} [world] - World matrix to place it with (e.g. `obj.world` inside a group).
141
+ * Default: the instance's own pos / rot / scale
139
142
  */
140
- export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
143
+ export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3, world) {
141
144
  const near = camera.near ?? DEFAULT_NEAR;
142
- const ptsCam = inst.mesh.vertices.map(v => worldToCamera(localToWorld(v, inst), camera));
145
+ const toWorld = world ? (v) => transformPoint(world, v) : (v) => localToWorld(v, inst);
146
+ const ptsCam = inst.mesh.vertices.map((v) => worldToCamera(toWorld(v), camera));
143
147
 
144
148
  ctx.lineWidth = lineWidth;
145
149
  ctx.strokeStyle = color;
@@ -0,0 +1,257 @@
1
+ import { localToWorld, rotateXYZ } from "../math/transform.js";
2
+ import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
3
+ import { forEachPlaced, worldMatrix } from "../sceneGraph.js";
4
+
5
+ /** Deep copy of one mesh field: arrays and plain objects are copied, the rest kept. */
6
+ const copy = (x) => (Array.isArray(x) ? x.map(copy) : x && typeof x === "object" ? { ...x } : x);
7
+
8
+ /** A transform with every part filled in. */
9
+ const fullTransform = ({ pos, rot, scale } = {}) => ({
10
+ pos: { x: 0, y: 0, z: 0, ...pos },
11
+ rot: { x: 0, y: 0, z: 0, ...rot },
12
+ scale: { x: 1, y: 1, z: 1, ...scale },
13
+ });
14
+
15
+ /** A copy of a mesh with a world matrix baked in (normals turned, faces flipped for mirrors). */
16
+ function bake(mesh, world) {
17
+ const out = cloneMesh(mesh);
18
+ for (const v of out.vertices) Object.assign(v, transformPoint(world, v));
19
+ if (out.normals) {
20
+ const nm = normalMatrix(world);
21
+ out.normals = out.normals.map((n) => transformNormal(nm, n));
22
+ }
23
+ if (determinantMat4(world) < 0 && out.faces) {
24
+ const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
25
+ out.faces = flip(out.faces);
26
+ if (out.faceUVs) out.faceUVs = flip(out.faceUVs);
27
+ if (out.faceNormals) out.faceNormals = flip(out.faceNormals);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * Deep copy of a mesh: vertices, edges, faces, texture coordinates, normals
34
+ * and materials. Change the copy without touching the original (or the other
35
+ * objects that share it).
36
+ *
37
+ * @template M
38
+ * @param {M} mesh
39
+ * @returns {M} A new mesh
40
+ */
41
+ export function cloneMesh(mesh) {
42
+ const out = {};
43
+ for (const k of Object.keys(mesh)) if (k !== "dirty") out[k] = copy(mesh[k]);
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Bakes a transform into a mesh's vertices, in place: scale, then rotate
49
+ * (X, then Y, then Z), then move, the same order objects use. Its own normals
50
+ * turn with it, and a mirroring scale (an odd number of negative factors)
51
+ * reverses the faces so they still face outward.
52
+ *
53
+ * Pass a scene object to bake its `pos`, `rot` and `scale` (then reset them
54
+ * on the object, or it's transformed twice).
55
+ *
56
+ * @example
57
+ * // A handle standing on the side of a mug
58
+ * transformMesh(handle, { pos: { x: 0.3, y: 0, z: 0 }, rot: { x: 0, y: 0, z: Math.PI / 2 } });
59
+ *
60
+ * @template M
61
+ * @param {M} mesh - Mesh to change
62
+ * @param {{ pos?:{x:number,y:number,z:number}, rot?:{x:number,y:number,z:number}, scale?:{x:number,y:number,z:number} }} transform
63
+ * Parts left out don't change anything
64
+ * @returns {M} The same mesh
65
+ */
66
+ export function transformMesh(mesh, transform) {
67
+ const t = fullTransform(transform);
68
+ for (const v of mesh.vertices) Object.assign(v, localToWorld(v, t));
69
+
70
+ // Normals: divide by the scale (keeps them perpendicular to stretched faces), turn, then normalize
71
+ if (mesh.normals) {
72
+ const s = t.scale;
73
+ mesh.normals = mesh.normals.map((n) => {
74
+ const r = rotateXYZ({ x: n.x / (s.x || 1), y: n.y / (s.y || 1), z: n.z / (s.z || 1) }, t.rot);
75
+ const len = Math.hypot(r.x, r.y, r.z) || 1;
76
+ return { x: r.x / len, y: r.y / len, z: r.z / len };
77
+ });
78
+ }
79
+
80
+ // A mirror turns clockwise faces counter-clockwise: reverse them (and their corner lists)
81
+ if (t.scale.x * t.scale.y * t.scale.z < 0 && mesh.faces) {
82
+ const flip = (list) => list?.map((f) => (f ? [...f].reverse() : f));
83
+ mesh.faces = flip(mesh.faces);
84
+ if (mesh.faceUVs) mesh.faceUVs = flip(mesh.faceUVs);
85
+ if (mesh.faceNormals) mesh.faceNormals = flip(mesh.faceNormals);
86
+ }
87
+
88
+ mesh.dirty = true;
89
+ return mesh;
90
+ }
91
+
92
+ /**
93
+ * Moves every vertex to `fn(vertex, index)`, in place: noise on a rock,
94
+ * waves on a plane, a bend or a twist.
95
+ *
96
+ * The mesh's own normals (from an OBJ file) no longer fit the new shape, so
97
+ * they're removed and the renderers compute new ones.
98
+ *
99
+ * @example
100
+ * // A lumpy rock
101
+ * displaceMesh(makeShape("sphere"), (v) => {
102
+ * const k = 1 + 0.3 * (fbm3(v.x * 3, v.y * 3, v.z * 3) - 0.5);
103
+ * return { x: v.x * k, y: v.y * k, z: v.z * k };
104
+ * });
105
+ *
106
+ * @template M
107
+ * @param {M} mesh - Mesh to change
108
+ * @param {(v:{x:number,y:number,z:number}, index:number) => {x:number,y:number,z:number}} fn - New position of a vertex
109
+ * @returns {M} The same mesh
110
+ */
111
+ export function displaceMesh(mesh, fn) {
112
+ mesh.vertices.forEach((v, i) => {
113
+ const p = fn({ x: v.x, y: v.y, z: v.z }, i);
114
+ v.x = p.x;
115
+ v.y = p.y;
116
+ v.z = p.z;
117
+ });
118
+ delete mesh.normals;
119
+ delete mesh.faceNormals;
120
+ mesh.dirty = true;
121
+ return mesh;
122
+ }
123
+
124
+ /**
125
+ * Joins meshes into one: one object to draw instead of many, or one mesh to
126
+ * export. Faces, edges, texture coordinates, normals and materials are kept,
127
+ * with their indices shifted.
128
+ *
129
+ * An object (`{ mesh, pos, rot, scale }`, added to a scene or not) in the
130
+ * list is merged where it stands, with its transform baked in (its mesh
131
+ * isn't changed). A group is merged with all its children, placed by the
132
+ * group's own transform (not its parents').
133
+ *
134
+ * With `materialPerMesh`, the faces of the i-th mesh use material i, so an
135
+ * object with `material: [blade, guard, grip]` paints each part its own way.
136
+ * Otherwise each mesh's `faceMaterials` are kept; material names (from OBJ
137
+ * files) are joined into one list when every mesh with materials has them.
138
+ *
139
+ * The meshes are copied, never changed.
140
+ *
141
+ * @example
142
+ * const sword = mergeMeshes([blade, guard, grip], { materialPerMesh: true });
143
+ * scene.add({ mesh: sword, material: [{ fill: "#ccd" }, { fill: "#c90" }, { fill: "#642" }] });
144
+ *
145
+ * @param {Object[]} items - Meshes, or scene objects with a `mesh`
146
+ * @param {Object} [opts]
147
+ * @param {boolean} [opts.materialPerMesh=false] - Faces of the i-th mesh use material i
148
+ * @returns {import("../index.d.ts").Mesh} A new mesh
149
+ */
150
+ export function mergeMeshes(items, { materialPerMesh = false } = {}) {
151
+ // Meshes as they are; objects and groups baked where they stand, one entry per item
152
+ const meshes = items.map((it) => {
153
+ if (it.vertices) return it;
154
+ const parts = [];
155
+ forEachPlaced([it], () => null, (obj, world) => {
156
+ if (obj.mesh) parts.push(bake(obj.mesh, world));
157
+ });
158
+ return parts.length === 1 ? parts[0] : mergeMeshes(parts);
159
+ });
160
+ const some = (k) => meshes.some((m) => m[k]);
161
+
162
+ const out = { vertices: [], faces: [] };
163
+ const hasEdges = some("edges");
164
+ const hasUVs = some("faceUVs") && some("uvs");
165
+ const hasNormals = some("faceNormals") && some("normals");
166
+ const hasMats = materialPerMesh || some("faceMaterials");
167
+ const withMats = meshes.filter((m) => m.faceMaterials);
168
+ const names = !materialPerMesh && withMats.length > 0 && withMats.every((m) => m.materialNames) ? [] : null;
169
+ if (hasEdges) out.edges = [];
170
+ if (hasUVs) Object.assign(out, { uvs: [], faceUVs: [] });
171
+ if (hasNormals) Object.assign(out, { normals: [], faceNormals: [] });
172
+ if (hasMats) out.faceMaterials = [];
173
+
174
+ meshes.forEach((m, mi) => {
175
+ const vo = out.vertices.length, uo = out.uvs?.length ?? 0, no = out.normals?.length ?? 0;
176
+ const shift = (list, o) => list.map((i) => i + o);
177
+ out.vertices.push(...m.vertices.map((v) => ({ x: v.x, y: v.y, z: v.z })));
178
+ // A mesh without edges keeps showing its face outlines next to meshes that have some
179
+ if (hasEdges) out.edges.push(...(m.edges ?? m.faces ?? []).map((e) => shift(e, vo)));
180
+ if (hasUVs) out.uvs.push(...(m.uvs ?? []).map((t) => ({ u: t.u, v: t.v })));
181
+ if (hasNormals) out.normals.push(...(m.normals ?? []).map((n) => ({ x: n.x, y: n.y, z: n.z })));
182
+
183
+ // Material names: this mesh's index k → the index of its name in the joined list
184
+ const remap = names && m.materialNames
185
+ ? m.materialNames.map((n) => (names.includes(n) ? names.indexOf(n) : names.push(n) - 1))
186
+ : null;
187
+
188
+ (m.faces ?? []).forEach((f, fi) => {
189
+ out.faces.push(shift(f, vo));
190
+ if (hasUVs) {
191
+ const fu = m.uvs && m.faceUVs?.[fi];
192
+ out.faceUVs.push(fu ? shift(fu, uo) : null);
193
+ }
194
+ if (hasNormals) {
195
+ const fn = m.normals && m.faceNormals?.[fi];
196
+ out.faceNormals.push(fn ? shift(fn, no) : null);
197
+ }
198
+ if (hasMats) {
199
+ const k = m.faceMaterials?.[fi] ?? 0;
200
+ out.faceMaterials.push(materialPerMesh ? mi : remap ? (remap[k] ?? 0) : k);
201
+ }
202
+ });
203
+ });
204
+
205
+ if (names) out.materialNames = names;
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * The box around one or more meshes: `min` and `max` corners, plus its
211
+ * `center` and `size`. Scene objects (and a whole Scene) are measured where
212
+ * they stand, with their transform.
213
+ *
214
+ * @example
215
+ * const { center, size } = meshBounds(scene);
216
+ * orbit.target = center;
217
+ * orbit.distance = Math.max(size.x, size.y, size.z) * 2;
218
+ *
219
+ * @param {Object|Object[]} input - A mesh, a scene object, a list of them, or a Scene
220
+ * @returns {{ min:{x:number,y:number,z:number}, max:{x:number,y:number,z:number},
221
+ * center:{x:number,y:number,z:number}, size:{x:number,y:number,z:number} } | null}
222
+ * null when there are no vertices
223
+ */
224
+ export function meshBounds(input) {
225
+ const items = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : [input];
226
+ const min = { x: Infinity, y: Infinity, z: Infinity };
227
+ const max = { x: -Infinity, y: -Infinity, z: -Infinity };
228
+ let any = false;
229
+ // Plain meshes as they are; objects (and their children) where they're drawn
230
+ const placed = [];
231
+ const objects = [];
232
+ for (const it of items) {
233
+ if (it?.vertices) placed.push({ mesh: it, world: null });
234
+ else if (it) objects.push(it);
235
+ }
236
+ forEachPlaced(objects, (root) => (root.parent ? worldMatrix(root.parent) : null), (obj, world) => {
237
+ if (obj.mesh?.vertices) placed.push({ mesh: obj.mesh, world });
238
+ });
239
+ for (const { mesh, world } of placed) {
240
+ for (const v of mesh.vertices) {
241
+ const p = world ? transformPoint(world, v) : v;
242
+ any = true;
243
+ if (p.x < min.x) min.x = p.x;
244
+ if (p.y < min.y) min.y = p.y;
245
+ if (p.z < min.z) min.z = p.z;
246
+ if (p.x > max.x) max.x = p.x;
247
+ if (p.y > max.y) max.y = p.y;
248
+ if (p.z > max.z) max.z = p.z;
249
+ }
250
+ }
251
+ if (!any) return null;
252
+ return {
253
+ min, max,
254
+ center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 },
255
+ size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
256
+ };
257
+ }
@@ -0,0 +1,211 @@
1
+ import { fbm2, noise2 } from "../math/noise.js";
2
+ import { updateTexture } from "../loaders/texture.js";
3
+
4
+ /**
5
+ * Procedural textures: patterns painted in code, from a style, two colors
6
+ * and a seed. Every style tiles with no seam (the noise repeats and every
7
+ * count is a whole number), so they wrap around spheres, lathes and
8
+ * repeated textures cleanly. Like a canvas painted once, they're uploaded
9
+ * once.
10
+ *
11
+ * scene.add({ mesh: makeShape("cube"), material: { texture: makeTexture("wood") } });
12
+ */
13
+
14
+ /** The built-in styles of `makeTexture`. */
15
+ export const TEXTURE_STYLES = ["noise", "marble", "wood", "bricks", "checker", "bark", "spots", "stripes", "solid"];
16
+
17
+ /** Default colors for each style: [first, second]. */
18
+ const DEFAULT_COLORS = {
19
+ noise: ["#3a3f4b", "#c9ced6"],
20
+ marble: ["#e9e6df", "#5d5a55"],
21
+ wood: ["#8a5a33", "#5a3820"],
22
+ bricks: ["#9c4a35", "#cfc8bb"],
23
+ checker: ["#f5f0e6", "#E9806E"],
24
+ bark: ["#4a3426", "#2a1d15"],
25
+ spots: ["#e8c88f", "#5a3d22"],
26
+ stripes: ["#f2efe8", "#4a6fa5"],
27
+ solid: ["#c0c0c0", "#c0c0c0"],
28
+ };
29
+
30
+ const clamp01 = (t) => Math.min(1, Math.max(0, t));
31
+ const smooth = (a, b, t) => {
32
+ const x = clamp01((t - a) / (b - a));
33
+ return x * x * (3 - 2 * x);
34
+ };
35
+ const even = (n) => Math.max(2, Math.round(n / 2) * 2);
36
+ const frac = (x) => x - Math.floor(x);
37
+
38
+ /**
39
+ * For a style, a function of (u, v) in [0, 1) giving `[t, light]`: how much
40
+ * of the second color to use (0..1), and a brightness to multiply by.
41
+ */
42
+ function styleFn(style, s, seed) {
43
+ // Noise with `period` p repeats every p units: sampled at u * (a multiple of p), it tiles
44
+ const f = (x, y, octaves, period, k = 0) => fbm2(x, y, { octaves, period, seed: seed + k });
45
+ switch (style) {
46
+ case "solid":
47
+ return () => [0, 1];
48
+ case "noise":
49
+ return (u, v) => [smooth(0.25, 0.75, f(u * s, v * s, 5, s)), 1];
50
+ case "marble":
51
+ return (u, v) => {
52
+ const turb = f(u * s, v * s, 6, s);
53
+ const t = 0.5 + 0.5 * Math.sin(((u + v) * s + turb * 2.5) * Math.PI * 2);
54
+ return [Math.pow(t, 6), 1];
55
+ };
56
+ case "wood":
57
+ return (u, v) => {
58
+ const warp = f(u * s, v * s, 4, s) * 3;
59
+ const ring = frac(u * s * 2 + warp);
60
+ // Fine grain along the boards (64 and 4 are both multiples of the period 4)
61
+ return [smooth(0.55, 0.95, ring), 0.9 + 0.1 * f(u * 64, v * 4, 2, 4, 7)];
62
+ };
63
+ case "bricks": {
64
+ const rows = even(s * 2), cols = s;
65
+ return (u, v) => {
66
+ const row = Math.floor(v * rows);
67
+ const x = u * cols + (row % 2) * 0.5;
68
+ const fx = frac(x), fy = v * rows - row;
69
+ if (Math.min(fx, 1 - fx) < 0.04 || Math.min(fy, 1 - fy) < 0.08) return [1, 1]; // mortar
70
+ // Each brick its own shade (bricks cut by the edge are the same brick on the other side)
71
+ const brick = ((Math.floor(x) % cols) + cols) % cols;
72
+ const tint = 0.85 + 0.3 * noise2(brick, row, { seed });
73
+ return [0.15 * f(u * 32, v * 32, 3, 32), tint];
74
+ };
75
+ }
76
+ case "checker": {
77
+ const c = even(s);
78
+ return (u, v) => [(Math.floor(u * c) + Math.floor(v * c)) % 2, 1];
79
+ }
80
+ case "bark":
81
+ return (u, v) => {
82
+ const ridges = Math.abs(f(u * s * 3, v * s, 5, s) * 2 - 1);
83
+ return [smooth(0.05, 0.4, ridges), 0.8 + 0.4 * ridges];
84
+ };
85
+ case "spots":
86
+ return (u, v) => [smooth(0.55, 0.62, f(u * s, v * s, 4, s)), 1];
87
+ case "stripes":
88
+ return (u, v) => [smooth(0.45, 0.55, 0.5 + 0.5 * Math.sin((v * s + f(u * 4, v * 4, 3, 4) * 0.4) * Math.PI * 2)), 1];
89
+ default:
90
+ throw new Error(`makeTexture: unknown style "${style}" (one of ${TEXTURE_STYLES.join(", ")}, or a function)`);
91
+ }
92
+ }
93
+
94
+ /** [r, g, b, a] (0..255, a 0..1) from "#rgb", "#rrggbb", "#rrggbbaa", "rgb(...)" / "rgba(...)" or an array. */
95
+ export function colorBytes(color) {
96
+ if (Array.isArray(color)) return [color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 1];
97
+ const s = String(color).trim();
98
+ if (s[0] === "#") {
99
+ let h = s.slice(1);
100
+ if (h.length === 3 || h.length === 4) h = h.replace(/./g, "$&$&");
101
+ const v = (i) => parseInt(h.slice(i, i + 2), 16);
102
+ return [v(0), v(2), v(4), h.length === 8 ? v(6) / 255 : 1];
103
+ }
104
+ const nums = s.match(/-?[\d.]+%?/g);
105
+ if (/^rgba?\(/i.test(s) && nums && nums.length >= 3) {
106
+ const ch = (x) => (x.endsWith("%") ? (parseFloat(x) * 255) / 100 : +x);
107
+ const a = nums[3] === undefined ? 1 : nums[3].endsWith("%") ? parseFloat(nums[3]) / 100 : +nums[3];
108
+ return [ch(nums[0]), ch(nums[1]), ch(nums[2]), a];
109
+ }
110
+ // Named CSS colors need the browser to read them
111
+ if (typeof document !== "undefined") {
112
+ const ctx = document.createElement("canvas").getContext("2d");
113
+ if (ctx) {
114
+ ctx.fillStyle = "#000";
115
+ ctx.fillStyle = s;
116
+ if (ctx.fillStyle !== s) return colorBytes(ctx.fillStyle);
117
+ }
118
+ }
119
+ throw new Error(`Can't read the color "${color}"`);
120
+ }
121
+
122
+ /**
123
+ * The pixels of a procedural texture, without a canvas (for workers, or to
124
+ * process them further). Same options as `makeTexture`.
125
+ *
126
+ * @param {string|((u:number, v:number) => number)} style - A style name, or `(u, v) => t`
127
+ * @param {Object} [opts] - See `makeTexture`
128
+ * @returns {{ width:number, height:number, data:Uint8ClampedArray }} RGBA rows, top row first
129
+ */
130
+ export function texturePixels(style = "noise", opts = {}) {
131
+ const {
132
+ size = 256,
133
+ colors = typeof style === "string" ? DEFAULT_COLORS[style] ?? DEFAULT_COLORS.noise : ["#000000", "#ffffff"],
134
+ scale = 4,
135
+ seed = 0,
136
+ grain = 0,
137
+ } = opts;
138
+ const width = Math.max(1, Math.floor(size)), height = width;
139
+ const s = Math.max(1, Math.round(scale));
140
+ const a = colorBytes(colors[0]), b = colorBytes(colors[1] ?? colors[0]);
141
+ const fn = typeof style === "function" ? (u, v) => [clamp01(style(u, v)), 1] : styleFn(style, s, seed);
142
+ const data = new Uint8ClampedArray(width * height * 4);
143
+
144
+ for (let y = 0; y < height; y++) {
145
+ // Image rows go down, texture v goes up
146
+ const v = 1 - (y + 0.5) / height;
147
+ for (let x = 0; x < width; x++) {
148
+ const u = (x + 0.5) / width;
149
+ const [t, light] = fn(u, v);
150
+ const g = grain > 0 ? 1 + (fbm2(u * 128, v * 128, { octaves: 2, period: 128, seed: seed + 99 }) - 0.5) * grain : 1;
151
+ const k = (y * width + x) * 4;
152
+ for (let c = 0; c < 3; c++) data[k + c] = (a[c] + (b[c] - a[c]) * t) * light * g;
153
+ data[k + 3] = (a[3] + (b[3] - a[3]) * t) * 255;
154
+ }
155
+ }
156
+ return { width, height, data };
157
+ }
158
+
159
+ /**
160
+ * Paints a procedural texture on a canvas: wood, marble, bricks... Every
161
+ * style tiles with no seam. Pass the result as a material's `texture`.
162
+ *
163
+ * | Style | Looks like | `scale` is |
164
+ * | --- | --- | --- |
165
+ * | `"noise"` | Soft clouds | Blobs across |
166
+ * | `"marble"` | Veined stone | Veins across |
167
+ * | `"wood"` | Boards with rings and grain (rings run along v) | Rings across, ×2 |
168
+ * | `"bricks"` | A brick wall, each brick its own shade | Bricks across (rows: 2× as many) |
169
+ * | `"checker"` | A checkerboard | Squares across (made even) |
170
+ * | `"bark"` | Rough ridges running along v | Ridges across, ×3 |
171
+ * | `"spots"` | Blotches, like a giraffe | Spots across |
172
+ * | `"stripes"` | Wavy stripes across v | Stripes |
173
+ * | `"solid"` | Just the first color (with `grain`: a rough surface) | — |
174
+ *
175
+ * `style` can also be your own function `(u, v) => t`: 0 gives the first
176
+ * color, 1 the second. It should repeat for u and v in [0, 1) to tile.
177
+ *
178
+ * Repainting into the same canvas (`opts.canvas`) calls `updateTexture` for you.
179
+ *
180
+ * @example
181
+ * const floor = makeTexture("wood", { colors: ["#a87447", "#6b4426"], scale: 3, grain: 0.15 });
182
+ * scene.add({ mesh: makeShape("plane"), material: { texture: floor, textureRepeat: 4 } });
183
+ *
184
+ * @param {string|((u:number, v:number) => number)} [style="noise"] - One of `TEXTURE_STYLES`, or a function
185
+ * @param {Object} [opts]
186
+ * @param {number} [opts.size=256] - Width and height in pixels (a power of two is best for WebGL 1)
187
+ * @param {(string|number[])[]} [opts.colors] - [first, second]: hex, rgb() / rgba(), CSS names or [r, g, b, a?]
188
+ * (default: a pair that suits the style)
189
+ * @param {number} [opts.scale=4] - How many times the pattern repeats across (a whole number)
190
+ * @param {number} [opts.seed=0] - Another whole number gives another texture of the same style
191
+ * @param {number} [opts.grain=0] - Fine speckles on top, 0 to 1
192
+ * @param {HTMLCanvasElement|OffscreenCanvas} [opts.canvas] - Paint into this canvas instead of a new one
193
+ * @returns {HTMLCanvasElement|OffscreenCanvas} The canvas
194
+ */
195
+ export function makeTexture(style = "noise", opts = {}) {
196
+ const { width, height, data } = texturePixels(style, opts);
197
+ let canvas = opts.canvas;
198
+ if (!canvas) {
199
+ if (typeof document !== "undefined") canvas = document.createElement("canvas");
200
+ else if (typeof OffscreenCanvas !== "undefined") canvas = new OffscreenCanvas(width, height);
201
+ else throw new Error("makeTexture needs a browser (or a worker with OffscreenCanvas); use texturePixels instead");
202
+ }
203
+ canvas.width = width;
204
+ canvas.height = height;
205
+ const ctx = canvas.getContext("2d");
206
+ const img = ctx.createImageData(width, height);
207
+ img.data.set(data);
208
+ ctx.putImageData(img, 0, 0);
209
+ if (opts.canvas) updateTexture(canvas);
210
+ return canvas;
211
+ }