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,9 +1,13 @@
1
+ import { parseMTL } from "./mtl.js";
2
+ import { loadTexture } from "./texture.js";
3
+
1
4
  /**
2
5
  * Wavefront OBJ loading.
3
6
  *
4
7
  * Reads vertex positions (`v`), texture coordinates (`vt`), normals
5
- * (`vn`), faces (`f`), polylines (`l`) and material names (`usemtl`).
6
- * Everything else (.mtl files, groups) is ignored for now.
8
+ * (`vn`), faces (`f`), polylines (`l`), material names (`usemtl`) and the
9
+ * material file's name (`mtllib`). `loadOBJ(url, { materials: true })` also
10
+ * reads that .mtl file and its textures. Groups (`g`, `o`) are ignored.
7
11
  *
8
12
  * OBJ files are right-handed with +Z toward the viewer. Trid is
9
13
  * left-handed with the camera looking down +Z, so Z is flipped on
@@ -24,10 +28,15 @@
24
28
  * @property {string[]} [materialNames] - Material names from `usemtl` lines, in the order they first appear
25
29
  * @property {number[]} [faceMaterials] - For each face, its index in `materialNames`. Give the
26
30
  * object a list of materials in the same order to color or texture each part.
31
+ * @property {string} [mtllib] - The .mtl file the OBJ names (`mtllib`), if any
32
+ * @property {Object[]} [materials] - With `loadOBJ(url, { materials: true })`: one Trid material per
33
+ * entry of `materialNames`, from the .mtl file (fill, texture). Use it as the object's `material`
27
34
  *
28
35
  * @typedef {Object} ObjOptions
29
36
  * @property {number} [fit] - If set, center the model on the origin and scale it
30
37
  * so its largest side is this long. Handy because OBJ files come in any size.
38
+ * @property {boolean} [materials=false] - `loadOBJ` only: also load the .mtl file and its
39
+ * textures, into `mesh.materials`
31
40
  */
32
41
 
33
42
  /**
@@ -49,6 +58,7 @@ export function parseOBJ(text, { fit } = {}) {
49
58
  const materialNames = [];
50
59
  const faceMaterials = []; // per face: index in materialNames
51
60
  let material = 0;
61
+ let mtllib;
52
62
 
53
63
  const src = text.split(/\r?\n/);
54
64
 
@@ -66,6 +76,8 @@ export function parseOBJ(text, { fit } = {}) {
66
76
  uvs.push({ u: +(parts[1] ?? 0), v: +(parts[2] ?? 0) });
67
77
  } else if (kind === "vn") {
68
78
  normals.push({ x: +(parts[1] ?? 0), y: +(parts[2] ?? 0), z: -(parts[3] ?? 0) });
79
+ } else if (kind === "mtllib") {
80
+ mtllib ??= parts.slice(1).join(" ");
69
81
  } else if (kind === "usemtl") {
70
82
  const name = parts.slice(1).join(" ");
71
83
  material = materialNames.indexOf(name);
@@ -138,11 +150,12 @@ export function parseOBJ(text, { fit } = {}) {
138
150
  mesh.uvs = uvs;
139
151
  mesh.faceUVs = faceUVs;
140
152
  }
141
- // Parts using different materials
142
- if (materialNames.length > 1) {
153
+ // Material names (a list of materials on the object then colors each part)
154
+ if (materialNames.length > 0) {
143
155
  mesh.materialNames = materialNames;
144
156
  mesh.faceMaterials = faceMaterials;
145
157
  }
158
+ if (mtllib) mesh.mtllib = mtllib;
146
159
  if (fit != null) fitMesh(mesh, fit);
147
160
  return mesh;
148
161
  }
@@ -187,10 +200,49 @@ export function fitMesh(mesh, size = 1) {
187
200
  * @param {ObjOptions} [opts]
188
201
  * @returns {Promise<ObjMesh>}
189
202
  */
190
- export async function loadOBJ(url, opts) {
203
+ export async function loadOBJ(url, opts = {}) {
191
204
  const res = await fetch(url);
192
205
  if (!res.ok) throw new Error(`Could not load ${url} (${res.status})`);
193
- return parseOBJ(await res.text(), opts);
206
+ const mesh = parseOBJ(await res.text(), opts);
207
+ if (opts.materials) mesh.materials = await loadMaterials(mesh, new URL(url, location.href));
208
+ return mesh;
209
+ }
210
+
211
+ /**
212
+ * The materials of a loaded OBJ, from its .mtl file: one per name in
213
+ * `mesh.materialNames`, with the texture images loaded. Missing files only
214
+ * give a warning (the part keeps a plain fill).
215
+ *
216
+ * @param {ObjMesh} mesh - Parsed with `parseOBJ`
217
+ * @param {URL|string} base - Where the OBJ file is (file names are relative to it)
218
+ * @returns {Promise<Object[]>}
219
+ */
220
+ export async function loadMaterials(mesh, base) {
221
+ const names = mesh.materialNames ?? [];
222
+ if (!mesh.mtllib) return names.map((name) => ({ name, fill: "#cccccc" }));
223
+ let lib = {};
224
+ const mtlURL = new URL(mesh.mtllib, base);
225
+ try {
226
+ const res = await fetch(mtlURL);
227
+ if (!res.ok) throw new Error(`${res.status}`);
228
+ lib = parseMTL(await res.text());
229
+ } catch (err) {
230
+ console.warn(`Trid: couldn't load the material file ${mtlURL} (${err.message})`);
231
+ }
232
+ return Promise.all(names.map(async (name) => {
233
+ const m = lib[name] ?? {};
234
+ const material = { name, fill: m.fill ?? (m.textureFile ? "#ffffff" : "#cccccc") };
235
+ if (m.textureFile) {
236
+ const file = new URL(m.textureFile.replace(/\\/g, "/"), mtlURL);
237
+ try {
238
+ material.texture = await loadTexture(file.href);
239
+ material.fill = "#ffffff"; // MTL's Kd under a texture is usually a tint nobody meant
240
+ } catch (err) {
241
+ console.warn(`Trid: couldn't load the texture ${file} (${err.message})`);
242
+ }
243
+ }
244
+ return material;
245
+ }));
194
246
  }
195
247
 
196
248
  /**
@@ -3,9 +3,15 @@
3
3
  *
4
4
  * A material's `texture` can be anything the canvas can draw:
5
5
  * an `<img>`, a `<canvas>`, a `<video>` or an `ImageBitmap`.
6
- * Canvases and videos are read again every frame, so they can change.
6
+ *
7
+ * Videos are read again every frame. Everything else, canvases included,
8
+ * is read once: after redrawing a canvas, call `updateTexture(canvas)`, or
9
+ * give its material `liveTexture: true` to read it every frame.
7
10
  */
8
11
 
12
+ /** How many times each source was updated with `updateTexture` (0 when never). */
13
+ const versions = new WeakMap();
14
+
9
15
  /**
10
16
  * Loads an image to use as a texture.
11
17
  *
@@ -48,9 +54,44 @@ export function textureSize(src) {
48
54
  };
49
55
  }
50
56
 
51
- /** Whether a source can change over time (canvas, video), so it's read every frame. */
52
- export function textureIsLive(src) {
53
- return (typeof HTMLCanvasElement !== "undefined" && src instanceof HTMLCanvasElement)
54
- || (typeof HTMLVideoElement !== "undefined" && src instanceof HTMLVideoElement)
55
- || (typeof OffscreenCanvas !== "undefined" && src instanceof OffscreenCanvas);
57
+ /**
58
+ * Whether a texture is read again every frame: videos, and any source whose
59
+ * material has `liveTexture: true`. `liveTexture: false` freezes a video.
60
+ *
61
+ * @param {CanvasImageSource} src
62
+ * @param {{ liveTexture?: boolean }} [material] - The material using it
63
+ * @returns {boolean}
64
+ */
65
+ export function textureIsLive(src, material) {
66
+ return material?.liveTexture ?? (typeof HTMLVideoElement !== "undefined" && src instanceof HTMLVideoElement);
67
+ }
68
+
69
+ /**
70
+ * Tells the renderers a texture source changed: call it after drawing on a
71
+ * canvas used as a texture. It's read again (once) on the next frame, by
72
+ * every renderer and every material using it.
73
+ *
74
+ * Canvases are read only once otherwise, which keeps them fast and sharp
75
+ * (they get mipmaps). For a canvas redrawn every frame, set
76
+ * `liveTexture: true` on its material instead.
77
+ *
78
+ * @example
79
+ * paintClouds(canvas, seed);
80
+ * updateTexture(canvas);
81
+ *
82
+ * @param {CanvasImageSource} src - The canvas (or image) that changed
83
+ */
84
+ export function updateTexture(src) {
85
+ if (src && typeof src === "object") versions.set(src, (versions.get(src) ?? 0) + 1);
86
+ }
87
+
88
+ /**
89
+ * How many times `updateTexture` was called for a source (0 when never).
90
+ * Renderers compare it with the version they read last.
91
+ *
92
+ * @param {CanvasImageSource} src
93
+ * @returns {number}
94
+ */
95
+ export function textureVersion(src) {
96
+ return versions.get(src) ?? 0;
56
97
  }
@@ -0,0 +1,200 @@
1
+ import { matFromEuler } from "./rotation.js";
2
+
3
+ /**
4
+ * 4×4 transform matrices, for objects inside groups: an object's world
5
+ * matrix is its parent's world matrix times its own.
6
+ *
7
+ * A matrix is an array of 16 numbers in column-major order (the order
8
+ * WebGL uses): `m[col * 4 + row]`, translation in `m[12..14]`. They're
9
+ * built with `matFromEuler`, so they match `localToWorld` exactly.
10
+ *
11
+ * @typedef {number[]} Mat4
12
+ * @typedef {{x:number, y:number, z:number}} Vec3
13
+ */
14
+
15
+ /** @returns {Mat4} The identity matrix (changes nothing). */
16
+ export function identityMat4() {
17
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
18
+ }
19
+
20
+ /**
21
+ * The matrix of a transform: scale, then rotate (X, then Y, then Z), then
22
+ * move. Parts left out change nothing.
23
+ *
24
+ * @param {{ pos?:Vec3, rot?:Vec3, scale?:Vec3 }} t - A transform, or a scene object
25
+ * @returns {Mat4}
26
+ */
27
+ export function mat4FromTransform({ pos, rot, scale } = {}) {
28
+ const r = rot ? matFromEuler(rot) : [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
29
+ const sx = scale?.x ?? 1, sy = scale?.y ?? 1, sz = scale?.z ?? 1;
30
+ return [
31
+ r[0][0] * sx, r[1][0] * sx, r[2][0] * sx, 0,
32
+ r[0][1] * sy, r[1][1] * sy, r[2][1] * sy, 0,
33
+ r[0][2] * sz, r[1][2] * sz, r[2][2] * sz, 0,
34
+ pos?.x ?? 0, pos?.y ?? 0, pos?.z ?? 0, 1,
35
+ ];
36
+ }
37
+
38
+ /**
39
+ * Matrix product a · b: b is applied first. A child's world matrix is
40
+ * `mulMat4(parentWorld, childLocal)`.
41
+ *
42
+ * @param {Mat4} a
43
+ * @param {Mat4} b
44
+ * @returns {Mat4}
45
+ */
46
+ export function mulMat4(a, b) {
47
+ const out = new Array(16);
48
+ for (let c = 0; c < 4; c++) {
49
+ for (let r = 0; r < 4; r++) {
50
+ out[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Inverse of a transform matrix (scale, rotation and move; no perspective).
58
+ * Turns world positions into an object's local ones.
59
+ *
60
+ * @param {Mat4} m
61
+ * @returns {Mat4} The inverse, or the identity if `m` flattens space (a zero scale)
62
+ */
63
+ export function invertMat4(m) {
64
+ const [a, b, c, , d, e, f, , g, h, i] = m;
65
+ // Inverse of the 3×3 part by cofactors
66
+ const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
67
+ const det = a * A + b * B + c * C;
68
+ if (Math.abs(det) < 1e-12) return identityMat4();
69
+ const k = 1 / det;
70
+ const inv = [
71
+ A * k, -(b * i - c * h) * k, (b * f - c * e) * k,
72
+ B * k, (a * i - c * g) * k, -(a * f - c * d) * k,
73
+ C * k, -(a * h - b * g) * k, (a * e - b * d) * k,
74
+ ];
75
+ const tx = m[12], ty = m[13], tz = m[14];
76
+ return [
77
+ inv[0], inv[1], inv[2], 0,
78
+ inv[3], inv[4], inv[5], 0,
79
+ inv[6], inv[7], inv[8], 0,
80
+ -(inv[0] * tx + inv[3] * ty + inv[6] * tz),
81
+ -(inv[1] * tx + inv[4] * ty + inv[7] * tz),
82
+ -(inv[2] * tx + inv[5] * ty + inv[8] * tz),
83
+ 1,
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * Determinant of the 3×3 part: < 0 when the matrix mirrors (an odd number
89
+ * of negative scales), which turns faces inside out.
90
+ *
91
+ * @param {Mat4} m
92
+ * @returns {number}
93
+ */
94
+ export function determinantMat4(m) {
95
+ return m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5]);
96
+ }
97
+
98
+ /**
99
+ * A point through a matrix (scaled, turned and moved).
100
+ *
101
+ * @param {Mat4} m
102
+ * @param {Vec3} p
103
+ * @returns {Vec3}
104
+ */
105
+ export function transformPoint(m, p) {
106
+ return {
107
+ x: m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12],
108
+ y: m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13],
109
+ z: m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14],
110
+ };
111
+ }
112
+
113
+ /**
114
+ * A direction or offset through a matrix: scaled and turned, not moved.
115
+ *
116
+ * @param {Mat4} m
117
+ * @param {Vec3} v
118
+ * @returns {Vec3}
119
+ */
120
+ export function transformDirection(m, v) {
121
+ return {
122
+ x: m[0] * v.x + m[4] * v.y + m[8] * v.z,
123
+ y: m[1] * v.x + m[5] * v.y + m[9] * v.z,
124
+ z: m[2] * v.x + m[6] * v.y + m[10] * v.z,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The 3×3 matrix that turns surface normals with an object (the inverse
130
+ * transpose of its matrix), column-major. It keeps normals perpendicular to
131
+ * stretched faces, and pointing outward on mirrored ones.
132
+ *
133
+ * @param {Mat4} m
134
+ * @returns {number[]} 9 numbers, column-major (ready for `gl.uniformMatrix3fv`)
135
+ */
136
+ export function normalMatrix(m) {
137
+ const inv = invertMat4(m);
138
+ // Transpose of the inverse's 3×3 part
139
+ return [inv[0], inv[4], inv[8], inv[1], inv[5], inv[9], inv[2], inv[6], inv[10]];
140
+ }
141
+
142
+ /**
143
+ * A surface normal through a normal matrix (from `normalMatrix`), unit length.
144
+ *
145
+ * @param {number[]} nm - 3×3 column-major
146
+ * @param {Vec3} n
147
+ * @returns {Vec3}
148
+ */
149
+ export function transformNormal(nm, n) {
150
+ const x = nm[0] * n.x + nm[3] * n.y + nm[6] * n.z;
151
+ const y = nm[1] * n.x + nm[4] * n.y + nm[7] * n.z;
152
+ const z = nm[2] * n.x + nm[5] * n.y + nm[8] * n.z;
153
+ const len = Math.hypot(x, y, z) || 1;
154
+ return { x: x / len, y: y / len, z: z / len };
155
+ }
156
+
157
+ /**
158
+ * Turns a world-space `TransformOp` (what `TransformGizmo` reports) into the
159
+ * space of an object's parent, so `applyTransformOp(child, op)` works on an
160
+ * object inside a group. Exact when the parents scale evenly (the same on
161
+ * every axis); with uneven scales, the turn is the closest a plain rotation
162
+ * can get.
163
+ *
164
+ * @example
165
+ * const local = obj.parent ? transformOpToLocal(op, worldMatrix(obj.parent)) : op;
166
+ * Object.assign(obj, applyTransformOp(saved, local));
167
+ *
168
+ * @param {Object} op - `{ mode: "move", delta }`, `{ mode: "rotate", axis, angle, center }`
169
+ * or `{ mode: "scale", axis, factor, center }`
170
+ * @param {Mat4} parentWorld - The parent's world matrix
171
+ * @returns {Object} The same operation, in the parent's space
172
+ */
173
+ export function transformOpToLocal(op, parentWorld) {
174
+ const inv = invertMat4(parentWorld);
175
+ const unit = (v) => {
176
+ const len = Math.hypot(v.x, v.y, v.z) || 1;
177
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
178
+ };
179
+ switch (op.mode) {
180
+ case "move":
181
+ return { mode: "move", delta: transformDirection(inv, op.delta) };
182
+ case "rotate":
183
+ // A mirrored parent turns the other way
184
+ return {
185
+ mode: "rotate",
186
+ axis: unit(transformDirection(inv, op.axis)),
187
+ angle: determinantMat4(parentWorld) < 0 ? -op.angle : op.angle,
188
+ center: transformPoint(inv, op.center),
189
+ };
190
+ case "scale":
191
+ return {
192
+ mode: "scale",
193
+ axis: op.axis ? unit(transformDirection(inv, op.axis)) : null,
194
+ factor: op.factor,
195
+ center: transformPoint(inv, op.center),
196
+ };
197
+ default:
198
+ return op;
199
+ }
200
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Seeded randomness and smooth noise, for procedural shapes and textures.
3
+ * The same seed always gives the same result, on every browser.
4
+ */
5
+
6
+ /**
7
+ * A seeded random number generator (mulberry32): call the function it
8
+ * returns for the next number in [0, 1). Use it instead of Math.random when
9
+ * the same seed must build the same model again.
10
+ *
11
+ * @example
12
+ * const random = rng(42);
13
+ * const x = random(), y = random(); // the same two numbers every time
14
+ *
15
+ * @param {number} [seed=0] - Any whole number
16
+ * @returns {() => number}
17
+ */
18
+ export function rng(seed = 0) {
19
+ let a = seed >>> 0;
20
+ return () => {
21
+ a = (a + 0x6d2b79f5) >>> 0;
22
+ let t = a;
23
+ t = Math.imul(t ^ (t >>> 15), t | 1);
24
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
25
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
26
+ };
27
+ }
28
+
29
+ /** Hash of lattice coordinates and a seed, in [0, 1). */
30
+ function hash3(x, y, z, seed) {
31
+ let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647) ^ Math.imul(seed, 1442695041);
32
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
33
+ h ^= h >>> 16;
34
+ return (h >>> 0) / 4294967296;
35
+ }
36
+
37
+ const fade = (t) => t * t * t * (t * (t * 6 - 15) + 10);
38
+ const lerp = (a, b, t) => a + (b - a) * t;
39
+ /** Wraps lattice coordinate n when the noise repeats every `period` (0: no wrap) */
40
+ const wrap = (n, period) => (period > 0 ? ((n % period) + period) % period : n);
41
+ /** A usable period: a whole number, or 0 for none */
42
+ const periodOf = (p) => (p > 0 ? Math.max(1, Math.round(p)) : 0);
43
+
44
+ function value2(x, y, seed, period) {
45
+ const xi = Math.floor(x), yi = Math.floor(y);
46
+ const fx = fade(x - xi), fy = fade(y - yi);
47
+ const x0 = wrap(xi, period), x1 = wrap(xi + 1, period);
48
+ const y0 = wrap(yi, period), y1 = wrap(yi + 1, period);
49
+ return lerp(
50
+ lerp(hash3(x0, y0, 0, seed), hash3(x1, y0, 0, seed), fx),
51
+ lerp(hash3(x0, y1, 0, seed), hash3(x1, y1, 0, seed), fx),
52
+ fy,
53
+ );
54
+ }
55
+
56
+ function value3(x, y, z, seed, period) {
57
+ const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
58
+ const fx = fade(x - xi), fy = fade(y - yi), fz = fade(z - zi);
59
+ const h = (dx, dy, dz) => hash3(wrap(xi + dx, period), wrap(yi + dy, period), wrap(zi + dz, period), seed);
60
+ return lerp(
61
+ lerp(lerp(h(0, 0, 0), h(1, 0, 0), fx), lerp(h(0, 1, 0), h(1, 1, 0), fx), fy),
62
+ lerp(lerp(h(0, 0, 1), h(1, 0, 1), fx), lerp(h(0, 1, 1), h(1, 1, 1), fx), fy),
63
+ fz,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Smooth 2D noise (value noise) in [0, 1): random at whole coordinates,
69
+ * blended smoothly in between. Multiply the coordinates to change the size
70
+ * of the blobs.
71
+ *
72
+ * With `period`, the noise repeats every `period` units on both axes, so a
73
+ * texture painted with it tiles with no seam (map the texture's width to
74
+ * `period` units).
75
+ *
76
+ * @param {number} x
77
+ * @param {number} y
78
+ * @param {Object} [opts]
79
+ * @param {number} [opts.seed=0] - Whole number; another seed gives other noise
80
+ * @param {number} [opts.period=0] - Repeat every this many units (a whole number; 0: never)
81
+ * @returns {number}
82
+ */
83
+ export function noise2(x, y, { seed = 0, period = 0 } = {}) {
84
+ return value2(x, y, seed, periodOf(period));
85
+ }
86
+
87
+ /**
88
+ * Smooth 3D noise in [0, 1). Sample it at a mesh's vertex positions to
89
+ * displace a sphere with no seam. `period` repeats it on all three axes.
90
+ *
91
+ * @param {number} x
92
+ * @param {number} y
93
+ * @param {number} z
94
+ * @param {Object} [opts]
95
+ * @param {number} [opts.seed=0] - Whole number; another seed gives other noise
96
+ * @param {number} [opts.period=0] - Repeat every this many units (a whole number; 0: never)
97
+ * @returns {number}
98
+ */
99
+ export function noise3(x, y, z, { seed = 0, period = 0 } = {}) {
100
+ return value3(x, y, z, seed, periodOf(period));
101
+ }
102
+
103
+ /**
104
+ * Fractal 2D noise (fBm): `octaves` layers of noise2, each twice as fine and
105
+ * `gain` times as strong as the one before. Gives natural detail: terrain,
106
+ * clouds, rock, marble. Result in about [0, 1), averaging 0.5.
107
+ *
108
+ * @example
109
+ * // A tiling cloud texture
110
+ * const p = 4; // 4 blobs across
111
+ * for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
112
+ * const n = fbm2((x / size) * p, (y / size) * p, { period: p });
113
+ * ctx.fillStyle = `rgb(${n * 255}, ${n * 255}, 255)`;
114
+ * ctx.fillRect(x, y, 1, 1);
115
+ * }
116
+ *
117
+ * @param {number} x
118
+ * @param {number} y
119
+ * @param {Object} [opts]
120
+ * @param {number} [opts.octaves=5] - Layers of detail
121
+ * @param {number} [opts.gain=0.5] - Strength of each layer compared to the one before
122
+ * @param {number} [opts.seed=0] - Whole number; another seed gives other noise
123
+ * @param {number} [opts.period=0] - Repeat every this many units (a whole number; 0: never)
124
+ * @returns {number}
125
+ */
126
+ export function fbm2(x, y, { octaves = 5, gain = 0.5, seed = 0, period = 0 } = {}) {
127
+ const p = periodOf(period);
128
+ let sum = 0, amp = 1, norm = 0, freq = 1;
129
+ for (let i = 0; i < octaves; i++) {
130
+ sum += amp * value2(x * freq, y * freq, seed + i * 31, p * freq);
131
+ norm += amp;
132
+ amp *= gain;
133
+ freq *= 2;
134
+ }
135
+ return norm > 0 ? sum / norm : 0;
136
+ }
137
+
138
+ /**
139
+ * Fractal 3D noise (fBm), like `fbm2` in three dimensions. Result in about
140
+ * [0, 1), averaging 0.5.
141
+ *
142
+ * @param {number} x
143
+ * @param {number} y
144
+ * @param {number} z
145
+ * @param {Object} [opts]
146
+ * @param {number} [opts.octaves=5] - Layers of detail
147
+ * @param {number} [opts.gain=0.5] - Strength of each layer compared to the one before
148
+ * @param {number} [opts.seed=0] - Whole number; another seed gives other noise
149
+ * @param {number} [opts.period=0] - Repeat every this many units (a whole number; 0: never)
150
+ * @returns {number}
151
+ */
152
+ export function fbm3(x, y, z, { octaves = 5, gain = 0.5, seed = 0, period = 0 } = {}) {
153
+ const p = periodOf(period);
154
+ let sum = 0, amp = 1, norm = 0, freq = 1;
155
+ for (let i = 0; i < octaves; i++) {
156
+ sum += amp * value3(x * freq, y * freq, z * freq, seed + i * 31, p * freq);
157
+ norm += amp;
158
+ amp *= gain;
159
+ freq *= 2;
160
+ }
161
+ return norm > 0 ? sum / norm : 0;
162
+ }
@@ -0,0 +1,152 @@
1
+ import { rotateXYZ } from "./transform.js";
2
+
3
+ /**
4
+ * Rotation matrices and move / rotate / scale operations on an object's
5
+ * transform ({ pos, rot, scale }, applied scale → rotate X, Y, Z → move).
6
+ *
7
+ * Matrices are 3×3, row-major (`m[row][col]`), applied as M · v. They're
8
+ * built from `rotateXYZ` itself, so they always match what the renderers draw.
9
+ *
10
+ * @typedef {[[number,number,number],[number,number,number],[number,number,number]]} Mat3
11
+ * @typedef {{x:number, y:number, z:number}} Vec3
12
+ * @typedef {{ pos:Vec3, rot:Vec3, scale:Vec3 }} Transform
13
+ *
14
+ * @typedef {{ mode:"move", delta:Vec3 }
15
+ * | { mode:"rotate", axis:Vec3, angle:number, center:Vec3 }
16
+ * | { mode:"scale", axis:Vec3|null, factor:number, center:Vec3 }} TransformOp
17
+ * An operation in world space. Rotate: a right-handed turn of `angle`
18
+ * radians around the unit `axis`, about `center`. Scale: `factor` about
19
+ * `center`, uniform when `axis` is null, otherwise along that unit axis.
20
+ */
21
+
22
+ const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
23
+ const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z });
24
+ const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
25
+ const mul = (a, k) => ({ x: a.x * k, y: a.y * k, z: a.z * k });
26
+
27
+ /**
28
+ * The matrix `rotateXYZ(p, rot)` applies.
29
+ *
30
+ * @param {Vec3} rot - Angles in radians (X, then Y, then Z)
31
+ * @returns {Mat3}
32
+ */
33
+ export function matFromEuler(rot) {
34
+ const cx = rotateXYZ({ x: 1, y: 0, z: 0 }, rot);
35
+ const cy = rotateXYZ({ x: 0, y: 1, z: 0 }, rot);
36
+ const cz = rotateXYZ({ x: 0, y: 0, z: 1 }, rot);
37
+ return [
38
+ [cx.x, cy.x, cz.x],
39
+ [cx.y, cy.y, cz.y],
40
+ [cx.z, cy.z, cz.z],
41
+ ];
42
+ }
43
+
44
+ /**
45
+ * Angles for `rotateXYZ` (an object's `rot`) from a rotation matrix: the
46
+ * inverse of `matFromEuler`. Looking straight along Y (gimbal lock), X and
47
+ * Z turn the same way, so the whole turn goes into Z.
48
+ *
49
+ * @param {Mat3} m - A rotation matrix
50
+ * @returns {Vec3} Angles in radians
51
+ */
52
+ export function eulerFromMat(m) {
53
+ // rotateXYZ's matrix is Rz·Ry·Rx: m[2][0] = sin(y), m[2][1] = cos(y)·sin(x), m[1][0] = sin(z)·cos(y)
54
+ const sy = Math.max(-1, Math.min(1, m[2][0]));
55
+ const y = Math.asin(sy);
56
+ if (Math.abs(sy) < 0.99999) {
57
+ return { x: Math.atan2(m[2][1], m[2][2]), y, z: Math.atan2(m[1][0], m[0][0]) };
58
+ }
59
+ return { x: 0, y, z: Math.atan2(-m[0][1], m[1][1]) };
60
+ }
61
+
62
+ /**
63
+ * A right-handed turn of `angle` radians around a unit `axis` (Rodrigues).
64
+ *
65
+ * @param {Vec3} axis - Unit vector
66
+ * @param {number} angle - Radians
67
+ * @returns {Mat3}
68
+ */
69
+ export function matFromAxisAngle(axis, angle) {
70
+ const { x, y, z } = axis, c = Math.cos(angle), s = Math.sin(angle), t = 1 - c;
71
+ return [
72
+ [t * x * x + c, t * x * y - s * z, t * x * z + s * y],
73
+ [t * x * y + s * z, t * y * y + c, t * y * z - s * x],
74
+ [t * x * z - s * y, t * y * z + s * x, t * z * z + c],
75
+ ];
76
+ }
77
+
78
+ /**
79
+ * Matrix product a · b (b is applied first).
80
+ *
81
+ * @param {Mat3} a
82
+ * @param {Mat3} b
83
+ * @returns {Mat3}
84
+ */
85
+ export function mulMat(a, b) {
86
+ return [0, 1, 2].map((i) => [0, 1, 2].map((j) => a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]));
87
+ }
88
+
89
+ /**
90
+ * Matrix times vector, m · v.
91
+ *
92
+ * @param {Mat3} m
93
+ * @param {Vec3} v
94
+ * @returns {Vec3}
95
+ */
96
+ export function mulVec(m, v) {
97
+ return {
98
+ x: m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z,
99
+ y: m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z,
100
+ z: m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Applies a world-space operation to a transform and returns the new one
106
+ * (the input isn't changed). `TransformGizmo` reports these.
107
+ *
108
+ * - move: adds `delta` to `pos`
109
+ * - rotate: turns the object around `center`: its position orbits and its
110
+ * orientation turns, both by the same rotation
111
+ * - scale: scales about `center`. Stretching a rotated object along a world
112
+ * axis can't be stored as `scale` (it would shear), so it stretches the
113
+ * object's own axis that points most along it
114
+ *
115
+ * @example
116
+ * Object.assign(obj, applyTransformOp(obj, { mode: "rotate", axis: { x: 0, y: 1, z: 0 }, angle: Math.PI / 2, center: { x: 0, y: 0, z: 0 } }));
117
+ *
118
+ * @param {Transform} t - The transform before the operation
119
+ * @param {TransformOp} op
120
+ * @returns {Transform}
121
+ */
122
+ export function applyTransformOp(t, op) {
123
+ switch (op.mode) {
124
+ case "move":
125
+ return { pos: add(t.pos, op.delta), rot: { ...t.rot }, scale: { ...t.scale } };
126
+ case "rotate": {
127
+ const r = matFromAxisAngle(op.axis, op.angle);
128
+ return {
129
+ pos: add(op.center, mulVec(r, sub(t.pos, op.center))),
130
+ rot: eulerFromMat(mulMat(r, matFromEuler(t.rot))),
131
+ scale: { ...t.scale },
132
+ };
133
+ }
134
+ case "scale": {
135
+ const f = op.factor;
136
+ if (!op.axis) {
137
+ return { pos: add(op.center, mul(sub(t.pos, op.center), f)), rot: { ...t.rot }, scale: mul(t.scale, f) };
138
+ }
139
+ const a = op.axis;
140
+ const pos = add(t.pos, mul(a, (f - 1) * dot(sub(t.pos, op.center), a)));
141
+ // The object's own axis most aligned with the stretch (columns of its rotation)
142
+ const m = matFromEuler(t.rot);
143
+ const along = [0, 1, 2].map((j) => Math.abs(m[0][j] * a.x + m[1][j] * a.y + m[2][j] * a.z));
144
+ const j = along.indexOf(Math.max(...along));
145
+ const scale = { ...t.scale };
146
+ scale["xyz"[j]] *= f;
147
+ return { pos, rot: { ...t.rot }, scale };
148
+ }
149
+ default:
150
+ throw new Error("applyTransformOp: unknown mode " + op?.mode);
151
+ }
152
+ }