trid3d 0.5.0 → 0.7.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,184 @@
1
+ /**
2
+ * Shadows from a directional light.
3
+ *
4
+ * scene.lights = [
5
+ * { type: "ambient", intensity: 0.3 },
6
+ * { type: "directional", direction: { x: -0.5, y: 1, z: -0.8 }, intensity: 0.7, shadows: true },
7
+ * ];
8
+ *
9
+ * - The first directional light with `shadows` casts them (point lights
10
+ * don't, yet). `shadows` is `true` or options, see `shadowOptions`
11
+ * - Objects cast and receive shadows unless they (or a group they're in)
12
+ * have `castShadow: false` / `receiveShadow: false`. Only faces cast
13
+ * shadows, not lines or see-through faces
14
+ *
15
+ * WebGL renderer: a shadow map. The scene is drawn from the light into a
16
+ * picture of distances, fitted around the casting objects every frame;
17
+ * each pixel then checks whether something is nearer the light than it.
18
+ *
19
+ * Canvas renderer: ground shadows. The casting objects are flattened onto
20
+ * the floor (the level where most of the area of flat, upward faces is, or
21
+ * `ground`) and drawn as one darker shape on the floor's faces. Objects
22
+ * don't shadow each other there.
23
+ */
24
+
25
+ import { transformPoint } from "../math/matrix.js";
26
+ import { newell } from "./normals.js";
27
+
28
+ /** Settings used for `shadows: true`. */
29
+ export const DEFAULT_SHADOWS = Object.freeze({ size: 2048, softness: 1.5, bias: 1, ground: null });
30
+
31
+ /**
32
+ * A light's shadow settings, defaults filled in.
33
+ *
34
+ * @param {boolean|Object} value - The light's `shadows`
35
+ * @returns {{ size:number, softness:number, bias:number, ground:number|null }}
36
+ * - `size`: width of the shadow map in pixels (WebGL). Bigger: sharper, slower
37
+ * - `softness`: blur of the shadows' edges, in shadow map pixels (0: sharp)
38
+ * - `bias`: pushes the test away from surfaces, against speckles ("shadow acne")
39
+ * on surfaces facing the light. Raise it if they appear; too high and shadows
40
+ * come loose from their objects
41
+ * - `ground`: the floor's height for the canvas renderer (default: found)
42
+ */
43
+ export function shadowOptions(value) {
44
+ const o = value && typeof value === "object" ? value : {};
45
+ return {
46
+ size: Math.max(64, Math.min(8192, Math.round(o.size ?? DEFAULT_SHADOWS.size))),
47
+ softness: Math.max(0, o.softness ?? DEFAULT_SHADOWS.softness),
48
+ bias: Math.max(0, o.bias ?? DEFAULT_SHADOWS.bias),
49
+ ground: typeof o.ground === "number" ? o.ground : null,
50
+ };
51
+ }
52
+
53
+ /** False when the object or one of its groups sets `key` to false. */
54
+ export function shadowFlag(obj, key) {
55
+ for (let o = obj; o; o = o.parent) if (o[key] === false) return false;
56
+ return true;
57
+ }
58
+
59
+ const spheres = new WeakMap();
60
+
61
+ /** A sphere around a mesh's vertices, in its own space (cached per vertex array). */
62
+ function meshSphere(mesh) {
63
+ const v = mesh.vertices;
64
+ const hit = spheres.get(v);
65
+ if (hit && hit.n === v.length && !mesh.dirty) return hit;
66
+ let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
67
+ for (const p of v) {
68
+ min = { x: Math.min(min.x, p.x), y: Math.min(min.y, p.y), z: Math.min(min.z, p.z) };
69
+ max = { x: Math.max(max.x, p.x), y: Math.max(max.y, p.y), z: Math.max(max.z, p.z) };
70
+ }
71
+ const c = { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 };
72
+ let r = 0;
73
+ for (const p of v) r = Math.max(r, Math.hypot(p.x - c.x, p.y - c.y, p.z - c.z));
74
+ const s = { c, r, n: v.length };
75
+ spheres.set(v, s);
76
+ return s;
77
+ }
78
+
79
+ /**
80
+ * Where the shadow map looks: a box facing along the light, around every
81
+ * casting object (each object's mesh sphere, placed and scaled).
82
+ *
83
+ * @param {Object[]} casters - Objects with `mesh` and `world`
84
+ * @param {{x:number,y:number,z:number}} dir - Unit direction the light comes from
85
+ * @returns {{ matrix: Float32Array, radius: number } | null} `matrix` takes world points to
86
+ * the shadow map's -1..1 cube (column-major, for WebGL)
87
+ */
88
+ export function shadowFrame(casters, dir) {
89
+ let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
90
+ let any = false;
91
+ for (const obj of casters) {
92
+ if (!obj.mesh?.vertices?.length) continue;
93
+ const s = meshSphere(obj.mesh);
94
+ const w = obj.world;
95
+ const c = transformPoint(w, s.c);
96
+ const k = Math.max(Math.hypot(w[0], w[1], w[2]), Math.hypot(w[4], w[5], w[6]), Math.hypot(w[8], w[9], w[10]));
97
+ const r = s.r * k;
98
+ min = { x: Math.min(min.x, c.x - r), y: Math.min(min.y, c.y - r), z: Math.min(min.z, c.z - r) };
99
+ max = { x: Math.max(max.x, c.x + r), y: Math.max(max.y, c.y + r), z: Math.max(max.z, c.z + r) };
100
+ any = true;
101
+ }
102
+ if (!any) return null;
103
+ const c = { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 };
104
+ const R = Math.max(1e-6, Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z) / 2);
105
+
106
+ // The light looks along -dir; right and up across it
107
+ const f = { x: -dir.x, y: -dir.y, z: -dir.z };
108
+ const up = Math.abs(f.y) > 0.99 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 };
109
+ const r = unit(cross(up, f));
110
+ const u = cross(f, r);
111
+ const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
112
+ const m = new Float32Array([
113
+ r.x / R, u.x / R, f.x / R, 0,
114
+ r.y / R, u.y / R, f.y / R, 0,
115
+ r.z / R, u.z / R, f.z / R, 0,
116
+ -dot(r, c) / R, -dot(u, c) / R, -dot(f, c) / R, 1,
117
+ ]);
118
+ return { matrix: m, radius: R };
119
+ }
120
+
121
+ /**
122
+ * The floor for ground shadows: the height where receiving objects have the
123
+ * most area of flat faces turned up. Null when there's none.
124
+ *
125
+ * @param {Object[]} receivers - Objects with `mesh` and `world`
126
+ */
127
+ export function findGround(receivers) {
128
+ const area = new Map();
129
+ for (const obj of receivers) {
130
+ const v = obj.mesh.vertices;
131
+ for (const face of obj.mesh.faces ?? []) {
132
+ if (face.length < 3) continue;
133
+ const p = face.map((i) => transformPoint(obj.world, v[i]));
134
+ const n = newell(p);
135
+ const len = Math.hypot(n.x, n.y, n.z);
136
+ if (!(len > 0) || n.y / len < 0.99) continue;
137
+ const y = Math.round(p[0].y * 1e4) / 1e4;
138
+ area.set(y, (area.get(y) ?? 0) + len / 2);
139
+ }
140
+ }
141
+ let best = null, most = 0;
142
+ for (const [y, a] of area) if (a > most) [best, most] = [y, a];
143
+ return best;
144
+ }
145
+
146
+ /**
147
+ * A world polygon's part above `ground`, flattened onto it along the light.
148
+ * Empty when it's all at or below the ground.
149
+ *
150
+ * @param {{x:number,y:number,z:number}[]} pts - World points
151
+ * @param {{x:number,y:number,z:number}} dir - Unit direction the light comes from (dir.y > 0)
152
+ * @param {number} ground
153
+ */
154
+ export function flattenOnGround(pts, dir, ground) {
155
+ const eps = 1e-5;
156
+ if (pts.every((p) => p.y <= ground + eps)) return [];
157
+ // Cut off what's below the ground
158
+ let poly = pts;
159
+ if (pts.some((p) => p.y < ground)) {
160
+ poly = [];
161
+ for (let i = 0; i < pts.length; i++) {
162
+ const a = pts[i], b = pts[(i + 1) % pts.length];
163
+ const aIn = a.y >= ground, bIn = b.y >= ground;
164
+ if (aIn) poly.push(a);
165
+ if (aIn !== bIn) {
166
+ const t = (ground - a.y) / (b.y - a.y);
167
+ poly.push({ x: a.x + (b.x - a.x) * t, y: ground, z: a.z + (b.z - a.z) * t });
168
+ }
169
+ }
170
+ }
171
+ return poly.map((p) => {
172
+ const t = (p.y - ground) / dir.y;
173
+ return { x: p.x - dir.x * t, y: ground, z: p.z - dir.z * t };
174
+ });
175
+ }
176
+
177
+ function cross(a, b) {
178
+ return { 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 };
179
+ }
180
+
181
+ function unit(v) {
182
+ const len = Math.hypot(v.x, v.y, v.z) || 1;
183
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
184
+ }
@@ -249,6 +249,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
249
249
  return { vertices, edges, faces, uvs, faceUVs };
250
250
  }
251
251
 
252
+ /** Profile points closer to the axis than this are poles (one vertex). */
253
+ const POLE = 1e-9;
254
+
252
255
  /**
253
256
  * Spins a profile around the Y axis: vases, bottles, cups, columns, chess
254
257
  * pieces. Cylinders, cones and tori are built with it.
@@ -256,6 +259,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
256
259
  * Walk the profile so the outside is on its right in the (r, y) plane:
257
260
  * bottom to top for a vase. Faces are clockwise as seen from outside.
258
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
+ *
259
265
  * Texture coordinates: u goes around, v follows the profile's length.
260
266
  * Caps map the texture as a disc. Edges are the profile's rings plus one
261
267
  * line along the profile per segment.
@@ -292,52 +298,67 @@ export function lathe(profile, { segments = 32, capBottom = false, capTop = fals
292
298
  }
293
299
  const total = lengths[lengths.length - 1] || 1;
294
300
 
295
- for (const p of profile) {
296
- for (let s = 0; s < segments; s++) {
297
- const t = (s / segments) * Math.PI * 2;
298
- 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
+ }
299
312
  }
300
313
  }
314
+ const at = (r, s) => (pole[r] ? start[r] : start[r] + (s % segments));
301
315
 
302
316
  const rows = pts.length - 1;
303
317
  for (let r = 0; r < rows; r++) {
304
- const a = r * segments;
305
- 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
306
320
  for (let s = 0; s < segments; s++) {
307
- const s1 = (s + 1) % segments;
308
- faces.push([a + s, b + s, b + s1, a + s1]);
309
- const u0 = s / segments, u1 = (s + 1) / segments;
321
+ const u0 = s / segments, u1 = (s + 1) / segments, um = (u0 + u1) / 2;
310
322
  const v0 = lengths[r] / total, v1 = lengths[r + 1] / total;
311
- 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
+ }
312
334
  }
313
335
  }
314
336
 
315
- // Rings (skipped where the profile touches the axis) and lines along the profile
337
+ // Rings (none at a pole) and lines along the profile
316
338
  profile.forEach((p, r) => {
317
- 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)));
318
340
  });
319
341
  for (let s = 0; s < segments; s++) {
320
- if (closed && profile.length > 2) edges.push(profile.map((_, r) => r * segments + s));
321
- 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)]);
322
344
  }
323
345
 
324
346
  const disc = (s) => {
325
347
  const t = (s / segments) * Math.PI * 2;
326
348
  return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
327
349
  };
328
- if (capBottom && !closed && profile[0].r > 0) {
350
+ if (capBottom && !closed && !pole[0]) {
329
351
  const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
330
352
  for (let s = 0; s < segments; s++) {
331
- faces.push([c, s, (s + 1) % segments]);
353
+ faces.push([c, at(0, s), at(0, s + 1)]);
332
354
  faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
333
355
  }
334
356
  }
335
357
  const last = profile.length - 1;
336
- if (capTop && !closed && profile[last].r > 0) {
358
+ if (capTop && !closed && !pole[last]) {
337
359
  const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
338
- const a = last * segments;
339
360
  for (let s = 0; s < segments; s++) {
340
- faces.push([c, a + (s + 1) % segments, a + s]);
361
+ faces.push([c, at(last, s + 1), at(last, s)]);
341
362
  faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
342
363
  }
343
364
  }
@@ -350,11 +371,9 @@ function cylinder(r = 0.3, h = 0.8, segments = 32) {
350
371
  return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
351
372
  }
352
373
 
353
- /** 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. */
354
375
  function cone(r = 0.35, h = 0.8, segments = 32, rTop = 0) {
355
- // A tiny top ring keeps the tip's faces non-degenerate for UVs and normals
356
- const profile = [{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 1e-6, y: h / 2 }];
357
- 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 });
358
377
  }
359
378
 
360
379
  /** A ring in the XZ plane: `R` from the center to the middle of the tube. */