trid3d 0.6.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 +16 -8
- package/package.json +1 -1
- package/trid/index.d.ts +107 -0
- package/trid/index.js +7 -3
- package/trid/render/canvasRenderer.js +267 -4
- package/trid/render/lights.js +14 -1
- package/trid/render/post.js +154 -0
- package/trid/render/shadows.js +184 -0
- package/trid/render/webglRenderer.js +347 -16
- package/trid/tools/index.js +1 -0
- package/trid/tools/selectionOutline.js +244 -0
- package/trid/tools/transformGizmo.js +9 -1
package/trid/render/post.js
CHANGED
|
@@ -18,11 +18,23 @@
|
|
|
18
18
|
* - `uniforms`: settings used by the code, like `{ uStrength: 0.5 }`. Same
|
|
19
19
|
* types as material effects; read every frame
|
|
20
20
|
* - `enabled: false` turns the effect off without removing it
|
|
21
|
+
* - `geometry: true`: the effect also reads the scene's depth and normals
|
|
22
|
+
* (WebGL renderer). Its GLSL can then call `depthAt(uv)`, `normalAt(uv)`
|
|
23
|
+
* and `maskAt(uv)`, see below. The renderer draws them in one extra pass,
|
|
24
|
+
* only while such an effect is on
|
|
25
|
+
* - `objects`: with `geometry`, the objects `maskAt` is 1 on (their children
|
|
26
|
+
* too). Default: every object
|
|
21
27
|
*
|
|
22
28
|
* In GLSL, every pass can call:
|
|
23
29
|
* - `frame(uv)`: the previous pass's picture (the first pass: the picture
|
|
24
30
|
* this effect starts from)
|
|
25
31
|
* - `source(uv)`: the picture this effect starts from
|
|
32
|
+
* With `geometry: true`, also:
|
|
33
|
+
* - `depthAt(uv)`: distance from the camera, along its view (`f.far` or more
|
|
34
|
+
* where nothing is drawn). Only opaque faces count, not lines
|
|
35
|
+
* - `normalAt(uv)`: the surface's normal, in camera space (z points away from
|
|
36
|
+
* the camera, so faces seen from the front have z < 0)
|
|
37
|
+
* - `maskAt(uv)`: 1 on the effect's `objects`, else 0 (nothing drawn: 0)
|
|
26
38
|
* Colors are premultiplied by alpha (the same as plain colors where the
|
|
27
39
|
* picture is opaque, which it is when the scene has an opaque background).
|
|
28
40
|
*
|
|
@@ -32,6 +44,7 @@
|
|
|
32
44
|
*/
|
|
33
45
|
|
|
34
46
|
import { parseColor } from "./color.js";
|
|
47
|
+
import { DEFAULT_NEAR } from "../math/transform.js";
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
50
|
* What a post effect knows about the picture. GLSL fields and the canvas
|
|
@@ -44,6 +57,8 @@ import { parseColor } from "./color.js";
|
|
|
44
57
|
* @property {{x:number,y:number}} texel - GLSL only: one pixel of the input picture, in uv units
|
|
45
58
|
* @property {number} time - Seconds since the renderer started
|
|
46
59
|
* @property {number[]} background - The scene's background color [r, g, b, a]
|
|
60
|
+
* @property {number} near - The camera's near distance
|
|
61
|
+
* @property {number} far - The camera's far distance (WebGL renderer: what's farther isn't drawn)
|
|
47
62
|
*/
|
|
48
63
|
|
|
49
64
|
/** The GLSL declaration of `Frame`, and the `frame` / `source` helpers. */
|
|
@@ -54,6 +69,8 @@ struct Frame {
|
|
|
54
69
|
vec2 texel;
|
|
55
70
|
float time;
|
|
56
71
|
vec4 background;
|
|
72
|
+
float near;
|
|
73
|
+
float far;
|
|
57
74
|
};
|
|
58
75
|
uniform sampler2D uFrame;
|
|
59
76
|
uniform sampler2D uSource;
|
|
@@ -61,6 +78,52 @@ vec4 frame(vec2 uv) { return texture2D(uFrame, uv); }
|
|
|
61
78
|
vec4 source(vec2 uv) { return texture2D(uSource, uv); }
|
|
62
79
|
`;
|
|
63
80
|
|
|
81
|
+
/**
|
|
82
|
+
* GLSL helpers for effects with `geometry: true`: `depthAt`, `normalAt` and
|
|
83
|
+
* `maskAt`. They read a picture where each pixel packs the surface's normal
|
|
84
|
+
* (red, green) and its distance on a log scale between near and far (blue,
|
|
85
|
+
* alpha), with the mask in alpha's lowest bit.
|
|
86
|
+
*/
|
|
87
|
+
export const GEOMETRY_GLSL = `
|
|
88
|
+
uniform sampler2D uGeometry;
|
|
89
|
+
uniform float uGeoNear;
|
|
90
|
+
uniform float uGeoRange; // log(far / near)
|
|
91
|
+
vec4 geoTexel(vec2 uv) { return floor(texture2D(uGeometry, uv) * 255.0 + 0.5); }
|
|
92
|
+
float depthAt(vec2 uv) {
|
|
93
|
+
vec4 g = geoTexel(uv);
|
|
94
|
+
return uGeoNear * exp((g.b * 128.0 + floor(g.a / 2.0)) / 32766.0 * uGeoRange);
|
|
95
|
+
}
|
|
96
|
+
vec3 normalAt(vec2 uv) {
|
|
97
|
+
vec2 p = geoTexel(uv).rg / 127.5 - 1.0;
|
|
98
|
+
vec3 n = vec3(p, 1.0 - abs(p.x) - abs(p.y));
|
|
99
|
+
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);
|
|
100
|
+
return normalize(n);
|
|
101
|
+
}
|
|
102
|
+
float maskAt(vec2 uv) { return mod(geoTexel(uv).a, 2.0); }
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
/** Whether a post effect reads the scene's depth and normals (`geometry: true`, with GLSL). */
|
|
106
|
+
export function wantsGeometry(effect) {
|
|
107
|
+
return !!effect?.geometry && postPasses(effect).length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every object an effect's `objects` list covers, children included, as a
|
|
112
|
+
* Set. Null when the effect has no list (it covers everything).
|
|
113
|
+
*/
|
|
114
|
+
export function maskedObjects(effect) {
|
|
115
|
+
const list = effect?.objects;
|
|
116
|
+
if (!Array.isArray(list)) return null;
|
|
117
|
+
const out = new Set();
|
|
118
|
+
const visit = (o) => {
|
|
119
|
+
if (!o || typeof o !== "object" || out.has(o)) return;
|
|
120
|
+
out.add(o);
|
|
121
|
+
if (Array.isArray(o.children)) o.children.forEach(visit);
|
|
122
|
+
};
|
|
123
|
+
list.forEach(visit);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
64
127
|
/** A post effect's passes, as `{ glsl, scale }`. Empty when it has no GLSL (canvas only). */
|
|
65
128
|
export function postPasses(effect) {
|
|
66
129
|
if (Array.isArray(effect.passes)) return effect.passes.filter((p) => p?.glsl).map((p) => ({ glsl: p.glsl, scale: p.scale > 0 ? p.scale : 1 }));
|
|
@@ -90,6 +153,8 @@ export function runCanvasPost(scene, list, start) {
|
|
|
90
153
|
time: (performance.now() - start) / 1000,
|
|
91
154
|
canvas,
|
|
92
155
|
rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
|
|
156
|
+
near: scene.camera?.near ?? DEFAULT_NEAR,
|
|
157
|
+
far: scene.camera?.far ?? 1000,
|
|
93
158
|
};
|
|
94
159
|
frame.background = frame.rgba(scene.background ?? "transparent");
|
|
95
160
|
for (const e of effects) {
|
|
@@ -290,6 +355,95 @@ vec4 post(vec4 color, Frame f) {
|
|
|
290
355
|
};
|
|
291
356
|
}
|
|
292
357
|
|
|
358
|
+
/** Crease angle the outline uses when `creases` isn't given, in degrees. */
|
|
359
|
+
export const DEFAULT_OUTLINE_CREASE = 40;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Cartoon-style lines: around each object (where the distance jumps:
|
|
363
|
+
* silhouettes, and where one object passes in front of another) and,
|
|
364
|
+
* with `creases`, inside it where the surface turns sharply (a cube's edges).
|
|
365
|
+
* Lines are drawn on the nearer side of a jump, so they hug the objects.
|
|
366
|
+
*
|
|
367
|
+
* Smooth-shaded surfaces (`smooth: true`) only get lines where their shading
|
|
368
|
+
* keeps a hard edge; flat-shaded ones get one wherever faces meet at more
|
|
369
|
+
* than `creases` degrees.
|
|
370
|
+
*
|
|
371
|
+
* The canvas renderer draws the meshes' outer edges and creases as lines
|
|
372
|
+
* instead (sorted with the faces), so the same scene looks close on both.
|
|
373
|
+
*
|
|
374
|
+
* @param {Object} [opts]
|
|
375
|
+
* @param {string} [opts.color="#1a1a1a"] - Line color (its alpha makes lines see-through)
|
|
376
|
+
* @param {number} [opts.width=2] - Line width, in pixels (WebGL: up to 16)
|
|
377
|
+
* @param {number|false} [opts.creases=40] - Draw a line where the surface turns by more than this
|
|
378
|
+
* many degrees. `false`: outer lines only
|
|
379
|
+
* @param {number} [opts.depth=1] - How readily a jump in distance makes a line (WebGL): higher finds
|
|
380
|
+
* smaller steps (an object resting on another), 0 = none (creases only)
|
|
381
|
+
* @param {Object[]} [opts.objects] - Only outline these objects (and their children). Default: all
|
|
382
|
+
*/
|
|
383
|
+
export function outline({ color = "#1a1a1a", width = 2, creases = DEFAULT_OUTLINE_CREASE, depth = 1, objects } = {}) {
|
|
384
|
+
return {
|
|
385
|
+
name: "outline",
|
|
386
|
+
geometry: true,
|
|
387
|
+
contour: true,
|
|
388
|
+
objects,
|
|
389
|
+
uniforms: { uColor: color, uWidth: width, uCreaseAngle: creases === false ? 180 : creases, uDepth: depth },
|
|
390
|
+
passes: [
|
|
391
|
+
{
|
|
392
|
+
// Where the lines are, 1 pixel thin (red channel)
|
|
393
|
+
glsl: `
|
|
394
|
+
vec4 post(vec4 color, Frame f) {
|
|
395
|
+
if (maskAt(f.uv) < 0.5) return vec4(0.0); // not an outlined object (or nothing there)
|
|
396
|
+
float zc = depthAt(f.uv);
|
|
397
|
+
vec2 d = f.texel;
|
|
398
|
+
|
|
399
|
+
// Jumps in distance. 1 / distance changes evenly across a flat face, so its
|
|
400
|
+
// second difference is 0 there and big at a jump; > 0 only on the nearer side.
|
|
401
|
+
float ic = 1.0 / zc;
|
|
402
|
+
float jx = 2.0 * ic - 1.0 / depthAt(f.uv - vec2(d.x, 0.0)) - 1.0 / depthAt(f.uv + vec2(d.x, 0.0));
|
|
403
|
+
float jy = 2.0 * ic - 1.0 / depthAt(f.uv - vec2(0.0, d.y)) - 1.0 / depthAt(f.uv + vec2(0.0, d.y));
|
|
404
|
+
float line = smoothstep(0.03, 0.08, max(jx, jy) / ic * uDepth);
|
|
405
|
+
|
|
406
|
+
// Creases: the normal turns sharply toward the next pixel, on the same surface
|
|
407
|
+
if (uCreaseAngle < 180.0) {
|
|
408
|
+
vec3 nc = normalAt(f.uv);
|
|
409
|
+
float bend = 0.0;
|
|
410
|
+
for (int k = 0; k < 2; k++) {
|
|
411
|
+
vec2 uv = f.uv + (k == 0 ? vec2(d.x, 0.0) : vec2(0.0, d.y));
|
|
412
|
+
if (abs(depthAt(uv) - zc) > zc * 0.05) continue; // another object: the jump test handles it
|
|
413
|
+
bend = max(bend, 1.0 - dot(nc, normalAt(uv)));
|
|
414
|
+
}
|
|
415
|
+
float limit = 1.0 - cos(radians(uCreaseAngle));
|
|
416
|
+
line = max(line, smoothstep(limit * 0.8, limit * 1.2 + 1e-3, bend));
|
|
417
|
+
}
|
|
418
|
+
return vec4(line, 0.0, 0.0, 1.0);
|
|
419
|
+
}`,
|
|
420
|
+
},
|
|
421
|
+
// Widened to `width` pixels: across, then down (soft at the ends)
|
|
422
|
+
{ glsl: widen("vec2(1.0, 0.0)", "return vec4(w, 0.0, 0.0, 1.0);") },
|
|
423
|
+
{
|
|
424
|
+
glsl: widen("vec2(0.0, 1.0)", `
|
|
425
|
+
float a = w * uColor.a;
|
|
426
|
+
return source(f.uv) * (1.0 - a) + vec4(uColor.rgb * a, a);`),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** One direction of the outline's widening: the strongest line nearby, by distance. */
|
|
433
|
+
function widen(dir, finish) {
|
|
434
|
+
return `
|
|
435
|
+
vec4 post(vec4 color, Frame f) {
|
|
436
|
+
float reach = max(uWidth, 1.0) * 0.5 + 0.5; // full strength up to here, fading over the next pixel
|
|
437
|
+
float w = 0.0;
|
|
438
|
+
for (int i = -8; i <= 8; i++) {
|
|
439
|
+
float o = float(i);
|
|
440
|
+
if (abs(o) > reach) continue;
|
|
441
|
+
w = max(w, frame(f.uv + ${dir} * o * f.texel).r * clamp(reach - abs(o), 0.0, 1.0));
|
|
442
|
+
}
|
|
443
|
+
${finish}
|
|
444
|
+
}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
293
447
|
// --- Helpers for the canvas versions ---
|
|
294
448
|
|
|
295
449
|
function supportsFilter(ctx) {
|
|
@@ -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
|
+
}
|