trid3d 0.4.0 → 0.5.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,191 @@
1
+ /**
2
+ * Everyday deformers: taper, twist, bend and bulge. Each one changes a mesh
3
+ * in place (like `displaceMesh`: the mesh's own normals are removed, and
4
+ * `dirty` is set) and returns it.
5
+ *
6
+ * Taper, twist and bend work along an `axis`. Where a vertex is along it is
7
+ * measured over the mesh's size: 0 at its low end, 1 at its high end.
8
+ * `from` and `to` (in that 0 to 1 measure) limit the effect to a part of the
9
+ * mesh: below `from` nothing changes, above `to` the full effect carries on.
10
+ * The axis line goes through `center` (default: the middle of the mesh's box).
11
+ */
12
+ import { displaceMesh, meshBounds } from "../render/meshUtils.js";
13
+ import { newell } from "../render/normals.js";
14
+
15
+ const AXES = ["x", "y", "z"];
16
+ /** The two other axes, in an order that keeps turns the same handedness as rotateX / Y / Z. */
17
+ const OTHERS = { x: ["y", "z"], y: ["x", "z"], z: ["x", "y"] };
18
+
19
+ /**
20
+ * Where things are along the axis: the mesh's span on it, the range in
21
+ * coordinates, the axis line's center, and `w(v)`: how far into the range a
22
+ * vertex is (0 before it, 1 after it).
23
+ */
24
+ function along(mesh, { axis = "y", from = 0, to = 1, center } = {}) {
25
+ if (!AXES.includes(axis)) throw new Error(`Unknown axis "${axis}": use "x", "y" or "z"`);
26
+ const box = meshBounds(mesh);
27
+ if (!box || !(box.size[axis] > 0)) return null;
28
+ const f = Number.isFinite(from) ? from : 0, t = Number.isFinite(to) ? to : 1;
29
+ const a = box.min[axis] + box.size[axis] * Math.min(f, t);
30
+ const b = box.min[axis] + box.size[axis] * Math.max(f, t);
31
+ const c = { ...box.center, ...(center ?? {}) };
32
+ const w = (v) => (b > a ? Math.min(1, Math.max(0, (v[axis] - a) / (b - a))) : v[axis] >= a ? 1 : 0);
33
+ return { a, b, c, w };
34
+ }
35
+
36
+ /**
37
+ * Narrows or widens a mesh along an axis, around the axis line: the start of
38
+ * the range keeps its size, the end is `amount` times as wide (0.2 = almost a
39
+ * point, 2 = twice as wide). A function gets how far into the range a
40
+ * vertex is (0 to 1) and returns its size.
41
+ *
42
+ * @example
43
+ * taperMesh(makeShape("cylinder"), { axis: "y", amount: 0.3 }); // a cone-ish column
44
+ *
45
+ * @template M
46
+ * @param {M} mesh
47
+ * @param {{ axis?: "x"|"y"|"z", amount?: number | ((w: number) => number), from?: number, to?: number, center?: {x?:number,y?:number,z?:number} }} [opts]
48
+ * @returns {M} The same mesh
49
+ */
50
+ export function taperMesh(mesh, opts = {}) {
51
+ const r = along(mesh, opts);
52
+ if (!r) return mesh;
53
+ const { amount = 0.5, axis = "y" } = opts;
54
+ const size = typeof amount === "function" ? amount : (w) => 1 + (amount - 1) * w;
55
+ const [p, q] = OTHERS[axis];
56
+ return displaceMesh(mesh, (v) => {
57
+ const k = size(r.w(v));
58
+ if (!Number.isFinite(k)) return v;
59
+ return { ...v, [p]: r.c[p] + (v[p] - r.c[p]) * k, [q]: r.c[q] + (v[q] - r.c[q]) * k };
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Turns a mesh around an axis line, more and more along it: `angle` radians
65
+ * in all, from the start of the range to its end (the rest of the mesh past
66
+ * the end turns the full angle). A function gets how far into the range a
67
+ * vertex is (0 to 1) and returns its angle.
68
+ *
69
+ * @template M
70
+ * @param {M} mesh
71
+ * @param {{ axis?: "x"|"y"|"z", angle?: number | ((w: number) => number), from?: number, to?: number, center?: {x?:number,y?:number,z?:number} }} [opts]
72
+ * @returns {M} The same mesh
73
+ */
74
+ export function twistMesh(mesh, opts = {}) {
75
+ const r = along(mesh, opts);
76
+ if (!r) return mesh;
77
+ const { angle = Math.PI / 2, axis = "y" } = opts;
78
+ const turn = typeof angle === "function" ? angle : (w) => angle * w;
79
+ const [p, q] = OTHERS[axis];
80
+ return displaceMesh(mesh, (v) => {
81
+ const a = turn(r.w(v));
82
+ if (!Number.isFinite(a) || a === 0) return v;
83
+ const c = Math.cos(a), s = Math.sin(a);
84
+ const dp = v[p] - r.c[p], dq = v[q] - r.c[q];
85
+ return { ...v, [p]: r.c[p] + dp * c - dq * s, [q]: r.c[q] + dp * s + dq * c };
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Bends a mesh along an axis into an arc of `angle` radians, curving toward
91
+ * `toward` (another axis; a negative angle curves the other way). The range
92
+ * is the part that curves: before it nothing moves, after it the mesh
93
+ * carries on straight in the new direction, like a bent pipe. π/2 is a
94
+ * quarter turn. The part that curves keeps its length along the axis line.
95
+ *
96
+ * @example
97
+ * // A banana: a stretched sphere bent a third of a turn
98
+ * bendMesh(scaleMesh(makeShape("sphere"), 0.4, 1.6, 0.4), { axis: "y", angle: 2, toward: "x" });
99
+ *
100
+ * @template M
101
+ * @param {M} mesh
102
+ * @param {{ axis?: "x"|"y"|"z", angle?: number, toward?: "x"|"y"|"z", from?: number, to?: number, center?: {x?:number,y?:number,z?:number} }} [opts]
103
+ * @returns {M} The same mesh
104
+ */
105
+ export function bendMesh(mesh, opts = {}) {
106
+ const { angle = Math.PI / 2, axis = "y" } = opts;
107
+ const toward = opts.toward && opts.toward !== axis ? opts.toward : OTHERS[axis][1];
108
+ if (!AXES.includes(toward)) throw new Error(`Unknown axis "${toward}": use "x", "y" or "z"`);
109
+ const r = along(mesh, opts);
110
+ if (!r || !Number.isFinite(angle) || Math.abs(angle) < 1e-9) return mesh;
111
+ const len = r.b - r.a;
112
+ if (!(len > 0)) return mesh;
113
+ const R = len / angle;
114
+ const c0 = r.c[toward];
115
+ const end = { cos: Math.cos(angle), sin: Math.sin(angle) };
116
+ return displaceMesh(mesh, (v) => {
117
+ const x = v[axis];
118
+ if (x <= r.a) return v;
119
+ const d = v[toward] - c0;
120
+ const th = Math.min(x - r.a, len) / R;
121
+ let ax = r.a + (R - d) * Math.sin(th);
122
+ let tw = c0 + R - (R - d) * Math.cos(th);
123
+ // Past the range: straight on from the end of the arc
124
+ if (x > r.b) {
125
+ ax += (x - r.b) * end.cos;
126
+ tw += (x - r.b) * end.sin;
127
+ }
128
+ return { ...v, [axis]: ax, [toward]: tw };
129
+ });
130
+ }
131
+
132
+ /** A unit normal per vertex: faces around it averaged (bigger faces count more), the same for vertices at the same place. */
133
+ function vertexNormals(mesh) {
134
+ const v = mesh.vertices;
135
+ const sum = v.map(() => ({ x: 0, y: 0, z: 0 }));
136
+ for (const f of mesh.faces ?? []) {
137
+ if (f.length < 3) continue;
138
+ const n = newell(f.map((i) => v[i]));
139
+ for (const i of f) {
140
+ sum[i].x += n.x; sum[i].y += n.y; sum[i].z += n.z;
141
+ }
142
+ }
143
+ // Vertices at the same place (texture seams, OBJ files) share one normal, so they move together
144
+ const groups = new Map();
145
+ v.forEach((p, i) => {
146
+ const k = `${p.x.toFixed(9)},${p.y.toFixed(9)},${p.z.toFixed(9)}`;
147
+ const g = groups.get(k);
148
+ if (g) g.push(i);
149
+ else groups.set(k, [i]);
150
+ });
151
+ const out = new Array(v.length);
152
+ for (const g of groups.values()) {
153
+ let x = 0, y = 0, z = 0;
154
+ for (const i of g) {
155
+ x += sum[i].x; y += sum[i].y; z += sum[i].z;
156
+ }
157
+ const l = Math.hypot(x, y, z);
158
+ const n = l > 1e-12 ? { x: x / l, y: y / l, z: z / l } : null;
159
+ for (const i of g) out[i] = n;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Pushes the surface out (or in, with a negative `amount`) around `center`,
166
+ * fading smoothly to nothing at `radius`: cheeks, bellies, muscles, dents.
167
+ * `amount` is how far the middle moves.
168
+ *
169
+ * By default each point moves along the surface (its normal), like a
170
+ * sculpting "inflate" brush, so it works on flat sides too. With
171
+ * `mode: "radial"`, points move away from `center` instead.
172
+ *
173
+ * @template M
174
+ * @param {M} mesh
175
+ * @param {{ center: {x:number,y:number,z:number}, radius: number, amount?: number, mode?: "normal"|"radial" }} opts
176
+ * @returns {M} The same mesh
177
+ */
178
+ export function bulgeMesh(mesh, { center, radius, amount = 0.1, mode = "normal" } = {}) {
179
+ if (!center || !(radius > 0) || !Number.isFinite(amount) || amount === 0) return mesh;
180
+ const normals = mode === "radial" || !mesh.faces?.length ? null : vertexNormals(mesh);
181
+ return displaceMesh(mesh, (v, i) => {
182
+ const dx = v.x - center.x, dy = v.y - center.y, dz = v.z - center.z;
183
+ const d = Math.hypot(dx, dy, dz);
184
+ if (d >= radius) return v;
185
+ const fall = (1 - (d / radius) ** 2) ** 2;
186
+ const n = normals ? normals[i] : d > 1e-12 ? { x: dx / d, y: dy / d, z: dz / d } : null;
187
+ if (!n) return v;
188
+ const k = amount * fall;
189
+ return { x: v.x + n.x * k, y: v.y + n.y * k, z: v.z + n.z * k };
190
+ });
191
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Frames along a 3D path (not exported from the library): where the path
3
+ * goes at each point, and which way is "up" for a section placed there.
4
+ * Shared by sweep and loft.
5
+ */
6
+ import { vec, add, sub, mul, dot, cross, dist, norm, rotateAround, isPoint } from "./vec.js";
7
+
8
+ /** `v` without its part along the unit vector `t`, at length 1 (null when `v` is along `t`). */
9
+ export const across = (v, t) => norm(sub(v, mul(t, dot(v, t))));
10
+
11
+ /**
12
+ * Carries the "up" direction `r` from one path point to the next without
13
+ * spinning it (double reflection, Wang et al. 2008).
14
+ */
15
+ export function transport(r, x0, t0, x1, t1) {
16
+ const v1 = sub(x1, x0), c1 = dot(v1, v1);
17
+ if (c1 < 1e-24) return across(r, t1) ?? r;
18
+ const rL = sub(r, mul(v1, (2 * dot(v1, r)) / c1));
19
+ const tL = sub(t0, mul(v1, (2 * dot(v1, t0)) / c1));
20
+ const v2 = sub(t1, tL), c2 = dot(v2, v2);
21
+ const out = c2 < 1e-24 ? rL : sub(rL, mul(v2, (2 * dot(v2, rL)) / c2));
22
+ return across(out, t1) ?? r;
23
+ }
24
+
25
+ /**
26
+ * A path's points without bad points and repeats in a row. A loop drops
27
+ * its closing repeat, and needs 3 points (fewer: an open path).
28
+ */
29
+ export function cleanPath(path, closed) {
30
+ const pts = [];
31
+ for (const p of path ?? []) if (isPoint(p) && (!pts.length || dist(p, pts[pts.length - 1]) > 1e-9)) pts.push(vec(p.x, p.y, p.z));
32
+ let loop = !!closed;
33
+ if (loop && pts.length > 2 && dist(pts[0], pts[pts.length - 1]) <= 1e-9) pts.pop();
34
+ if (pts.length < 3) loop = false;
35
+ return { pts, loop };
36
+ }
37
+
38
+ /**
39
+ * Frames at each point of a clean path (2 points or more):
40
+ * - `dirs`: direction of each side (to the next point);
41
+ * - `tangents`: direction at each point, halfway between its sides;
42
+ * - `ups`: the section's "up", carried along without spinning, or kept
43
+ * toward `up` when given. On a loop, the turn the carried "up" picks up
44
+ * on the way round is spread evenly, so the ends meet without a seam;
45
+ * - `along`: length from the start to each point (and around, for a loop), `total`.
46
+ */
47
+ export function pathFrames(pts, loop, up = null) {
48
+ const n = pts.length;
49
+ const sides = loop ? n : n - 1;
50
+ const along = [0];
51
+ for (let i = 0; i < sides; i++) along.push(along[i] + dist(pts[i], pts[(i + 1) % n]));
52
+ const total = along[sides] || 1;
53
+
54
+ const dirs = Array.from({ length: sides }, (_, i) => norm(sub(pts[(i + 1) % n], pts[i]), vec(0, 0, 1)));
55
+ const tangents = pts.map((_, i) => {
56
+ const din = loop ? dirs[(i + n - 1) % n] : dirs[i - 1];
57
+ const dout = dirs[i];
58
+ return !din ? dout : !dout ? din : norm(add(din, dout), dout);
59
+ });
60
+
61
+ const upDir = up && isPoint(up) ? norm(up) : null;
62
+ const t0 = tangents[0];
63
+ const start = (upDir && across(upDir, t0)) ?? across(Math.abs(t0.y) < 0.99 ? vec(0, 1, 0) : vec(1, 0, 0), t0);
64
+ const ups = [start];
65
+ for (let i = 1; i < n; i++) {
66
+ const carried = transport(ups[i - 1], pts[i - 1], tangents[i - 1], pts[i], tangents[i]);
67
+ ups.push((upDir && across(upDir, tangents[i])) ?? carried);
68
+ }
69
+ if (loop && !upDir) {
70
+ const back = transport(ups[n - 1], pts[n - 1], tangents[n - 1], pts[0], t0);
71
+ const fix = Math.atan2(dot(cross(back, ups[0]), t0), dot(back, ups[0]));
72
+ for (let i = 1; i < n; i++) ups[i] = rotateAround(ups[i], tangents[i], (fix * along[i]) / total);
73
+ }
74
+ return { dirs, tangents, ups, along, total };
75
+ }
76
+
77
+ /**
78
+ * The frame at length `s` along the path (between its points):
79
+ * `pos`, `tangent`, `up`, and `side` (= up × tangent: where a section's x goes).
80
+ */
81
+ export function frameAt(pts, loop, frames, s) {
82
+ const { along, total, tangents, ups } = frames;
83
+ const n = pts.length;
84
+ s = loop ? ((s % total) + total) % total : Math.min(total, Math.max(0, s));
85
+ let lo = 0, hi = along.length - 1;
86
+ while (hi - lo > 1) {
87
+ const mid = (lo + hi) >> 1;
88
+ if (along[mid] <= s) lo = mid;
89
+ else hi = mid;
90
+ }
91
+ const i = Math.min(lo, loop ? n - 1 : n - 2), j = (i + 1) % n;
92
+ const w = Math.min(1, Math.max(0, (s - along[i]) / (along[i + 1] - along[i] || 1)));
93
+ const pos = add(pts[i], mul(sub(pts[j], pts[i]), w));
94
+ const tangent = norm(add(mul(tangents[i], 1 - w), mul(tangents[j], w)), tangents[i]);
95
+ const upv = across(add(mul(ups[i], 1 - w), mul(ups[j], w)), tangent) ?? ups[i];
96
+ return { pos, tangent, up: upv, side: cross(upv, tangent) };
97
+ }
@@ -0,0 +1,363 @@
1
+ import { triangulate } from "../render/shapes.js";
2
+ import { newell } from "../render/normals.js";
3
+ import { vec, add, mul, dot, cross, norm, signedArea2 } from "./vec.js";
4
+ import { across, cleanPath, pathFrames, frameAt } from "./frames.js";
5
+
6
+ const EMPTY = () => ({ vertices: [], edges: [], faces: [], uvs: [], faceUVs: [] });
7
+
8
+ /** Fixed directions for a straight axis: where a section's x and y go, and the axis itself. */
9
+ const AXES = {
10
+ z: { side: vec(1, 0, 0), up: vec(0, 1, 0), dir: vec(0, 0, 1) },
11
+ y: { side: vec(1, 0, 0), up: vec(0, 0, 1), dir: vec(0, 1, 0) },
12
+ x: { side: vec(0, 0, 1), up: vec(0, 1, 0), dir: vec(1, 0, 0) },
13
+ };
14
+
15
+ const gap = (a, b) => {
16
+ let s = 0;
17
+ for (let d = 0; d < a.length; d++) s += (a[d] - b[d]) ** 2;
18
+ return Math.sqrt(s);
19
+ };
20
+
21
+ /** A ring's points as number arrays, without bad points, repeats in a row and the closing repeat. */
22
+ function cleanRing(points, keys) {
23
+ const out = [];
24
+ for (const p of points ?? []) {
25
+ const a = keys.map((k) => p?.[k]);
26
+ if (a.some((x) => !Number.isFinite(x))) continue;
27
+ if (!out.length || gap(a, out[out.length - 1]) > 1e-12) out.push(a);
28
+ }
29
+ if (out.length > 1 && gap(out[0], out[out.length - 1]) <= 1e-12) out.pop();
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * `n` points around a closed ring. With more points than the ring has, its
35
+ * own points are kept (so corners stay sharp) and the new ones are shared
36
+ * out between its sides by length. With fewer, they're evenly spaced.
37
+ */
38
+ function resampleRing(ring, n) {
39
+ const m = ring.length;
40
+ if (m === n) return ring.map((p) => p.slice());
41
+ const lens = ring.map((p, i) => gap(p, ring[(i + 1) % m]));
42
+ const total = lens.reduce((a, b) => a + b, 0) || 1;
43
+ const out = [];
44
+ if (n > m) {
45
+ // Each side gets 1 piece, the rest by length (largest remainder)
46
+ const extra = n - m;
47
+ const share = lens.map((l) => (l / total) * extra);
48
+ const pieces = share.map((x) => 1 + Math.floor(x));
49
+ let left = n - pieces.reduce((a, b) => a + b, 0);
50
+ share.map((x, i) => [x - Math.floor(x), i]).sort((a, b) => b[0] - a[0]).forEach(([, i]) => {
51
+ if (left-- > 0) pieces[i]++;
52
+ });
53
+ ring.forEach((p, i) => {
54
+ const q = ring[(i + 1) % m];
55
+ for (let c = 0; c < pieces[i]; c++) out.push(p.map((x, d) => x + (q[d] - x) * (c / pieces[i])));
56
+ });
57
+ return out;
58
+ }
59
+ let side = 0, start = 0;
60
+ for (let k = 0; k < n; k++) {
61
+ const d = (k / n) * total;
62
+ while (side < m - 1 && start + lens[side] < d) start += lens[side++];
63
+ const p = ring[side], q = ring[(side + 1) % m];
64
+ const t = lens[side] ? (d - start) / lens[side] : 0;
65
+ out.push(p.map((x, j) => x + (q[j] - x) * t));
66
+ }
67
+ return out;
68
+ }
69
+
70
+ const centered = (ring) => {
71
+ const c = ring[0].map((_, d) => ring.reduce((s, p) => s + p[d], 0) / ring.length);
72
+ return ring.map((p) => p.map((x, d) => x - c[d]));
73
+ };
74
+
75
+ /**
76
+ * `ring` turned (its start point moved), and reversed when allowed, so its
77
+ * points line up best with `prev`'s: no twist between two sections.
78
+ */
79
+ function align(ring, prev, allowReverse) {
80
+ const n = ring.length;
81
+ const a = centered(prev);
82
+ let best = ring, bestCost = Infinity;
83
+ for (const cand of allowReverse ? [ring, [...ring].reverse()] : [ring]) {
84
+ const b = centered(cand);
85
+ for (let k = 0; k < n; k++) {
86
+ let cost = 0;
87
+ for (let i = 0; i < n && cost < bestCost; i++) {
88
+ const p = b[(i + k) % n], q = a[i];
89
+ for (let d = 0; d < p.length; d++) cost += (p[d] - q[d]) ** 2;
90
+ }
91
+ if (cost < bestCost) {
92
+ bestCost = cost;
93
+ best = cand.slice(k).concat(cand.slice(0, k));
94
+ }
95
+ }
96
+ }
97
+ return best;
98
+ }
99
+
100
+ /**
101
+ * Slopes for a smooth curve through values `v` at positions `s` that never
102
+ * overshoots them (monotone cubic, Fritsch-Carlson): where the values turn,
103
+ * the curve is flat, so a section's size is a true widest or narrowest point.
104
+ */
105
+ function slopes(v, s, loop, period) {
106
+ const N = v.length;
107
+ return v.map((vi, i) => {
108
+ const hasPrev = loop || i > 0, hasNext = loop || i < N - 1;
109
+ const ip = (i + N - 1) % N, inx = (i + 1) % N;
110
+ const h0 = hasPrev ? s[i] - s[ip] + (i === 0 ? period : 0) : 0;
111
+ const h1 = hasNext ? s[inx] - s[i] + (i === N - 1 ? period : 0) : 0;
112
+ const d0 = hasPrev && h0 > 0 ? (vi - v[ip]) / h0 : null;
113
+ const d1 = hasNext && h1 > 0 ? (v[inx] - vi) / h1 : null;
114
+ if (d0 === null) return d1 ?? 0;
115
+ if (d1 === null) return d0;
116
+ if (d0 * d1 <= 0) return 0;
117
+ const w1 = 2 * h1 + h0, w2 = h1 + 2 * h0;
118
+ return (w1 + w2) / (w1 / d0 + w2 / d1);
119
+ });
120
+ }
121
+
122
+ /** Cubic Hermite between a (slope ma) and b (slope mb) over a length h, at u in [0, 1]. */
123
+ function hermite(a, b, ma, mb, h, u) {
124
+ const u2 = u * u, u3 = u2 * u;
125
+ return (2 * u3 - 3 * u2 + 1) * a + (u3 - 2 * u2 + u) * h * ma + (-2 * u3 + 3 * u2) * b + (u3 - u2) * h * mb;
126
+ }
127
+
128
+ /** Triangles for a flat-ish polygon of 3D points, in the polygon's own order. */
129
+ function capTriangles(points) {
130
+ const nrm = norm(newell(points), vec(0, 0, 1));
131
+ const e1 = across(Math.abs(nrm.x) < 0.9 ? vec(1, 0, 0) : vec(0, 1, 0), nrm);
132
+ const e2 = cross(nrm, e1);
133
+ const flat = points.map((p) => ({ x: dot(p, e1), y: dot(p, e2) }));
134
+ if (signedArea2(flat) >= 0) return { tris: triangulate(flat), flat };
135
+ const L = flat.length;
136
+ return { tris: triangulate([...flat].reverse()).map((t) => t.map((i) => L - 1 - i).reverse()), flat };
137
+ }
138
+
139
+ /** Volume of a closed mesh (> 0 when its faces point outward). */
140
+ function volume(vertices, faces) {
141
+ let v = 0;
142
+ for (const f of faces) {
143
+ const a = vertices[f[0]];
144
+ for (let i = 1; i + 1 < f.length; i++) {
145
+ const b = vertices[f[i]], c = vertices[f[i + 1]];
146
+ v += a.x * (b.y * c.z - b.z * c.y) - a.y * (b.x * c.z - b.z * c.x) + a.z * (b.x * c.y - b.y * c.x);
147
+ }
148
+ }
149
+ return v;
150
+ }
151
+
152
+ /**
153
+ * A surface through cross-sections: bodies, bottles, boat hulls, car
154
+ * bodies, heads, fuselages. Each section is a closed outline; the surface
155
+ * joins them in order, smoothly.
156
+ *
157
+ * Sections are either:
158
+ * - `{ outline, at }`: a 2D outline placed along a straight `axis` (`at` is
159
+ * the position on it) or along a 3D `path` (`at` from 0 at its start to 1
160
+ * at its end). Optional `scale`, `rotate` and `offset` change it in place;
161
+ * - `{ points }`: a ring of 3D points, used where it is.
162
+ *
163
+ * Outlines may have different point counts and start points: they're
164
+ * resampled (keeping their corners) and lined up so the surface doesn't
165
+ * twist. A section of one point (or `scale: 0`) closes the surface into a
166
+ * point (a nose, a tip).
167
+ *
168
+ * Faces are clockwise from outside like the built-in shapes. Texture
169
+ * coordinates: u goes around the sections, v along the surface. Caps map
170
+ * their own box.
171
+ *
172
+ * @param {object[]} sections
173
+ * @param {object} [opts]
174
+ * @returns {import("../index.d.ts").Mesh}
175
+ */
176
+ export function loft(sections, opts = {}) {
177
+ const { axis = "z", path = null, up = null, closed = false, caps = true, segments = 6, smooth = true } = opts;
178
+ const list = (Array.isArray(sections) ? sections : []).filter((s) => s && (s.points || s.outline));
179
+ if (list.some((s) => s.points) && list.some((s) => !s.points)) {
180
+ throw new Error("loft: give every section `points` (3D) or every section `outline` (2D), not both");
181
+ }
182
+ const in3D = list.length > 0 && !!list[0].points;
183
+ const along = in3D ? null : path ? cleanPath(path, closed) : null;
184
+ if (along && along.pts.length < 2) return EMPTY();
185
+ const frames = along ? pathFrames(along.pts, along.loop, up) : null;
186
+ const straight = AXES[axis] ?? AXES.z;
187
+
188
+ // Each section: a ring of points (number arrays) and its position along the surface
189
+ let secs = list.map((sec, i) => {
190
+ if (in3D) return { ring: cleanRing(sec.points, ["x", "y", "z"]) };
191
+ const k = sec.scale ?? 1;
192
+ const kx = typeof k === "number" ? k : k?.x ?? 1, ky = typeof k === "number" ? k : k?.y ?? 1;
193
+ const c = Math.cos(sec.rotate ?? 0), sn = Math.sin(sec.rotate ?? 0);
194
+ const ox = sec.offset?.x ?? 0, oy = sec.offset?.y ?? 0;
195
+ let ring = cleanRing(sec.outline, ["x", "y"]).map(([x, y]) => [(x * kx) * c - (y * ky) * sn + ox, (x * kx) * sn + (y * ky) * c + oy]);
196
+ if (ring.length > 2 && signedArea2(ring.map(([x, y]) => ({ x, y }))) < 0) ring.reverse();
197
+ const fallback = list.length > 1 ? i / (along?.loop ? list.length : list.length - 1) : 0;
198
+ const at = Number.isFinite(sec.at) ? sec.at : fallback;
199
+ return { ring, at };
200
+ });
201
+ // A section too small to be a ring is a point
202
+ secs = secs.filter((s) => s.ring.length > 0).map((s) => {
203
+ const size = Math.max(0, ...s.ring.map((p) => gap(p, s.ring[0])));
204
+ return { ...s, tip: s.ring.length < 3 || size < 1e-9 };
205
+ });
206
+ if (!in3D) secs.sort((a, b) => a.at - b.at);
207
+ const N = secs.length;
208
+ const loop = in3D ? !!closed && N > 2 : !!along?.loop;
209
+ if (N < 2 || secs.every((s) => s.tip)) return EMPTY();
210
+
211
+ // Same point count everywhere: kept as they are, unless `points` asks otherwise
212
+ const rings = secs.filter((s) => !s.tip).map((s) => s.ring.length);
213
+ const n = Math.max(3, Math.round(opts.points ?? (rings.every((m) => m === rings[0]) ? rings[0] : Math.max(24, ...rings))));
214
+ for (const s of secs) {
215
+ if (s.tip) {
216
+ const c = s.ring[0].map((_, d) => s.ring.reduce((t, p) => t + p[d], 0) / s.ring.length);
217
+ s.ring = Array.from({ length: n }, () => c.slice());
218
+ } else s.ring = resampleRing(s.ring, n);
219
+ }
220
+ if (opts.align !== false) {
221
+ for (let i = 1; i < N; i++) if (!secs[i].tip && !secs[i - 1].tip) secs[i].ring = align(secs[i].ring, secs[i - 1].ring, in3D);
222
+ }
223
+
224
+ // Position of each section along the surface (s), and the length around for a loop
225
+ let period = 0;
226
+ if (in3D) {
227
+ const step = (a, b) => Math.max(1e-6, a.ring.reduce((t, p, k) => t + gap(p, b.ring[k]), 0) / n);
228
+ secs[0].s = 0;
229
+ for (let i = 1; i < N; i++) secs[i].s = secs[i - 1].s + step(secs[i - 1], secs[i]);
230
+ period = loop ? secs[N - 1].s + step(secs[N - 1], secs[0]) : 0;
231
+ } else if (frames) {
232
+ for (const s of secs) s.s = Math.min(1, Math.max(0, s.at)) * frames.total;
233
+ period = loop ? frames.total : 0;
234
+ } else {
235
+ for (const s of secs) s.s = s.at;
236
+ }
237
+ // Sections at the same place can't be joined: keep the first
238
+ secs = secs.filter((s, i) => i === 0 || s.s > secs[i - 1].s + 1e-9);
239
+ if (secs.length < 2) return EMPTY();
240
+ const M = secs.length;
241
+ const S = secs.map((s) => s.s);
242
+ if (loop) period = Math.max(period, S[M - 1] - S[0] + 1e-6);
243
+
244
+ // Slopes of every coordinate of every point, across the sections
245
+ const dims = secs[0].ring[0].length;
246
+ const slope = Array.from({ length: n }, (_, k) => Array.from({ length: dims }, (_, d) => {
247
+ const v = secs.map((s) => s.ring[k][d]);
248
+ return smooth ? slopes(v, S, loop, period) : null;
249
+ }));
250
+
251
+ // Rows between sections: `segments` per span on average, shared out by length
252
+ const spans = loop ? M : M - 1;
253
+ const gaps = Array.from({ length: spans }, (_, i) => (i < M - 1 ? S[i + 1] - S[i] : period - (S[M - 1] - S[0])));
254
+ const gapTotal = gaps.reduce((a, b) => a + b, 0) || 1;
255
+ const perSpan = gaps.map((g) => Math.max(1, Math.round((g / gapTotal) * Math.max(1, Math.round(segments)) * spans)));
256
+
257
+ const place = (p, s) => {
258
+ if (in3D) return vec(p[0], p[1], p[2]);
259
+ if (frames) {
260
+ const f = frameAt(along.pts, along.loop, frames, s);
261
+ return add(f.pos, add(mul(f.side, p[0]), mul(f.up, p[1])));
262
+ }
263
+ return add(mul(straight.dir, s), add(mul(straight.side, p[0]), mul(straight.up, p[1])));
264
+ };
265
+
266
+ // Every row: its points (as numbers) and its position s
267
+ const rowsData = [];
268
+ for (let i = 0; i < spans; i++) {
269
+ const j = (i + 1) % M;
270
+ for (let q = 0; q < perSpan[i]; q++) {
271
+ const u = q / perSpan[i];
272
+ const s = S[i] + gaps[i] * u;
273
+ rowsData.push({
274
+ s,
275
+ pts: secs[i].ring.map((p, k) => p.map((a, d) => {
276
+ const b = secs[j].ring[k][d];
277
+ return smooth ? hermite(a, b, slope[k][d][i], slope[k][d][j], gaps[i], u) : a + (b - a) * u;
278
+ })),
279
+ });
280
+ }
281
+ }
282
+ if (!loop) rowsData.push({ s: S[M - 1], pts: secs[M - 1].ring.map((p) => p.slice()) });
283
+
284
+ // Vertices: a row whose points all meet is one point
285
+ const vertices = [];
286
+ let size = 0;
287
+ const rowsWorld = rowsData.map((r) => r.pts.map((p) => place(p, r.s)));
288
+ for (const r of rowsWorld) for (const p of r) size = Math.max(size, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
289
+ const eps = (size || 1) * 1e-9;
290
+ const rows = rowsWorld.map((r) => {
291
+ const tip = r.every((p) => Math.hypot(p.x - r[0].x, p.y - r[0].y, p.z - r[0].z) <= eps);
292
+ if (tip) {
293
+ const v = vertices.push(r[0]) - 1;
294
+ return new Array(n).fill(v);
295
+ }
296
+ return r.map((p) => vertices.push(p) - 1);
297
+ });
298
+ const R = rows.length;
299
+ const isTip = (r) => r[0] === r[1] && r[0] === r[n - 1];
300
+
301
+ // Texture grid: an extra column for the seam around, an extra row for a loop
302
+ const sStart = rowsData[0].s, sLen = (loop ? period : rowsData[R - 1].s - sStart) || 1;
303
+ const uvs = [];
304
+ const uvRows = loop ? R + 1 : R;
305
+ for (let r = 0; r < uvRows; r++) {
306
+ const v = r < R ? (rowsData[r].s - sStart) / sLen : 1;
307
+ for (let k = 0; k <= n; k++) uvs.push({ u: k / n, v });
308
+ }
309
+ const uvAt = (r, k) => r * (n + 1) + k;
310
+
311
+ const faces = [], faceUVs = [];
312
+ const steps = loop ? R : R - 1;
313
+ for (let r = 0; r < steps; r++) {
314
+ const a = rows[r], b = rows[(r + 1) % R];
315
+ for (let k = 0; k < n; k++) {
316
+ const k1 = (k + 1) % n;
317
+ const corners = [[a[k], uvAt(r, k)], [a[k1], uvAt(r, k + 1)], [b[k1], uvAt(r + 1, k + 1)], [b[k], uvAt(r + 1, k)]];
318
+ const keep = corners.filter(([v], c) => v !== corners[(c + 3) % 4][0]);
319
+ if (keep.length < 3) continue;
320
+ faces.push(keep.map(([v]) => v));
321
+ faceUVs.push(keep.map(([, u]) => u));
322
+ }
323
+ }
324
+
325
+ // Caps: they go round each end the other way from the side faces next to them, so the whole is consistent
326
+ const capFaces = [], capUVs = [];
327
+ if (!loop) {
328
+ for (const [r, order] of [[rows[0], (ring) => [...ring].reverse()], [rows[R - 1], (ring) => ring]]) {
329
+ if (isTip(r)) continue;
330
+ const ring = order(r);
331
+ const { tris, flat } = capTriangles(ring.map((v) => vertices[v]));
332
+ const xs = flat.map((p) => p.x), ys = flat.map((p) => p.y);
333
+ const x0 = Math.min(...xs), y0 = Math.min(...ys);
334
+ const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
335
+ const first = uvs.length;
336
+ for (const p of flat) uvs.push({ u: (p.x - x0) / w, v: (p.y - y0) / h });
337
+ for (const t of tris) {
338
+ capFaces.push(t.map((i) => ring[i]));
339
+ capUVs.push(t.map((i) => first + i));
340
+ }
341
+ }
342
+ }
343
+
344
+ // Outward: measured on the closed shape (with its caps, even when they're left out)
345
+ if (volume(vertices, [...faces, ...capFaces]) < 0) {
346
+ for (const list of [faces, faceUVs, capFaces, capUVs]) list.forEach((f, i) => (list[i] = f.reverse()));
347
+ }
348
+ if (caps !== false) {
349
+ faces.push(...capFaces);
350
+ faceUVs.push(...capUVs);
351
+ } else uvs.length = uvRows * (n + 1);
352
+
353
+ // Edges: each row, and a line along the surface from each point around
354
+ const edges = [];
355
+ for (const r of rows) if (!isTip(r)) edges.push(r.slice());
356
+ for (let k = 0; k < n; k++) {
357
+ if (loop) edges.push(rows.map((r) => r[k]));
358
+ else for (let r = 0; r < R - 1; r++) if (rows[r][k] !== rows[r + 1][k]) edges.push([rows[r][k], rows[r + 1][k]]);
359
+ }
360
+
361
+ return { vertices, edges, faces, uvs, faceUVs };
362
+ }
363
+