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.
- package/README.md +43 -20
- package/package.json +1 -1
- package/trid/index.d.ts +210 -7
- package/trid/index.js +10 -3
- package/trid/math/projection.js +8 -2
- package/trid/math/transform.js +4 -5
- package/trid/modeling/query.js +706 -0
- package/trid/render/canvasRenderer.js +268 -5
- package/trid/render/draw.js +12 -2
- package/trid/render/lights.js +14 -1
- package/trid/render/post.js +155 -1
- package/trid/render/shadows.js +184 -0
- package/trid/render/shapes.js +42 -23
- package/trid/render/webglRenderer.js +348 -17
- package/trid/scene.js +1 -1
- package/trid/tools/index.js +1 -0
- package/trid/tools/selectionOutline.js +244 -0
- package/trid/tools/transformGizmo.js +9 -1
|
@@ -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
|
+
}
|
package/trid/render/shapes.js
CHANGED
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
337
|
+
// Rings (none at a pole) and lines along the profile
|
|
316
338
|
profile.forEach((p, r) => {
|
|
317
|
-
if (
|
|
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
|
|
321
|
-
else for (let r = 0; r < profile.length - 1; r++)
|
|
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 &&
|
|
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)
|
|
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 &&
|
|
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,
|
|
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
|
-
|
|
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. */
|