trid3d 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -9
- package/package.json +8 -3
- package/trid/export/capture.js +150 -0
- package/trid/export/obj.js +141 -0
- package/trid/index.d.ts +988 -0
- package/trid/index.js +10 -3
- package/trid/loaders/obj.js +18 -2
- package/trid/render/canvasRenderer.js +94 -61
- package/trid/render/draw.js +15 -4
- package/trid/render/shapes.js +285 -23
- package/trid/render/uvMapping.js +225 -0
- package/trid/render/webglRenderer.js +148 -54
- package/trid/scene.js +23 -0
- package/trid/tools/hoverHighlight.js +2 -1
- package/trid/tools/orbitCamera.js +18 -0
package/trid/render/shapes.js
CHANGED
|
@@ -45,6 +45,8 @@ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
|
|
|
45
45
|
[0, 3, 7, 4], // right
|
|
46
46
|
[1, 5, 6, 2], // left
|
|
47
47
|
],
|
|
48
|
+
// Which material each face uses when `material` is a list: one per side, in the order above
|
|
49
|
+
faceMaterials: [0, 1, 2, 3, 4, 5],
|
|
48
50
|
};
|
|
49
51
|
return withFaceUVs(mesh);
|
|
50
52
|
}
|
|
@@ -161,6 +163,268 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
|
|
|
161
163
|
return { vertices, edges, faces, uvs, faceUVs };
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Spins a profile around the Y axis: vases, bottles, cups, columns, chess
|
|
168
|
+
* pieces. Cylinders, cones and tori are built with it.
|
|
169
|
+
*
|
|
170
|
+
* Walk the profile so the outside is on its right in the (r, y) plane:
|
|
171
|
+
* bottom to top for a vase. Faces are clockwise as seen from outside.
|
|
172
|
+
*
|
|
173
|
+
* Texture coordinates: u goes around, v follows the profile's length.
|
|
174
|
+
* Caps map the texture as a disc. Edges are the profile's rings plus one
|
|
175
|
+
* line along the profile per segment.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* // A vase
|
|
179
|
+
* const vase = lathe(
|
|
180
|
+
* [{ r: 0.2, y: -0.5 }, { r: 0.35, y: -0.2 }, { r: 0.15, y: 0.3 }, { r: 0.2, y: 0.5 }],
|
|
181
|
+
* { capBottom: true },
|
|
182
|
+
* );
|
|
183
|
+
*
|
|
184
|
+
* @param {{r:number, y:number}[]} profile - Points: distance from the Y axis and height
|
|
185
|
+
* @param {Object} [opts]
|
|
186
|
+
* @param {number} [opts.segments=32] - Points around the axis
|
|
187
|
+
* @param {boolean} [opts.capBottom=false] - Close the bottom with a flat cap
|
|
188
|
+
* @param {boolean} [opts.capTop=false] - Close the top with a flat cap
|
|
189
|
+
* @param {boolean} [opts.closed=false] - The last point joins the first (torus-like shapes)
|
|
190
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
191
|
+
*/
|
|
192
|
+
export function lathe(profile, { segments = 32, capBottom = false, capTop = false, closed = false } = {}) {
|
|
193
|
+
segments = Math.max(3, Math.floor(segments));
|
|
194
|
+
const vertices = [];
|
|
195
|
+
const edges = [];
|
|
196
|
+
const faces = [];
|
|
197
|
+
const uvs = [];
|
|
198
|
+
const faceUVs = [];
|
|
199
|
+
const uv = (u, v) => uvs.push({ u, v }) - 1;
|
|
200
|
+
|
|
201
|
+
// v along the profile, by length
|
|
202
|
+
const pts = closed ? [...profile, profile[0]] : profile;
|
|
203
|
+
const lengths = [0];
|
|
204
|
+
for (let i = 1; i < pts.length; i++) {
|
|
205
|
+
lengths.push(lengths[i - 1] + Math.hypot(pts[i].r - pts[i - 1].r, pts[i].y - pts[i - 1].y));
|
|
206
|
+
}
|
|
207
|
+
const total = lengths[lengths.length - 1] || 1;
|
|
208
|
+
|
|
209
|
+
for (const p of profile) {
|
|
210
|
+
for (let s = 0; s < segments; s++) {
|
|
211
|
+
const t = (s / segments) * Math.PI * 2;
|
|
212
|
+
vertices.push({ x: Math.cos(t) * p.r, y: p.y, z: Math.sin(t) * p.r });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rows = pts.length - 1;
|
|
217
|
+
for (let r = 0; r < rows; r++) {
|
|
218
|
+
const a = r * segments;
|
|
219
|
+
const b = ((r + 1) % profile.length) * segments;
|
|
220
|
+
for (let s = 0; s < segments; s++) {
|
|
221
|
+
const s1 = (s + 1) % segments;
|
|
222
|
+
faces.push([a + s, b + s, b + s1, a + s1]);
|
|
223
|
+
const u0 = s / segments, u1 = (s + 1) / segments;
|
|
224
|
+
const v0 = lengths[r] / total, v1 = lengths[r + 1] / total;
|
|
225
|
+
faceUVs.push([uv(u0, v0), uv(u0, v1), uv(u1, v1), uv(u1, v0)]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Rings (skipped where the profile touches the axis) and lines along the profile
|
|
230
|
+
profile.forEach((p, r) => {
|
|
231
|
+
if (p.r > 1e-5) edges.push(Array.from({ length: segments }, (_, s) => r * segments + s));
|
|
232
|
+
});
|
|
233
|
+
for (let s = 0; s < segments; s++) {
|
|
234
|
+
if (closed && profile.length > 2) edges.push(profile.map((_, r) => r * segments + s));
|
|
235
|
+
else for (let r = 0; r < profile.length - 1; r++) edges.push([r * segments + s, (r + 1) * segments + s]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const disc = (s) => {
|
|
239
|
+
const t = (s / segments) * Math.PI * 2;
|
|
240
|
+
return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
|
|
241
|
+
};
|
|
242
|
+
if (capBottom && !closed && profile[0].r > 0) {
|
|
243
|
+
const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
|
|
244
|
+
for (let s = 0; s < segments; s++) {
|
|
245
|
+
faces.push([c, s, (s + 1) % segments]);
|
|
246
|
+
faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const last = profile.length - 1;
|
|
250
|
+
if (capTop && !closed && profile[last].r > 0) {
|
|
251
|
+
const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
|
|
252
|
+
const a = last * segments;
|
|
253
|
+
for (let s = 0; s < segments; s++) {
|
|
254
|
+
faces.push([c, a + (s + 1) % segments, a + s]);
|
|
255
|
+
faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { vertices, edges, faces, uvs, faceUVs };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A capped cylinder along Y, centered on the origin. */
|
|
263
|
+
function cylinder(r = 0.3, h = 0.8, segments = 32) {
|
|
264
|
+
return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A cone along Y with its tip up, or a tapered cylinder when `rTop` > 0. */
|
|
268
|
+
function cone(r = 0.35, h = 0.8, segments = 32, rTop = 0) {
|
|
269
|
+
// A tiny top ring keeps the tip's faces non-degenerate for UVs and normals
|
|
270
|
+
const profile = [{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 1e-6, y: h / 2 }];
|
|
271
|
+
return lathe(profile, { segments, capBottom: true, capTop: rTop > 0 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A ring in the XZ plane: `R` from the center to the middle of the tube. */
|
|
275
|
+
function torus(R = 0.35, tube = 0.12, segments = 48, tubeSegments = 20) {
|
|
276
|
+
tubeSegments = Math.max(3, Math.floor(tubeSegments));
|
|
277
|
+
const profile = [];
|
|
278
|
+
for (let i = 0; i < tubeSegments; i++) {
|
|
279
|
+
const p = (i / tubeSegments) * Math.PI * 2;
|
|
280
|
+
profile.push({ r: R + Math.cos(p) * tube, y: Math.sin(p) * tube });
|
|
281
|
+
}
|
|
282
|
+
return lathe(profile, { segments, closed: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A flat grid in the XZ plane facing +Y, `size` wide, with `div` cells per
|
|
287
|
+
* side. `height(x, z)` lifts each vertex (terrain). The texture covers the
|
|
288
|
+
* whole grid once.
|
|
289
|
+
*/
|
|
290
|
+
function plane(size = 1, div = 8, height = null) {
|
|
291
|
+
div = Math.max(1, Math.floor(div));
|
|
292
|
+
const vertices = [];
|
|
293
|
+
const uvs = [];
|
|
294
|
+
const n = div + 1;
|
|
295
|
+
const k = (i, j) => j * n + i;
|
|
296
|
+
for (let j = 0; j < n; j++) {
|
|
297
|
+
for (let i = 0; i < n; i++) {
|
|
298
|
+
const x = (i / div - 0.5) * size, z = (j / div - 0.5) * size;
|
|
299
|
+
vertices.push({ x, y: height ? height(x, z) : 0, z });
|
|
300
|
+
uvs.push({ u: i / div, v: j / div });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const faces = [];
|
|
304
|
+
const edges = [];
|
|
305
|
+
for (let j = 0; j < div; j++) {
|
|
306
|
+
for (let i = 0; i < div; i++) faces.push([k(i + 1, j + 1), k(i + 1, j), k(i, j), k(i, j + 1)]);
|
|
307
|
+
}
|
|
308
|
+
for (let j = 0; j < n; j++) {
|
|
309
|
+
for (let i = 0; i < div; i++) {
|
|
310
|
+
edges.push([k(i, j), k(i + 1, j)]);
|
|
311
|
+
edges.push([k(j, i), k(j, i + 1)]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Grid vertices and UVs share indices
|
|
315
|
+
return { vertices, edges, faces, uvs, faceUVs: faces.map((f) => [...f]) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Twice the signed area: > 0 when the points go counter-clockwise (x right, y up). */
|
|
319
|
+
function signedArea(pts) {
|
|
320
|
+
let a = 0;
|
|
321
|
+
for (let i = 0; i < pts.length; i++) {
|
|
322
|
+
const p = pts[i], q = pts[(i + 1) % pts.length];
|
|
323
|
+
a += p.x * q.y - q.x * p.y;
|
|
324
|
+
}
|
|
325
|
+
return a;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Cuts a simple polygon (no holes, sides don't cross) into triangles, by
|
|
330
|
+
* ear clipping. Works for concave outlines. Falls back to a fan if the
|
|
331
|
+
* outline crosses itself.
|
|
332
|
+
*
|
|
333
|
+
* @param {{x:number, y:number}[]} pts - The outline, counter-clockwise (x right, y up)
|
|
334
|
+
* @returns {number[][]} Index triples into `pts`, counter-clockwise too
|
|
335
|
+
*/
|
|
336
|
+
export function triangulate(pts) {
|
|
337
|
+
const idx = pts.map((_, i) => i);
|
|
338
|
+
const tris = [];
|
|
339
|
+
const cross = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
340
|
+
const inside = (p, a, b, c) => cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0;
|
|
341
|
+
|
|
342
|
+
let guard = idx.length * idx.length;
|
|
343
|
+
while (idx.length > 3 && guard-- > 0) {
|
|
344
|
+
let clipped = false;
|
|
345
|
+
for (let i = 0; i < idx.length; i++) {
|
|
346
|
+
const i0 = idx[(i + idx.length - 1) % idx.length], i1 = idx[i], i2 = idx[(i + 1) % idx.length];
|
|
347
|
+
const a = pts[i0], b = pts[i1], c = pts[i2];
|
|
348
|
+
if (cross(a, b, c) <= 1e-12) continue; // reflex or flat corner
|
|
349
|
+
if (idx.some((j) => j !== i0 && j !== i1 && j !== i2 && inside(pts[j], a, b, c))) continue;
|
|
350
|
+
tris.push([i0, i1, i2]);
|
|
351
|
+
idx.splice(i, 1);
|
|
352
|
+
clipped = true;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
if (!clipped) break;
|
|
356
|
+
}
|
|
357
|
+
if (idx.length === 3) tris.push([idx[0], idx[1], idx[2]]);
|
|
358
|
+
else for (let i = 1; i < idx.length - 1; i++) tris.push([idx[0], idx[i], idx[i + 1]]);
|
|
359
|
+
return tris;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Turns a flat outline in the XY plane into a solid `depth` thick along Z,
|
|
364
|
+
* centered on the origin: blades, axes, keys, stars, letters, floor plans.
|
|
365
|
+
* The outline may go either way round and may be concave (no holes).
|
|
366
|
+
*
|
|
367
|
+
* Texture coordinates: the front and back map the outline's bounding box,
|
|
368
|
+
* the sides wrap u along the outline and v across the depth. Edges are the
|
|
369
|
+
* front and back outlines plus a line at each corner.
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* const star = Array.from({ length: 10 }, (_, i) => {
|
|
373
|
+
* const a = (i / 10) * Math.PI * 2, r = i % 2 ? 0.2 : 0.5;
|
|
374
|
+
* return { x: Math.cos(a) * r, y: Math.sin(a) * r };
|
|
375
|
+
* });
|
|
376
|
+
* scene.add({ mesh: extrude(star, 0.15), material: { fill: "#fc5" } });
|
|
377
|
+
*
|
|
378
|
+
* @param {{x:number, y:number}[]} outline - The flat shape (3 points or more)
|
|
379
|
+
* @param {number} [depth=0.2] - Thickness along Z
|
|
380
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
381
|
+
*/
|
|
382
|
+
export function extrude(outline, depth = 0.2) {
|
|
383
|
+
const pts = signedArea(outline) < 0 ? [...outline].reverse() : outline;
|
|
384
|
+
const n = pts.length;
|
|
385
|
+
const vertices = [];
|
|
386
|
+
const faces = [];
|
|
387
|
+
const uvs = [];
|
|
388
|
+
const faceUVs = [];
|
|
389
|
+
const uv = (u, v) => uvs.push({ u, v }) - 1;
|
|
390
|
+
|
|
391
|
+
// 0..n-1 at the front (-z), n..2n-1 at the back (+z)
|
|
392
|
+
for (const p of pts) vertices.push({ x: p.x, y: p.y, z: -depth / 2 });
|
|
393
|
+
for (const p of pts) vertices.push({ x: p.x, y: p.y, z: depth / 2 });
|
|
394
|
+
|
|
395
|
+
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
|
396
|
+
const x0 = Math.min(...xs), y0 = Math.min(...ys);
|
|
397
|
+
const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
|
|
398
|
+
const capUV = (i) => uv((pts[i].x - x0) / w, (pts[i].y - y0) / h);
|
|
399
|
+
|
|
400
|
+
// Caps: counter-clockwise outlines look clockwise from the back (+z), so the
|
|
401
|
+
// back cap keeps that order and the front cap (seen from -z) is reversed
|
|
402
|
+
for (const [a, b, c] of triangulate(pts)) {
|
|
403
|
+
faces.push([n + a, n + b, n + c]);
|
|
404
|
+
faceUVs.push([capUV(a), capUV(b), capUV(c)]);
|
|
405
|
+
faces.push([c, b, a]);
|
|
406
|
+
faceUVs.push([capUV(c), capUV(b), capUV(a)]);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Sides, u by length along the outline
|
|
410
|
+
const len = [0];
|
|
411
|
+
for (let i = 0; i < n; i++) len.push(len[i] + Math.hypot(pts[(i + 1) % n].x - pts[i].x, pts[(i + 1) % n].y - pts[i].y));
|
|
412
|
+
const total = len[n] || 1;
|
|
413
|
+
for (let i = 0; i < n; i++) {
|
|
414
|
+
const j = (i + 1) % n;
|
|
415
|
+
const ua = len[i] / total, ub = len[i + 1] / total;
|
|
416
|
+
faces.push([n + j, n + i, i, j]);
|
|
417
|
+
faceUVs.push([uv(ub, 1), uv(ua, 1), uv(ua, 0), uv(ub, 0)]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const edges = [
|
|
421
|
+
pts.map((_, i) => i),
|
|
422
|
+
pts.map((_, i) => n + i),
|
|
423
|
+
...pts.map((_, i) => [i, n + i]),
|
|
424
|
+
];
|
|
425
|
+
return { vertices, edges, faces, uvs, faceUVs };
|
|
426
|
+
}
|
|
427
|
+
|
|
164
428
|
/**
|
|
165
429
|
* Scales a mesh along the X, Y, and Z axes.
|
|
166
430
|
*
|
|
@@ -192,32 +456,26 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
|
|
|
192
456
|
}
|
|
193
457
|
|
|
194
458
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* This provides a unified API for constructing different meshes
|
|
198
|
-
* (cube, box, sphere) while keeping the rendering pipeline generic.
|
|
199
|
-
*
|
|
200
|
-
* Supported shapes:
|
|
201
|
-
* - "cube" → wireframe cube
|
|
202
|
-
* - "box" → rectangular prism (box)
|
|
203
|
-
* - "sphere" → wireframe sphere
|
|
459
|
+
* Creates a built-in shape, centered on the origin.
|
|
204
460
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
461
|
+
* | Kind | Options (defaults) |
|
|
462
|
+
* | --- | --- |
|
|
463
|
+
* | `"cube"` | `size` (0.5) |
|
|
464
|
+
* | `"box"` | `w`, `h`, `d` (0.6 each) |
|
|
465
|
+
* | `"sphere"` | `r` (0.45), `rings` (12), `segments` (24) |
|
|
466
|
+
* | `"cylinder"` | `r` (0.3), `h` (0.8), `segments` (32) |
|
|
467
|
+
* | `"cone"` | `r` (0.35), `h` (0.8), `segments` (32), `rTop` (0: pointed; > 0: tapered cylinder) |
|
|
468
|
+
* | `"torus"` | `R` (0.35, center to tube middle), `tube` (0.12), `segments` (48), `tubeSegments` (20) |
|
|
469
|
+
* | `"plane"` | `size` (1), `div` (8 cells per side), `height` ((x, z) => y, optional) |
|
|
207
470
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* @param {number} [opts.h] - Box height
|
|
213
|
-
* @param {number} [opts.d] - Box depth
|
|
214
|
-
* @param {number} [opts.r] - Sphere radius
|
|
215
|
-
* @param {number} [opts.rings] - Number of latitude rings (sphere)
|
|
216
|
-
* @param {number} [opts.segments] - Segments per ring (sphere)
|
|
217
|
-
*
|
|
218
|
-
* @returns {{ vertices: {x:number,y:number,z:number}[], edges: number[][] }}
|
|
219
|
-
* A wireframe mesh object
|
|
471
|
+
* Cylinders, cones and tori stand along Y; the plane lies in XZ facing +Y.
|
|
472
|
+
* Every shape has edges (for wireframes), faces (clockwise from outside)
|
|
473
|
+
* and texture coordinates. For other round or flat shapes, see `lathe`
|
|
474
|
+
* and `extrude`.
|
|
220
475
|
*
|
|
476
|
+
* @param {string} kind - Shape name (case doesn't matter)
|
|
477
|
+
* @param {Object} [opts] - Size options for that shape (see the table)
|
|
478
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
221
479
|
* @throws {Error} If the shape type is not supported
|
|
222
480
|
*/
|
|
223
481
|
export function makeShape(kind, opts = {}) {
|
|
@@ -225,6 +483,10 @@ export function makeShape(kind, opts = {}) {
|
|
|
225
483
|
case "cube": return cubeWire(opts.size ?? 0.5);
|
|
226
484
|
case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6);
|
|
227
485
|
case "sphere":return sphereWire(opts.r ?? 0.45, opts.rings ?? 12, opts.segments ?? 24);
|
|
486
|
+
case "cylinder": return cylinder(opts.r, opts.h, opts.segments);
|
|
487
|
+
case "cone": return cone(opts.r, opts.h, opts.segments, opts.rTop);
|
|
488
|
+
case "torus": return torus(opts.R, opts.tube, opts.segments, opts.tubeSegments);
|
|
489
|
+
case "plane": return plane(opts.size, opts.div, opts.height);
|
|
228
490
|
default: throw new Error("Unknown shape: " + kind);
|
|
229
491
|
}
|
|
230
492
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Texture mapping: texture coordinates made from a mesh's shape, for
|
|
3
|
+
* meshes that don't have any (or to replace the ones they have).
|
|
4
|
+
*
|
|
5
|
+
* Every mode fits the image to the mesh's bounding box:
|
|
6
|
+
*
|
|
7
|
+
* - "planar": the image is projected straight through the mesh along one axis
|
|
8
|
+
* - "box": each face takes the image from the side of the box it faces most
|
|
9
|
+
* - "sphere": wrapped around like a world map
|
|
10
|
+
* - "cylinder": wrapped around the sides like a label; the ends get a planar image
|
|
11
|
+
*
|
|
12
|
+
* A mapping is a mode name, or an object with options:
|
|
13
|
+
*
|
|
14
|
+
* mapping: "box"
|
|
15
|
+
* mapping: { type: "cylinder", axis: "x" }
|
|
16
|
+
*
|
|
17
|
+
* @typedef {"uv"|"planar"|"box"|"sphere"|"cylinder"} MappingType
|
|
18
|
+
* @typedef {MappingType | { type: MappingType, axis?: "x"|"y"|"z" }} Mapping
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TAU = Math.PI * 2;
|
|
22
|
+
|
|
23
|
+
/** Mapping modes accepted by `computeUVs` and `material.mapping`. */
|
|
24
|
+
export const MAPPINGS = ["uv", "planar", "box", "sphere", "cylinder"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Texture coordinates for a mesh's faces, made from its shape.
|
|
28
|
+
* The mesh isn't changed. Every face corner gets its own entry in `uvs`.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ vertices:{x:number,y:number,z:number}[], faces?:number[][] }} mesh
|
|
31
|
+
* @param {Mapping} mapping
|
|
32
|
+
* @returns {{ uvs:{u:number,v:number}[], faceUVs:number[][] }}
|
|
33
|
+
* @throws {Error} For an unknown mapping
|
|
34
|
+
*/
|
|
35
|
+
export function computeUVs(mesh, mapping) {
|
|
36
|
+
const { type, axis } = mappingOf(mapping);
|
|
37
|
+
const faces = mesh.faces ?? [];
|
|
38
|
+
const uvs = [], faceUVs = [];
|
|
39
|
+
if (type === "uv") {
|
|
40
|
+
return { uvs: mesh.uvs ?? [], faceUVs: mesh.faceUVs ?? [] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Sphere and cylinder wrap around Y; other axes are turned to Y first.
|
|
44
|
+
// Cyclic swaps are rotations, so the image isn't mirrored.
|
|
45
|
+
const turn = axis === "x" ? (p) => ({ x: p.z, y: p.x, z: p.y })
|
|
46
|
+
: axis === "z" ? (p) => ({ x: p.y, y: p.z, z: p.x })
|
|
47
|
+
: (p) => p;
|
|
48
|
+
const wraps = type === "sphere" || type === "cylinder";
|
|
49
|
+
const verts = wraps ? mesh.vertices.map(turn) : mesh.vertices;
|
|
50
|
+
const box = bounds(verts);
|
|
51
|
+
const center = { x: (box.min.x + box.max.x) / 2, y: (box.min.y + box.max.y) / 2, z: (box.min.z + box.max.z) / 2 };
|
|
52
|
+
const size = { x: box.max.x - box.min.x || 1, y: box.max.y - box.min.y || 1, z: box.max.z - box.min.z || 1 };
|
|
53
|
+
|
|
54
|
+
// A flat image seen from outside along +side of an axis ("y" with sign 1: from above)
|
|
55
|
+
const flat = (p, ax, sign) => {
|
|
56
|
+
const nx = (p.x - box.min.x) / size.x, ny = (p.y - box.min.y) / size.y, nz = (p.z - box.min.z) / size.z;
|
|
57
|
+
if (ax === "x") return { u: sign > 0 ? nz : 1 - nz, v: ny };
|
|
58
|
+
if (ax === "y") return { u: nx, v: sign > 0 ? nz : 1 - nz };
|
|
59
|
+
return { u: sign > 0 ? 1 - nx : nx, v: ny };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const f of faces) {
|
|
63
|
+
const p = f.map((i) => verts[i]);
|
|
64
|
+
let ts;
|
|
65
|
+
if (type === "planar") {
|
|
66
|
+
// From the front: along the axis, seen from its negative side
|
|
67
|
+
const ax = axis ?? "z";
|
|
68
|
+
ts = p.map((q) => flat(q, ax, -1));
|
|
69
|
+
} else if (type === "box") {
|
|
70
|
+
const n = faceNormal(p);
|
|
71
|
+
const ax = dominant(n);
|
|
72
|
+
ts = p.map((q) => flat(q, ax, Math.sign(n[ax]) || 1));
|
|
73
|
+
} else {
|
|
74
|
+
const n = faceNormal(p);
|
|
75
|
+
if (type === "cylinder" && Math.abs(n.y) > 0.7) {
|
|
76
|
+
// An end cap: planar from above or below
|
|
77
|
+
ts = p.map((q) => flat(q, "y", Math.sign(n.y)));
|
|
78
|
+
} else {
|
|
79
|
+
ts = p.map((q) => {
|
|
80
|
+
const d = { x: q.x - center.x, y: q.y - center.y, z: q.z - center.z };
|
|
81
|
+
const around = Math.hypot(d.x, d.z);
|
|
82
|
+
const u = around < 1e-9 ? NaN : (((Math.atan2(d.z, d.x) / TAU) % 1) + 1) % 1;
|
|
83
|
+
const v = type === "sphere"
|
|
84
|
+
? 0.5 + Math.atan2(d.y, around) / Math.PI
|
|
85
|
+
: (q.y - box.min.y) / size.y;
|
|
86
|
+
return { u, v };
|
|
87
|
+
});
|
|
88
|
+
fixSeam(ts);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
faceUVs.push(ts.map((t) => uvs.push(t) - 1));
|
|
92
|
+
}
|
|
93
|
+
return { uvs, faceUVs };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Replaces a mesh's texture coordinates with ones made from its shape,
|
|
98
|
+
* in place. Handy for OBJ files without any, or before exporting.
|
|
99
|
+
*
|
|
100
|
+
* @param {Object} mesh
|
|
101
|
+
* @param {Mapping} mapping
|
|
102
|
+
* @returns The same mesh
|
|
103
|
+
*/
|
|
104
|
+
export function mapUVs(mesh, mapping) {
|
|
105
|
+
const { uvs, faceUVs } = computeUVs(mesh, mapping);
|
|
106
|
+
mesh.uvs = uvs;
|
|
107
|
+
mesh.faceUVs = faceUVs;
|
|
108
|
+
mesh.dirty = true;
|
|
109
|
+
return mesh;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The mapping as `{ type, axis }`, checked. */
|
|
113
|
+
export function mappingOf(mapping) {
|
|
114
|
+
const m = typeof mapping === "string" ? { type: mapping } : { ...(mapping ?? {}) };
|
|
115
|
+
m.type ??= "uv";
|
|
116
|
+
if (!MAPPINGS.includes(m.type)) throw new Error(`Unknown texture mapping: "${m.type}"`);
|
|
117
|
+
if (m.axis != null && !["x", "y", "z"].includes(m.axis)) throw new Error(`Unknown mapping axis: "${m.axis}"`);
|
|
118
|
+
return m;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A short string naming a mapping, for caches ("" for the mesh's own coordinates). */
|
|
122
|
+
export function mappingKey(mapping) {
|
|
123
|
+
if (mapping == null) return "";
|
|
124
|
+
const { type, axis } = mappingOf(mapping);
|
|
125
|
+
return type === "uv" ? "" : type + (axis ? ":" + axis : "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cache = new WeakMap(); // mesh → Map(key → { counts, vertices, faces, result })
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Texture coordinates a renderer should use for a mesh: its own, or
|
|
132
|
+
* computed for `mapping` and cached until the mesh changes.
|
|
133
|
+
*
|
|
134
|
+
* @returns {{ uvs:{u:number,v:number}[]|undefined, faceUVs:(number[]|null)[]|undefined }}
|
|
135
|
+
*/
|
|
136
|
+
export function uvsFor(mesh, mapping) {
|
|
137
|
+
const key = mappingKey(mapping);
|
|
138
|
+
if (!key) return { uvs: mesh.uvs, faceUVs: mesh.faceUVs };
|
|
139
|
+
let byKey = cache.get(mesh);
|
|
140
|
+
if (!byKey) cache.set(mesh, (byKey = new Map()));
|
|
141
|
+
const counts = `${mesh.vertices.length}/${mesh.faces?.length ?? 0}`;
|
|
142
|
+
let c = byKey.get(key);
|
|
143
|
+
if (!c || mesh.dirty || c.counts !== counts || c.vertices !== mesh.vertices || c.faces !== mesh.faces) {
|
|
144
|
+
c = { counts, vertices: mesh.vertices, faces: mesh.faces, result: computeUVs(mesh, mapping) };
|
|
145
|
+
byKey.set(key, c);
|
|
146
|
+
}
|
|
147
|
+
return c.result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The 2D transform a material's `textureRepeat`, `textureOffset` and
|
|
152
|
+
* `textureRotation` describe, as [a, b, c, d, e, f]:
|
|
153
|
+
* u' = a·u + c·v + e, v' = b·u + d·v + f. Null when it changes nothing.
|
|
154
|
+
*
|
|
155
|
+
* Rotation turns the image around its center, then repeat scales it
|
|
156
|
+
* (2 = the image twice across), then offset moves it.
|
|
157
|
+
*/
|
|
158
|
+
export function uvTransform(mat) {
|
|
159
|
+
const r = mat.textureRepeat ?? 1, o = mat.textureOffset, a = mat.textureRotation ?? 0;
|
|
160
|
+
const ru = typeof r === "number" ? r : r.u ?? 1, rv = typeof r === "number" ? r : r.v ?? 1;
|
|
161
|
+
const ou = o?.u ?? 0, ov = o?.v ?? 0;
|
|
162
|
+
if (ru === 1 && rv === 1 && !ou && !ov && !a) return null;
|
|
163
|
+
const c = Math.cos(a), s = Math.sin(a);
|
|
164
|
+
// Rotate around (0.5, 0.5): R·(t - 0.5) + 0.5, then scale and move
|
|
165
|
+
return [
|
|
166
|
+
ru * c, rv * s,
|
|
167
|
+
-ru * s, rv * c,
|
|
168
|
+
ru * (0.5 - 0.5 * c + 0.5 * s) + ou,
|
|
169
|
+
rv * (0.5 - 0.5 * s - 0.5 * c) + ov,
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Applies a `uvTransform` to one texture coordinate. */
|
|
174
|
+
export function applyUVTransform(m, t) {
|
|
175
|
+
if (!m || !t) return t;
|
|
176
|
+
return { u: m[0] * t.u + m[2] * t.v + m[4], v: m[1] * t.u + m[3] * t.v + m[5] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Corners that wrap past u = 1 get 1 added, so a face crossing the
|
|
181
|
+
* seam doesn't stretch the whole image across itself. Corners on the
|
|
182
|
+
* axis (u = NaN, like a sphere's poles) take the face's middle u.
|
|
183
|
+
*/
|
|
184
|
+
function fixSeam(ts) {
|
|
185
|
+
const known = ts.filter((t) => !Number.isNaN(t.u));
|
|
186
|
+
if (!known.length) {
|
|
187
|
+
for (const t of ts) t.u = 0.5;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const us = known.map((t) => t.u);
|
|
191
|
+
if (Math.max(...us) - Math.min(...us) > 0.5) {
|
|
192
|
+
for (const t of known) if (t.u < 0.5) t.u += 1;
|
|
193
|
+
}
|
|
194
|
+
const mid = known.reduce((a, t) => a + t.u, 0) / known.length;
|
|
195
|
+
for (const t of ts) if (Number.isNaN(t.u)) t.u = mid;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Outward normal of a clockwise face (not normalized). */
|
|
199
|
+
function faceNormal(p) {
|
|
200
|
+
let x = 0, y = 0, z = 0;
|
|
201
|
+
for (let i = 0; i < p.length; i++) {
|
|
202
|
+
const a = p[i], b = p[(i + 1) % p.length];
|
|
203
|
+
x += (a.y - b.y) * (a.z + b.z);
|
|
204
|
+
y += (a.z - b.z) * (a.x + b.x);
|
|
205
|
+
z += (a.x - b.x) * (a.y + b.y);
|
|
206
|
+
}
|
|
207
|
+
return { x, y, z };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The axis a vector points along most. */
|
|
211
|
+
function dominant(n) {
|
|
212
|
+
const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
|
|
213
|
+
return ay >= ax && ay >= az ? "y" : ax >= az ? "x" : "z";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function bounds(verts) {
|
|
217
|
+
const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
218
|
+
for (const v of verts) {
|
|
219
|
+
if (v.x < min.x) min.x = v.x; if (v.x > max.x) max.x = v.x;
|
|
220
|
+
if (v.y < min.y) min.y = v.y; if (v.y > max.y) max.y = v.y;
|
|
221
|
+
if (v.z < min.z) min.z = v.z; if (v.z > max.z) max.z = v.z;
|
|
222
|
+
}
|
|
223
|
+
if (!verts.length) return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } };
|
|
224
|
+
return { min, max };
|
|
225
|
+
}
|