trid3d 0.4.0 → 0.6.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.
@@ -13,7 +13,8 @@ function cubeWire(size = 0.5) {
13
13
  * @param {number} h - height (y)
14
14
  * @param {number} d - depth (z)
15
15
  */
16
- function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
16
+ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4, radius = 0, segments = 4) {
17
+ if (radius > 0) return roundedBox(w, h, d, radius, segments);
17
18
  const x = w / 2, y = h / 2, z = d / 2;
18
19
 
19
20
  const mesh = {
@@ -51,6 +52,91 @@ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
51
52
  return withFaceUVs(mesh);
52
53
  }
53
54
 
55
+ /**
56
+ * A box with rounded edges and corners, centered on the origin: each side is
57
+ * a grid whose outer rows bend around the edges (`segments` rows per
58
+ * rounded edge, evenly spread over the quarter circle). Vertices along the
59
+ * edges are shared, so smooth shading has no seams. Like the plain box,
60
+ * each side has its own material number (front, back, top, bottom, right,
61
+ * left) and the whole texture, upright as seen from outside.
62
+ */
63
+ function roundedBox(w, h, d, radius, segments) {
64
+ const half = { x: Math.abs(w) / 2, y: Math.abs(h) / 2, z: Math.abs(d) / 2 };
65
+ const r = Math.min(radius, half.x, half.y, half.z);
66
+ const s = Math.max(1, Math.floor(segments));
67
+ const inner = { x: half.x - r, y: half.y - r, z: half.z - r };
68
+ // Grid lines across a side: `s` rows around each rounded edge, one flat span between
69
+ // (where the edges meet, as on a cube of radius = half its size, the flat span is skipped)
70
+ const ticks = (a) => {
71
+ const arc = Array.from({ length: s }, (_, i) => Math.tan((Math.PI / 4) * (1 - i / s)) * r);
72
+ const lo = arc.map((t) => -inner[a] - t), hi = [...arc].reverse().map((t) => inner[a] + t);
73
+ return inner[a] > 1e-12 ? [...lo, -inner[a], inner[a], ...hi] : [...lo, 0, ...hi];
74
+ };
75
+ // The point on the flat side pulled onto the rounded surface
76
+ const round = (p) => {
77
+ const c = { x: Math.max(-inner.x, Math.min(inner.x, p.x)), y: Math.max(-inner.y, Math.min(inner.y, p.y)), z: Math.max(-inner.z, Math.min(inner.z, p.z)) };
78
+ const dx = p.x - c.x, dy = p.y - c.y, dz = p.z - c.z, l = Math.hypot(dx, dy, dz);
79
+ return l > 1e-12 ? { x: c.x + (dx / l) * r, y: c.y + (dy / l) * r, z: c.z + (dz / l) * r } : p;
80
+ };
81
+ // Sides in the plain box's order: front (-z), back (+z), top, bottom, right (+x), left (-x).
82
+ // The texture goes like on the plain box (withFaceUVs): up is +Y (±Z on the top and bottom), right = normal × up
83
+ const axisOf = (v) => (v.x ? ["x", Math.sign(v.x)] : v.y ? ["y", Math.sign(v.y)] : ["z", Math.sign(v.z)]);
84
+ const sides = [["z", -1], ["z", 1], ["y", 1], ["y", -1], ["x", 1], ["x", -1]].map(([n, sign]) => {
85
+ const nv = { x: 0, y: 0, z: 0 };
86
+ nv[n] = sign;
87
+ const up = n === "y" ? { x: 0, y: 0, z: sign } : { x: 0, y: 1, z: 0 };
88
+ const right = { x: nv.y * up.z - nv.z * up.y, y: nv.z * up.x - nv.x * up.z, z: nv.x * up.y - nv.y * up.x };
89
+ return { n, sign, u: axisOf(right), v: axisOf(up) };
90
+ });
91
+ const vertices = [], faces = [], uvs = [], faceUVs = [], faceMaterials = [];
92
+ const shared = new Map();
93
+ const key = (p) => `${p.x.toFixed(9)},${p.y.toFixed(9)},${p.z.toFixed(9)}`;
94
+ sides.forEach((side, m) => {
95
+ const [ua, us] = side.u, [va, vs] = side.v;
96
+ const tu = ticks(ua), tv = ticks(va);
97
+ const ids = [], uvIds = [];
98
+ for (const b of tv) {
99
+ for (const a of tu) {
100
+ const p = { x: 0, y: 0, z: 0 };
101
+ p[side.n] = side.sign * half[side.n];
102
+ p[ua] = a;
103
+ p[va] = b;
104
+ const q = round(p);
105
+ const k = key(q);
106
+ if (!shared.has(k)) shared.set(k, vertices.push(q) - 1);
107
+ ids.push(shared.get(k));
108
+ uvIds.push(uvs.push({ u: (us * a + half[ua]) / (2 * half[ua] || 1), v: (vs * b + half[va]) / (2 * half[va] || 1) }) - 1);
109
+ }
110
+ }
111
+ const nu = tu.length;
112
+ for (let j = 0; j + 1 < tv.length; j++) {
113
+ for (let i = 0; i + 1 < nu; i++) {
114
+ const c = [j * nu + i, j * nu + i + 1, (j + 1) * nu + i + 1, (j + 1) * nu + i];
115
+ // Clockwise from outside
116
+ const p = c.map((t) => vertices[ids[t]]);
117
+ const e1 = { x: p[1].x - p[0].x, y: p[1].y - p[0].y, z: p[1].z - p[0].z };
118
+ const e2 = { x: p[3].x - p[0].x, y: p[3].y - p[0].y, z: p[3].z - p[0].z };
119
+ const nz = { x: e1.y * e2.z - e1.z * e2.y, y: e1.z * e2.x - e1.x * e2.z, z: e1.x * e2.y - e1.y * e2.x }[side.n] * side.sign;
120
+ const order = nz < 0 ? [...c].reverse() : c;
121
+ faces.push(order.map((t) => ids[t]));
122
+ faceUVs.push(order.map((t) => uvIds[t]));
123
+ faceMaterials.push(m);
124
+ }
125
+ }
126
+ });
127
+ // Faces that collapsed where grid points met (a cube rounded into a ball) are dropped
128
+ const keep = faces.map((f) => new Set(f).size >= 3);
129
+ const out = {
130
+ vertices,
131
+ faces: faces.filter((_, i) => keep[i]).map((f) => f.filter((v, i) => v !== f[(i + f.length - 1) % f.length])),
132
+ uvs,
133
+ faceUVs: faceUVs.filter((_, i) => keep[i]),
134
+ faceMaterials: faceMaterials.filter((_, i) => keep[i]),
135
+ };
136
+ out.faceUVs = out.faceUVs.map((uv, i) => (uv.length === out.faces[i].length ? uv : uv.slice(0, out.faces[i].length)));
137
+ return out;
138
+ }
139
+
54
140
  /**
55
141
  * Gives every face the whole texture, upright as seen from outside:
56
142
  * side faces have +Y up, the top face has +Z up, the bottom face -Z.
@@ -163,6 +249,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
163
249
  return { vertices, edges, faces, uvs, faceUVs };
164
250
  }
165
251
 
252
+ /** Profile points closer to the axis than this are poles (one vertex). */
253
+ const POLE = 1e-9;
254
+
166
255
  /**
167
256
  * Spins a profile around the Y axis: vases, bottles, cups, columns, chess
168
257
  * pieces. Cylinders, cones and tori are built with it.
@@ -170,6 +259,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
170
259
  * Walk the profile so the outside is on its right in the (r, y) plane:
171
260
  * bottom to top for a vase. Faces are clockwise as seen from outside.
172
261
  *
262
+ * A point on the axis (`r: 0`) is a pole: one vertex, with triangles around
263
+ * it, like a cone's tip or the closed end of a spinning top.
264
+ *
173
265
  * Texture coordinates: u goes around, v follows the profile's length.
174
266
  * Caps map the texture as a disc. Edges are the profile's rings plus one
175
267
  * line along the profile per segment.
@@ -206,52 +298,67 @@ export function lathe(profile, { segments = 32, capBottom = false, capTop = fals
206
298
  }
207
299
  const total = lengths[lengths.length - 1] || 1;
208
300
 
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 });
301
+ // One ring of vertices per profile point, or a single vertex for a pole (on the axis)
302
+ const pole = profile.map((p) => !(Math.abs(p.r) > POLE));
303
+ const start = [];
304
+ for (const [r, p] of profile.entries()) {
305
+ start.push(vertices.length);
306
+ if (pole[r]) vertices.push({ x: 0, y: p.y, z: 0 });
307
+ else {
308
+ for (let s = 0; s < segments; s++) {
309
+ const t = (s / segments) * Math.PI * 2;
310
+ vertices.push({ x: Math.cos(t) * p.r, y: p.y, z: Math.sin(t) * p.r });
311
+ }
213
312
  }
214
313
  }
314
+ const at = (r, s) => (pole[r] ? start[r] : start[r] + (s % segments));
215
315
 
216
316
  const rows = pts.length - 1;
217
317
  for (let r = 0; r < rows; r++) {
218
- const a = r * segments;
219
- const b = ((r + 1) % profile.length) * segments;
318
+ const next = (r + 1) % profile.length;
319
+ if (pole[r] && pole[next]) continue; // along the axis: nothing to draw
220
320
  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;
321
+ const u0 = s / segments, u1 = (s + 1) / segments, um = (u0 + u1) / 2;
224
322
  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)]);
323
+ // Next to a pole, a triangle; its corner there sits in the middle of u
324
+ if (pole[r]) {
325
+ faces.push([at(r, s), at(next, s), at(next, s + 1)]);
326
+ faceUVs.push([uv(um, v0), uv(u0, v1), uv(u1, v1)]);
327
+ } else if (pole[next]) {
328
+ faces.push([at(r, s), at(next, s), at(r, s + 1)]);
329
+ faceUVs.push([uv(u0, v0), uv(um, v1), uv(u1, v0)]);
330
+ } else {
331
+ faces.push([at(r, s), at(next, s), at(next, s + 1), at(r, s + 1)]);
332
+ faceUVs.push([uv(u0, v0), uv(u0, v1), uv(u1, v1), uv(u1, v0)]);
333
+ }
226
334
  }
227
335
  }
228
336
 
229
- // Rings (skipped where the profile touches the axis) and lines along the profile
337
+ // Rings (none at a pole) and lines along the profile
230
338
  profile.forEach((p, r) => {
231
- if (p.r > 1e-5) edges.push(Array.from({ length: segments }, (_, s) => r * segments + s));
339
+ if (!pole[r]) edges.push(Array.from({ length: segments }, (_, s) => at(r, s)));
232
340
  });
233
341
  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]);
342
+ if (closed && profile.length > 2) edges.push(profile.map((_, r) => at(r, s)));
343
+ else for (let r = 0; r < profile.length - 1; r++) if (!(pole[r] && pole[r + 1])) edges.push([at(r, s), at(r + 1, s)]);
236
344
  }
237
345
 
238
346
  const disc = (s) => {
239
347
  const t = (s / segments) * Math.PI * 2;
240
348
  return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
241
349
  };
242
- if (capBottom && !closed && profile[0].r > 0) {
350
+ if (capBottom && !closed && !pole[0]) {
243
351
  const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
244
352
  for (let s = 0; s < segments; s++) {
245
- faces.push([c, s, (s + 1) % segments]);
353
+ faces.push([c, at(0, s), at(0, s + 1)]);
246
354
  faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
247
355
  }
248
356
  }
249
357
  const last = profile.length - 1;
250
- if (capTop && !closed && profile[last].r > 0) {
358
+ if (capTop && !closed && !pole[last]) {
251
359
  const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
252
- const a = last * segments;
253
360
  for (let s = 0; s < segments; s++) {
254
- faces.push([c, a + (s + 1) % segments, a + s]);
361
+ faces.push([c, at(last, s + 1), at(last, s)]);
255
362
  faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
256
363
  }
257
364
  }
@@ -264,11 +371,9 @@ function cylinder(r = 0.3, h = 0.8, segments = 32) {
264
371
  return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
265
372
  }
266
373
 
267
- /** A cone along Y with its tip up, or a tapered cylinder when `rTop` > 0. */
374
+ /** A cone along Y with its tip up (a single point, so it's closed), or a tapered cylinder when `rTop` > 0. */
268
375
  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 });
376
+ return lathe([{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 0, y: h / 2 }], { segments, capBottom: true, capTop: rTop > 0 });
272
377
  }
273
378
 
274
379
  /** A ring in the XZ plane: `R` from the center to the middle of the tube. */
@@ -548,8 +653,10 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
548
653
  */
549
654
  export function makeShape(kind, opts = {}) {
550
655
  switch (kind.toLowerCase()) {
551
- case "cube": return cubeWire(opts.size ?? 0.5);
552
- case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6);
656
+ case "cube": return opts.radius > 0
657
+ ? rectPrismWire(opts.size ?? 0.5, opts.size ?? 0.5, opts.size ?? 0.5, opts.radius, opts.segments ?? 4)
658
+ : cubeWire(opts.size ?? 0.5);
659
+ case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6, opts.radius ?? 0, opts.segments ?? 4);
553
660
  case "sphere":return sphereWire(opts.r ?? 0.45, opts.rings ?? 12, opts.segments ?? 24);
554
661
  case "cylinder": return cylinder(opts.r, opts.h, opts.segments);
555
662
  case "cone": return cone(opts.r, opts.h, opts.segments, opts.rTop);
@@ -343,7 +343,7 @@ export class WebGLRenderer {
343
343
  // Post effects: the ones with GLSL run here, canvas-only ones after the copy
344
344
  const post = activePost(scene.post);
345
345
  const gpuPost = post.filter((e) => postPasses(e).length);
346
- const background = this._color(ctx, scene.background);
346
+ const background = this._color(ctx, scene.background ?? "transparent");
347
347
  if (gpuPost.length) {
348
348
  // Into a texture, on the scene's background so effects see the whole picture
349
349
  gl.bindFramebuffer(gl.FRAMEBUFFER, this._sceneTarget(w, h));
package/trid/scene.js CHANGED
@@ -37,7 +37,7 @@ export class Scene {
37
37
  * @param {Object} [opts]
38
38
  * @param {number} [opts.width] - Sets canvas.width (keeps the current one if omitted)
39
39
  * @param {number} [opts.height] - Sets canvas.height (keeps the current one if omitted)
40
- * @param {string} [opts.background="#141414"] - Clear color
40
+ * @param {string|null} [opts.background="#141414"] - Clear color; "transparent" or null: see-through, the page shows behind
41
41
  * @param {string} [opts.color="#ffffff"] - Default wireframe color
42
42
  * @param {number} [opts.lineWidth=3] - Default wireframe width
43
43
  * @param {import("./math/transform.js").Camera} [opts.camera={ z: -1 }] - Camera