trid3d 0.0.0-stage → 0.1.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,146 @@
1
+ import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
2
+
3
+ /**
4
+ * Clears the entire canvas with a solid background color.
5
+ * This should usually be called once per frame before drawing.
6
+ *
7
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
8
+ * @param {number} w - Canvas width
9
+ * @param {number} h - Canvas height
10
+ * @param {string} color - Background color (CSS color)
11
+ */
12
+ export function clear(ctx, w, h, color) {
13
+ ctx.fillStyle = color;
14
+ ctx.fillRect(0, 0, w, h);
15
+ }
16
+
17
+ /**
18
+ * Draws a square point centered at a given 2D position.
19
+ * Useful for debugging or drawing vertices.
20
+ *
21
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
22
+ * @param {{x:number, y:number}} p - Screen-space point
23
+ * @param {string} color - Point color
24
+ * @param {number} size - Width/height of the square (default: 20)
25
+ */
26
+ export function point(ctx, p, color, size = 20) {
27
+ ctx.fillStyle = color;
28
+ ctx.fillRect(
29
+ p.x - size / 2,
30
+ p.y - size / 2,
31
+ size,
32
+ size
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Draws a straight line between two 2D screen-space points.
38
+ *
39
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
40
+ * @param {{x:number, y:number}} p1 - Start point
41
+ * @param {{x:number, y:number}} p2 - End point
42
+ * @param {string} color - Line color
43
+ * @param {number} width - Line thickness (default: 4)
44
+ */
45
+ export function line(ctx, p1, p2, color, width = 4) {
46
+ ctx.lineWidth = width;
47
+ ctx.strokeStyle = color;
48
+
49
+ ctx.beginPath();
50
+ ctx.moveTo(p1.x, p1.y);
51
+ ctx.lineTo(p2.x, p2.y);
52
+ ctx.stroke();
53
+ }
54
+
55
+ /**
56
+ * Turns edge definitions into pairs of vertex indices.
57
+ *
58
+ * - An edge with 2 indices is a single segment: [a, b]
59
+ * - An edge with 3+ indices is a closed loop: the last index
60
+ * connects back to the first
61
+ *
62
+ * @param {number[][]} edges - Edge definitions using vertex indices
63
+ * @returns {Generator<[number, number]>} Index pairs to connect
64
+ */
65
+ export function* edgeSegments(edges) {
66
+ for (const e of edges) {
67
+ const closed = e.length > 2;
68
+ const count = closed ? e.length : e.length - 1;
69
+ for (let i = 0; i < count; i++) {
70
+ yield [e[i], e[(i + 1) % e.length]];
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Draws a wireframe object by connecting projected vertices
77
+ * using index-based edge definitions.
78
+ *
79
+ * Each edge is defined as an array of vertex indices.
80
+ * A 2-index edge is a single segment; with 3 or more indices,
81
+ * consecutive indices are connected and the last index
82
+ * connects back to the first (closed loop).
83
+ *
84
+ * This function assumes:
85
+ * - `points2` indices match the original vertex order
86
+ * - All points are already in screen space
87
+ *
88
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
89
+ * @param {{x:number, y:number}[]} points2 - 2D screen-space points
90
+ * @param {number[][]} edges - Edge definitions using vertex indices
91
+ * All segments go into one path and are stroked once.
92
+ *
93
+ * @param {string} color - Stroke color
94
+ * @param {number} width - Line thickness (default: 4)
95
+ */
96
+ export function drawWireframe(ctx, points2, edges, color, width = 4) {
97
+ ctx.lineWidth = width;
98
+ ctx.strokeStyle = color;
99
+
100
+ ctx.beginPath();
101
+ for (const [i, j] of edgeSegments(edges)) {
102
+ ctx.moveTo(points2[i].x, points2[i].y);
103
+ ctx.lineTo(points2[j].x, points2[j].y);
104
+ }
105
+ ctx.stroke();
106
+ }
107
+
108
+ /**
109
+ * Draws a mesh instance by applying its transform and rendering
110
+ * its wireframe representation.
111
+ *
112
+ * Segments are clipped against the camera's near plane, so parts
113
+ * of the mesh behind the camera are not drawn. All segments go
114
+ * into one path and are stroked once.
115
+ *
116
+ * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
117
+ * @param {{
118
+ * mesh:{ vertices:{x:number,y:number,z:number}[], edges:number[][] },
119
+ * pos:{x:number,y:number,z:number},
120
+ * rot:{x:number,y:number,z:number},
121
+ * scale:{x:number,y:number,z:number}
122
+ * }} inst - Mesh instance
123
+ * @param {import("../math/transform.js").Camera} camera - Camera settings
124
+ * @param {number} w - Canvas width
125
+ * @param {number} h - Canvas height
126
+ * @param {string} color - Wireframe color
127
+ * @param {number} lineWidth - Wireframe width
128
+ */
129
+ export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
130
+ const near = camera.near ?? DEFAULT_NEAR;
131
+ const ptsCam = inst.mesh.vertices.map(v => worldToCamera(localToWorld(v, inst), camera));
132
+
133
+ ctx.lineWidth = lineWidth;
134
+ ctx.strokeStyle = color;
135
+
136
+ ctx.beginPath();
137
+ for (const [i, j] of edgeSegments(inst.mesh.edges)) {
138
+ const seg = clipNear(ptsCam[i], ptsCam[j], near);
139
+ if (!seg) continue;
140
+ const a = cameraToScreen(seg[0], camera, w, h);
141
+ const b = cameraToScreen(seg[1], camera, w, h);
142
+ ctx.moveTo(a.x, a.y);
143
+ ctx.lineTo(b.x, b.y);
144
+ }
145
+ ctx.stroke();
146
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Effects: small shaders that change how a material looks.
3
+ *
4
+ * A material can have a list of them, applied in order. Each one gets
5
+ * the color so far and the `Surface` being drawn, and returns a new color:
6
+ *
7
+ * material: { fill: "#8ab4f8", effects: [toon({ steps: 3 }), rim()] }
8
+ *
9
+ * An effect is a plain object:
10
+ * - `glsl`: GLSL code defining `vec4 effect(vec4 color, Surface s)` (WebGL renderer)
11
+ * - `canvas`: optional `(color, s, uniforms) => color` run once per face
12
+ * by the canvas renderer; colors are [r, g, b, a] from 0 to 1
13
+ * - `uniforms`: optional settings used by the code, like `{ uPower: 2 }`.
14
+ * Change them at any time; they're read every frame.
15
+ * Numbers are `float`, [x, y] / [x, y, z] / [x, y, z, w] are `vec2..4`,
16
+ * CSS color strings are `vec4` colors, `true` / `false` are `float` 1 / 0.
17
+ *
18
+ * The built-in effects below are written exactly that way.
19
+ */
20
+
21
+ /**
22
+ * What an effect knows about the point being drawn. GLSL fields and
23
+ * the canvas renderer's `s` object have the same names.
24
+ *
25
+ * @typedef {Object} Surface
26
+ * @property {number[]} base - The material's fill color [r, g, b, a], times the texture's color
27
+ * @property {{u,v}} uv - Texture coordinates (0, 0 when the mesh has none)
28
+ * @property {{x,y,z}} normal - Unit normal, in world space, facing the camera's side
29
+ * @property {{x,y,z}} position - World position
30
+ * @property {{x,y,z}} local - Position in the mesh's own space
31
+ * @property {{x,y,z}} view - Unit direction from the point to the camera
32
+ * @property {{x,y,z}} lightDir - Unit direction light comes from
33
+ * @property {number} diffuse - How much the point faces the light (0..1)
34
+ * @property {number} light - Brightness Trid used: ambient + (1 - ambient) × diffuse
35
+ * @property {number} ambient - The scene's ambient level
36
+ * @property {number} depth - Distance to the camera
37
+ * @property {number} time - Seconds since the renderer started
38
+ * @property {number[]} background - The scene's background color [r, g, b, a]
39
+ */
40
+
41
+ /** The GLSL declaration of `Surface`, shared by every effect. */
42
+ export const SURFACE_GLSL = `
43
+ struct Surface {
44
+ vec4 base;
45
+ vec2 uv;
46
+ vec3 normal;
47
+ vec3 position;
48
+ vec3 local;
49
+ vec3 view;
50
+ vec3 lightDir;
51
+ float diffuse;
52
+ float light;
53
+ float ambient;
54
+ float depth;
55
+ float time;
56
+ vec4 background;
57
+ };
58
+ `;
59
+
60
+ /**
61
+ * Cartoon look: the light comes in a few hard steps instead of a smooth fade.
62
+ *
63
+ * @param {Object} [opts]
64
+ * @param {number} [opts.steps=3] - Number of brightness levels
65
+ */
66
+ export function toon({ steps = 3 } = {}) {
67
+ return {
68
+ name: "toon",
69
+ uniforms: { uSteps: steps },
70
+ glsl: `
71
+ vec4 effect(vec4 color, Surface s) {
72
+ float level = min(floor(s.diffuse * uSteps), uSteps - 1.0) / max(uSteps - 1.0, 1.0);
73
+ float b = s.ambient + (1.0 - s.ambient) * level;
74
+ return vec4(s.base.rgb * b, color.a);
75
+ }`,
76
+ canvas: (color, s, u) => {
77
+ const level = Math.min(Math.floor(s.diffuse * u.uSteps), u.uSteps - 1) / Math.max(u.uSteps - 1, 1);
78
+ const b = s.ambient + (1 - s.ambient) * level;
79
+ return [s.base[0] * b, s.base[1] * b, s.base[2] * b, color[3]];
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * A glow along the edges, where the surface turns away from the camera.
86
+ *
87
+ * @param {Object} [opts]
88
+ * @param {string} [opts.color="#ffffff"] - Glow color
89
+ * @param {number} [opts.power=2] - Higher = thinner glow
90
+ * @param {number} [opts.strength=1] - How bright the glow is
91
+ */
92
+ export function rim({ color = "#ffffff", power = 2, strength = 1 } = {}) {
93
+ return {
94
+ name: "rim",
95
+ uniforms: { uRimColor: color, uPower: power, uStrength: strength },
96
+ glsl: `
97
+ vec4 effect(vec4 color, Surface s) {
98
+ float edge = pow(1.0 - max(0.0, dot(s.normal, s.view)), uPower) * uStrength;
99
+ return vec4(color.rgb + uRimColor.rgb * edge, color.a);
100
+ }`,
101
+ canvas: (c, s, u) => {
102
+ const n = s.normal, v = s.view;
103
+ const edge = Math.pow(1 - Math.max(0, n.x * v.x + n.y * v.y + n.z * v.z), u.uPower) * u.uStrength;
104
+ const r = s.rgba(u.uRimColor);
105
+ return [c[0] + r[0] * edge, c[1] + r[1] * edge, c[2] + r[2] * edge, c[3]];
106
+ },
107
+ };
108
+ }
109
+
110
+ /**
111
+ * A glow along the border of each face, using the texture coordinates
112
+ * (0 and 1 are a face's edges). Made for boxy shapes, where `rim` barely
113
+ * shows. Needs texture coordinates: built-in shapes have them.
114
+ *
115
+ * WebGL only: the canvas renderer runs effects once per face, at its center,
116
+ * which is never near a border.
117
+ *
118
+ * @param {Object} [opts]
119
+ * @param {string} [opts.color="#ffe08a"] - Glow color
120
+ * @param {number} [opts.width=0.08] - Thickness, as a fraction of the face (0.08 = 8%)
121
+ * @param {number} [opts.strength=1] - How bright the glow is
122
+ */
123
+ export function edgeGlow({ color = "#ffe08a", width = 0.08, strength = 1 } = {}) {
124
+ return {
125
+ name: "edgeGlow",
126
+ uniforms: { uGlowColor: color, uWidth: width, uStrength: strength },
127
+ glsl: `
128
+ vec4 effect(vec4 color, Surface s) {
129
+ vec2 d = min(fract(s.uv), 1.0 - fract(s.uv)); // distance to the nearest face border
130
+ float edge = 1.0 - smoothstep(0.0, uWidth, min(d.x, d.y));
131
+ return vec4(color.rgb + uGlowColor.rgb * edge * uStrength, color.a);
132
+ }`,
133
+ };
134
+ }
135
+
136
+ /** Ignores the light: the object shows its fill color everywhere, like it glows. */
137
+ export function unlit() {
138
+ return {
139
+ name: "unlit",
140
+ uniforms: {},
141
+ glsl: `
142
+ vec4 effect(vec4 color, Surface s) {
143
+ return s.base;
144
+ }`,
145
+ canvas: (color, s) => s.base.slice(),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Color that changes along an axis, like a sky or terrain by height.
151
+ *
152
+ * @param {Object} [opts]
153
+ * @param {string} [opts.from="#E9806E"] - Color at `min`
154
+ * @param {string} [opts.to="#8ab4f8"] - Color at `max`
155
+ * @param {"x"|"y"|"z"} [opts.axis="y"] - Direction of the change
156
+ * @param {number} [opts.min=-0.5] - Where `from` is fully reached
157
+ * @param {number} [opts.max=0.5] - Where `to` is fully reached
158
+ * @param {"local"|"world"} [opts.space="local"] - Measure in the mesh's own space (moves with it) or in the world
159
+ * @param {boolean} [opts.lit=true] - Keep the shading from the light
160
+ */
161
+ export function gradient({
162
+ from = "#E9806E", to = "#8ab4f8", axis = "y", min = -0.5, max = 0.5, space = "local", lit = true,
163
+ } = {}) {
164
+ const k = { x: 0, y: 1, z: 2 }[axis] ?? 1;
165
+ const p = space === "world" ? "position" : "local";
166
+ return {
167
+ name: "gradient",
168
+ uniforms: { uFrom: from, uTo: to, uMin: min, uMax: max, uLit: lit },
169
+ glsl: `
170
+ vec4 effect(vec4 color, Surface s) {
171
+ float t = clamp((s.${p}[${k}] - uMin) / (uMax - uMin), 0.0, 1.0);
172
+ vec3 c = mix(uFrom.rgb, uTo.rgb, t);
173
+ return vec4(c * mix(1.0, s.light, uLit), color.a);
174
+ }`,
175
+ canvas: (color, s, u) => {
176
+ const v = s[p][axis] ?? s[p].y;
177
+ const t = Math.min(1, Math.max(0, (v - u.uMin) / (u.uMax - u.uMin)));
178
+ const a = s.rgba(u.uFrom), b = s.rgba(u.uTo);
179
+ const l = u.uLit ? s.light : 1;
180
+ return [0, 1, 2].map((i) => (a[i] + (b[i] - a[i]) * t) * l).concat(color[3]);
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Fades objects into a color with distance.
187
+ *
188
+ * @param {Object} [opts]
189
+ * @param {number} [opts.near=1] - Distance where the fog starts
190
+ * @param {number} [opts.far=5] - Distance where it's fully fog
191
+ * @param {string} [opts.color] - Fog color (default: the scene's background)
192
+ */
193
+ export function fog({ near = 1, far = 5, color } = {}) {
194
+ return {
195
+ name: "fog",
196
+ uniforms: { uNearFog: near, uFarFog: far, uFogColor: color ?? "#000000", uOwnColor: color != null },
197
+ glsl: `
198
+ vec4 effect(vec4 color, Surface s) {
199
+ float t = smoothstep(uNearFog, uFarFog, s.depth);
200
+ vec3 c = mix(s.background.rgb, uFogColor.rgb, uOwnColor);
201
+ return vec4(mix(color.rgb, c, t), color.a);
202
+ }`,
203
+ canvas: (color, s, u) => {
204
+ let t = Math.min(1, Math.max(0, (s.depth - u.uNearFog) / (u.uFarFog - u.uNearFog)));
205
+ t = t * t * (3 - 2 * t);
206
+ const c = u.uOwnColor ? s.rgba(u.uFogColor) : s.background;
207
+ return [0, 1, 2].map((i) => color[i] + (c[i] - color[i]) * t).concat(color[3]);
208
+ },
209
+ };
210
+ }
211
+
212
+ // --- Used by the renderers ---
213
+
214
+ /** GLSL type of a uniform value, or null if it can't be one. */
215
+ export function uniformType(value) {
216
+ if (typeof value === "number" || typeof value === "boolean") return "float";
217
+ if (typeof value === "string") return "vec4";
218
+ if (Array.isArray(value) && value.length >= 2 && value.length <= 4) return "vec" + value.length;
219
+ if (value && typeof value === "object" && "x" in value) return "vec3";
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Joins a material's effects into GLSL. Each effect's `effect` function,
225
+ * helper functions and uniforms get a numbered suffix, so the same effect
226
+ * can be used twice and two effects can use the same names.
227
+ *
228
+ * @param {Object[]} effects
229
+ * @returns {{ code:string, calls:string, uniforms:{ effect:number, name:string, glslName:string, type:string }[] }}
230
+ */
231
+ export function buildEffects(effects) {
232
+ let code = "";
233
+ let calls = "";
234
+ const uniforms = [];
235
+
236
+ effects.forEach((e, i) => {
237
+ let src = e.glsl ?? "vec4 effect(vec4 color, Surface s) { return color; }";
238
+ const rename = new Map();
239
+
240
+ for (const [name, value] of Object.entries(e.uniforms ?? {})) {
241
+ const type = uniformType(value);
242
+ if (!type) continue;
243
+ rename.set(name, `${name}_${i}`);
244
+ uniforms.push({ effect: i, name, glslName: `${name}_${i}`, type });
245
+ }
246
+ // Every function the effect defines, including `effect` itself
247
+ const fn = /(?:^|[\s;}])(?:void|float|int|bool|vec[234]|mat[234]|Surface)\s+([A-Za-z_]\w*)\s*\(/g;
248
+ for (const m of src.matchAll(fn)) rename.set(m[1], `${m[1]}_${i}`);
249
+
250
+ for (const [from, to] of rename) src = src.replace(new RegExp(`\\b${from}\\b`, "g"), to);
251
+
252
+ const decls = uniforms.filter((u) => u.effect === i).map((u) => `uniform ${u.type} ${u.glslName};`).join("\n");
253
+ code += `\n// Effect ${i}${e.name ? ": " + e.name : ""}\n${decls}\n${src}\n`;
254
+ calls += ` color = effect_${i}(color, s);\n`;
255
+ });
256
+
257
+ return { code, calls, uniforms };
258
+ }
259
+
260
+ /** A key that's the same for effect lists that compile to the same shader. */
261
+ export function effectsKey(effects) {
262
+ return effects
263
+ .map((e) => (e.glsl ?? "") + "|" + Object.entries(e.uniforms ?? {}).map(([k, v]) => k + ":" + uniformType(v)).join(","))
264
+ .join("\n");
265
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runs a callback every animation frame using requestAnimationFrame.
3
+ *
4
+ * The callback receives:
5
+ * - `dt`: seconds since the previous frame (0 on the first frame),
6
+ * capped at `maxDt` so a hidden tab doesn't cause a big jump
7
+ * - `time`: seconds since the loop started
8
+ *
9
+ * Typical usage:
10
+ * const stop = loop((dt) => { ... });
11
+ * stop(); // later, to end the loop
12
+ *
13
+ * @param {(dt:number, time:number) => void} update - Called once per frame
14
+ * @param {Object} [opts]
15
+ * @param {number} [opts.maxDt=0.1] - Largest dt passed to `update`, in seconds
16
+ * @returns {() => void} Function that stops the loop
17
+ */
18
+ export function loop(update, { maxDt = 0.1 } = {}) {
19
+ let running = true;
20
+ let id = 0;
21
+ let start = null;
22
+ let last = null;
23
+
24
+ function frame(now) {
25
+ if (!running) return;
26
+
27
+ if (start === null) start = last = now;
28
+ const dt = Math.min(Math.max(now - last, 0) / 1000, maxDt);
29
+ last = now;
30
+
31
+ update(dt, (now - start) / 1000);
32
+
33
+ if (running) id = requestAnimationFrame(frame);
34
+ }
35
+
36
+ id = requestAnimationFrame(frame);
37
+
38
+ return function stop() {
39
+ running = false;
40
+ cancelAnimationFrame(id);
41
+ };
42
+ }
@@ -0,0 +1,90 @@
1
+ /** Faces meeting at a sharper angle than this (degrees) keep a hard edge with `smooth: true`. */
2
+ export const DEFAULT_CREASE = 60;
3
+
4
+ const cache = new WeakMap();
5
+
6
+ /**
7
+ * Normals at each corner of each face, for smooth shading.
8
+ *
9
+ * A corner's normal is the average of the faces around its vertex
10
+ * (bigger faces count more), skipping faces that meet this one at a
11
+ * sharper angle than `crease`. So a sphere looks round while a cube
12
+ * keeps its sharp edges.
13
+ *
14
+ * When the mesh has its own normals (`mesh.normals` and
15
+ * `mesh.faceNormals`, as loaded from an OBJ file), those are used
16
+ * for the faces that have them.
17
+ *
18
+ * The result is cached per mesh until its arrays are replaced,
19
+ * resized, or `mesh.dirty` is set.
20
+ *
21
+ * @param {{ vertices:{x:number,y:number,z:number}[], faces?:number[][],
22
+ * normals?:{x:number,y:number,z:number}[], faceNormals?:(number[]|null)[] }} mesh
23
+ * @param {number} [crease=60] - Angle in degrees
24
+ * @returns {{x:number,y:number,z:number}[][]} For each face, one unit normal per corner
25
+ */
26
+ export function cornerNormals(mesh, crease = DEFAULT_CREASE) {
27
+ const faces = mesh.faces ?? [];
28
+ const key = `${crease}/${mesh.vertices.length}/${faces.length}`;
29
+ const hit = cache.get(mesh);
30
+ if (hit && !mesh.dirty && hit.key === key && hit.vertices === mesh.vertices && hit.faces === faces) {
31
+ return hit.result;
32
+ }
33
+
34
+ const v = mesh.vertices;
35
+ const raw = faces.map((f) => (f.length >= 3 ? newell(f.map((i) => v[i])) : { x: 0, y: 0, z: 0 }));
36
+ const unit = raw.map(normalize);
37
+
38
+ // Faces touching each vertex
39
+ const around = v.map(() => []);
40
+ faces.forEach((f, k) => {
41
+ for (const i of f) around[i].push(k);
42
+ });
43
+
44
+ const minDot = Math.cos((crease * Math.PI) / 180) - 1e-6;
45
+ const result = faces.map((f, k) => {
46
+ const own = mesh.faceNormals?.[k];
47
+ if (own && mesh.normals) return own.map((n) => normalize(mesh.normals[n]));
48
+
49
+ const u = unit[k];
50
+ return f.map((i) => {
51
+ let x = 0, y = 0, z = 0;
52
+ for (const g of around[i]) {
53
+ const w = unit[g];
54
+ if (u.x * w.x + u.y * w.y + u.z * w.z < minDot) continue;
55
+ x += raw[g].x; y += raw[g].y; z += raw[g].z;
56
+ }
57
+ return normalize({ x, y, z });
58
+ });
59
+ });
60
+
61
+ cache.set(mesh, { key, vertices: mesh.vertices, faces, result });
62
+ return result;
63
+ }
64
+
65
+ /** The `smooth` material setting as a crease angle, or null for flat shading. */
66
+ export function creaseOf(smooth) {
67
+ if (smooth === true) return DEFAULT_CREASE;
68
+ if (typeof smooth === "number" && smooth > 0) return smooth;
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Normal of a polygon (Newell's method, works for any number of points).
74
+ * Points clockwise as seen from a side give a normal pointing to that side.
75
+ */
76
+ export function newell(pts) {
77
+ let x = 0, y = 0, z = 0;
78
+ for (let i = 0; i < pts.length; i++) {
79
+ const a = pts[i], b = pts[(i + 1) % pts.length];
80
+ x += (a.y - b.y) * (a.z + b.z);
81
+ y += (a.z - b.z) * (a.x + b.x);
82
+ z += (a.x - b.x) * (a.y + b.y);
83
+ }
84
+ return { x, y, z };
85
+ }
86
+
87
+ function normalize(p) {
88
+ const len = Math.hypot(p.x, p.y, p.z) || 1;
89
+ return { x: p.x / len, y: p.y / len, z: p.z / len };
90
+ }