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