trid3d 0.5.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.
package/README.md CHANGED
@@ -4,20 +4,20 @@
4
4
 
5
5
  <h1 align="center">Trid</h1>
6
6
 
7
- <p align="center">A small 3D library for the HTML canvas. No dependencies, no build step.</p>
7
+ <p align="center">Model, light and draw 3D on the HTML canvas. No dependencies, no build step.</p>
8
8
 
9
9
  ---
10
10
 
11
- Trid gives you a scene, a camera, shapes, model loading and mouse/keyboard tools, all in plain ES modules you can drop next to your page.
11
+ Trid gives you a scene, shapes you can model in code, lights, effects, model loading and mouse/keyboard tools, all in plain ES modules you can drop next to your page. See the [docs](https://zachr622.github.io/Trid/) for live demos.
12
12
 
13
13
  > **Status:** early (0.x): the API can still change between minor versions. See [Upgrading](#upgrading).
14
14
 
15
15
  ## Features
16
16
 
17
- - **Scene** — holds a canvas, a camera and your objects, and runs the frame loop for you.
17
+ - **Scene** — holds a canvas, a camera, lights and your objects, and runs the frame loop for you. The background can be a color or see-through (`"transparent"`), so a model can float over your page.
18
18
  - **Groups** — give any object `children` and they move, turn and scale with it (models made of parts, a moon orbiting a planet).
19
- - **Shapes** — cubes, boxes (sharp or rounded), spheres, cylinders, cones, tori and planes built in, `lathe` for round shapes and `extrude` for flat ones, or any mesh you define as vertices and edges.
20
- - **Modeling** — smooth curves through a few points (`spline`), shapes swept along a 3D path (`sweep`: tails, horns, pipes, rails), surfaces through cross-sections (`loft`: bodies, bottles, hulls, heads), subdivision surfaces with creases (`subdivideMesh`: box modeling), shapes that melt together (`blobMesh`: creatures, clay; or any distance field with `sdfMesh`), rounded or cut edges on any mesh (`bevelMesh`), taper, twist, bend and bulge deformers, and mesh cleanup and checks (weld, clean, validate, fix winding).
19
+ - **Shapes** — cubes, boxes (sharp or rounded), spheres, cylinders, cones, tori and planes built in, `lathe` for round shapes (a point on the axis closes it to a tip) and `extrude` for flat ones, or any mesh you define as vertices, faces and edges.
20
+ - **Modeling** — smooth curves through a few points (`spline`), shapes swept along a 3D path (`sweep`: tails, horns, pipes, rails), surfaces through cross-sections (`loft`: bodies, bottles, hulls, heads), subdivision surfaces with creases (`subdivideMesh`: box modeling), shapes that melt together (`blobMesh`: creatures, clay; or any distance field with `sdfMesh`), rounded or cut edges on any mesh (`bevelMesh`), taper, twist, bend and bulge deformers, mesh cleanup and checks (weld, clean, validate, fix winding), and rays, inside tests and distances against meshes and scenes (`meshQuery`, `raycastScene`).
21
21
  - **Mesh helpers** — clone, transform, displace, merge and measure meshes; seeded random numbers and smooth noise (`fbm2`, `fbm3`, tileable) for rocks, terrain and procedural textures.
22
22
  - **Filled faces** — give an object a `material` to fill and shade its faces, flat or smooth.
23
23
  - **Lights** — ambient, directional and point lights, with colors and intensities, as many as you need (up to 8 at once besides ambient).
@@ -27,7 +27,7 @@ Trid gives you a scene, a camera, shapes, model loading and mouse/keyboard tools
27
27
  - **Two renderers** — the canvas 2D context by default, or `WebGLRenderer` for exact depth and big models, with the same scene code.
28
28
  - **OBJ files** — load models from a URL (with their `.mtl` colors and textures), or let users drop their own `.obj` files on the canvas.
29
29
  - **Export** — `scene.screenshot()` and `scene.record()` save images and videos of the canvas; `exportOBJ` / `exportModel` write meshes, objects or whole scenes as OBJ files with their MTL materials and textures.
30
- - **Camera** — position, yaw, pitch and field of view, with near-plane clipping.
30
+ - **Camera** — position, yaw, pitch and field of view (50° by default), with near-plane clipping.
31
31
  - **Tools** — orbit and fly cameras, object dragging, hover highlight, a move / rotate / scale gizmo (with a pivot of your choice), a view axes gizmo, a floor grid, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles, with the hit point and texture coordinates under the mouse. Writing your own is a plain object with a few methods.
32
32
  - **Pure math** — every transform and projection step is exported, plus 4×4 matrices and rotations, so you can use the pipeline piece by piece.
33
33
 
@@ -41,7 +41,7 @@ No bundler? Use the CDN with an import map, or copy the `trid/` folder next to y
41
41
 
42
42
  ```html
43
43
  <script type="importmap">
44
- { "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.3/trid/index.js" } }
44
+ { "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.6/trid/index.js" } }
45
45
  </script>
46
46
  ```
47
47
 
@@ -56,13 +56,22 @@ No bundler? Use the CDN with an import map, or copy the `trid/` folder next to y
56
56
  // main.js
57
57
  import { Scene, makeShape, OrbitCamera } from "trid3d";
58
58
 
59
- const scene = new Scene(document.getElementById("canvas"), { width: 800, height: 400 });
60
- const cube = scene.add({ mesh: makeShape("cube"), color: "#E9806E" }); // wireframe
61
- const ball = scene.add({ mesh: makeShape("sphere"), material: { fill: "#8ab4f8" } }); // filled and shaded
62
- ball.pos.x = 0.8;
59
+ const scene = new Scene(document.getElementById("canvas"), {
60
+ width: 800, height: 400,
61
+ camera: { z: -1.5 }, // a step back
62
+ });
63
+
64
+ // A cube with rounded edges, filled and smoothly shaded
65
+ const cube = scene.add({
66
+ mesh: makeShape("cube", { radius: 0.08 }),
67
+ material: { fill: "#E9806E", smooth: true },
68
+ });
63
69
 
64
70
  scene.use(new OrbitCamera()); // drag to orbit, scroll to zoom
65
- scene.start((dt) => (cube.rot.y += dt));
71
+ scene.start((dt) => {
72
+ cube.rot.x += dt * 0.5;
73
+ cube.rot.y += dt * 0.35;
74
+ });
66
75
  ```
67
76
 
68
77
  Without a bundler, serve the folder (`python3 -m http.server`) instead of opening the file directly: browsers block module imports from `file://` pages.
@@ -87,7 +96,7 @@ Or let users load their own: `scene.use(new ObjUploader())` accepts dropped `.ob
87
96
  | `trid/sceneGraph.js` | Groups: `children`, world matrices |
88
97
  | `trid/math/` | Transforms, camera directions, projection, matrices, rotations, random numbers and noise |
89
98
  | `trid/render/` | Renderers (Canvas 2D, WebGL), lights, effects, post effects, shapes, grids, mesh helpers, procedural textures, drawing, frame loop |
90
- | `trid/modeling/` | Modeling tools: curves, sweep, loft, subdivision, blobs, bevel, deformers, cleanup |
99
+ | `trid/modeling/` | Modeling tools: curves, sweep, loft, subdivision, blobs, bevel, deformers, cleanup, queries (rays, inside, distance) |
91
100
  | `trid/loaders/` | OBJ, MTL and texture loading |
92
101
  | `trid/export/` | Screenshots, video recording, OBJ and MTL export |
93
102
  | `trid/tools/` | Camera, object, gizmo and overlay tools |
@@ -113,10 +122,16 @@ See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.
113
122
  npm test
114
123
  ```
115
124
 
116
- Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes, mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, post effects, grids, and the WebGL renderer's GPU memory on a fake WebGL context.
125
+ Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes (lathe poles included), mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, mesh and scene queries, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, post effects, grids, the field of view, see-through backgrounds, and the WebGL renderer's GPU memory on a fake WebGL context.
117
126
 
118
127
  ## Upgrading
119
128
 
129
+ ### From 0.5 to 0.6
130
+
131
+ - **The cone's tip is a single point** (a pole), so the cone is closed: `isClosed` and `validateMesh` no longer report a hole there. It has fewer vertices than before (its tip ring of 32 is now 1), and its side faces are triangles. Any `lathe` profile point with `r: 0` works the same way.
132
+ - **The default field of view is 50°** (`DEFAULT_FOV`), instead of 90°. Scenes whose camera has no `fov` look about twice as close, like in most 3D tools. To keep the old view, give the camera `fov: Math.PI / 2`.
133
+ - **`background: null` is see-through**, like `"transparent"` (it used to come out black). `clear()` now empties the canvas before painting the background, so see-through and half see-through backgrounds work; opaque ones look the same.
134
+
120
135
  ### From 0.3 to 0.4
121
136
 
122
137
  - **Lights are a list**: `scene.lights`. A scene still starts with one directional light and an ambient level, and looks the same. `scene.light` and `scene.ambient` are now shortcuts for the first directional light's direction and the first ambient light's intensity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trid3d",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A small 3D library for the HTML canvas: wireframes, filled and smooth faces, textures and shader effects, drawn with Canvas 2D or WebGL. No dependencies.",
5
5
  "keywords": [
6
6
  "3d",
package/trid/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export interface Camera {
43
43
  yaw?: number;
44
44
  /** Tilt around X in radians, > 0 looks up (default 0) */
45
45
  pitch?: number;
46
- /** Vertical field of view in radians (default Math.PI / 2) */
46
+ /** Vertical field of view in radians (default DEFAULT_FOV: 50°) */
47
47
  fov?: number;
48
48
  /** Near plane distance; closer geometry is clipped (default 0.01) */
49
49
  near?: number;
@@ -192,8 +192,8 @@ export interface SceneOptions {
192
192
  width?: number;
193
193
  /** Sets canvas.height (keeps the current one if omitted) */
194
194
  height?: number;
195
- /** Clear color (default "#141414") */
196
- background?: string;
195
+ /** Clear color (default "#141414"); "transparent", a see-through rgba() or null: the page shows behind */
196
+ background?: string | null;
197
197
  /** Default wireframe color (default "#ffffff") */
198
198
  color?: string;
199
199
  /** Default wireframe width (default 3) */
@@ -221,7 +221,7 @@ export class Scene {
221
221
 
222
222
  canvas: HTMLCanvasElement;
223
223
  ctx: CanvasRenderingContext2D;
224
- background: string;
224
+ background: string | null;
225
225
  color: string;
226
226
  lineWidth: number;
227
227
  camera: Camera;
@@ -1426,8 +1426,8 @@ export function worldPerPixel(scene: Scene, depth: number): number;
1426
1426
  // Drawing and the frame loop
1427
1427
  // ---------------------------------------------------------------------------
1428
1428
 
1429
- /** Fills the whole canvas with a color. */
1430
- export function clear(ctx: CanvasRenderingContext2D, w: number, h: number, color: string): void;
1429
+ /** Clears the whole canvas, then fills it with a color ("transparent" or null: left see-through). */
1430
+ export function clear(ctx: CanvasRenderingContext2D, w: number, h: number, color?: string | null): void;
1431
1431
  /** Draws a square point centered at a screen position. */
1432
1432
  export function point(ctx: CanvasRenderingContext2D, p: Vec2, color: string, size?: number): void;
1433
1433
  /** Draws a line between two screen points. */
@@ -1467,7 +1467,7 @@ export function parseColor(ctx: CanvasRenderingContext2D, color: string): RGBA;
1467
1467
  // Math
1468
1468
  // ---------------------------------------------------------------------------
1469
1469
 
1470
- /** Default vertical field of view (90°). */
1470
+ /** Default vertical field of view: 50° (in radians). */
1471
1471
  export const DEFAULT_FOV: number;
1472
1472
  /** Default near plane distance. */
1473
1473
  export const DEFAULT_NEAR: number;
@@ -1951,3 +1951,99 @@ export interface BevelOptions {
1951
1951
  * Edges along an open border stay sharp.
1952
1952
  */
1953
1953
  export function bevelMesh(mesh: Mesh, opts?: BevelOptions): Mesh;
1954
+
1955
+ // Modeling: queries (rays, inside, distance)
1956
+
1957
+ /** Which faces a ray counts: "both" (default), "front" (hit from outside) or "back" (hit from inside). */
1958
+ export type RaySide = "both" | "front" | "back";
1959
+
1960
+ export interface RaycastOptions {
1961
+ /** Ignore hits farther than this (default Infinity) */
1962
+ maxDistance?: number;
1963
+ /** Which faces count (default "both") */
1964
+ side?: RaySide;
1965
+ }
1966
+
1967
+ /** Where a ray met a mesh, in the mesh's own space. */
1968
+ export interface MeshHit {
1969
+ point: Vec3;
1970
+ /** Unit normal of the face, pointing out of the mesh (whichever side the ray came from) */
1971
+ normal: Vec3;
1972
+ /** From the ray's origin */
1973
+ distance: number;
1974
+ /** Index in `mesh.faces` */
1975
+ face: number;
1976
+ /** true when the ray met the face from outside */
1977
+ front: boolean;
1978
+ /** The 3 vertex indices of the triangle hit (faces with more corners are fans from their first corner) */
1979
+ triangle: [number, number, number];
1980
+ /** Weights of those vertices at the point (they add up to 1) */
1981
+ barycentric: [number, number, number];
1982
+ }
1983
+
1984
+ /** The nearest point of a mesh's surface. */
1985
+ export type MeshClosest = Omit<MeshHit, "front">;
1986
+
1987
+ /** A mesh prepared for many questions (a snapshot: editing the mesh later doesn't change it). */
1988
+ export interface MeshQuery {
1989
+ /** Triangles in the query (faces split into fans) */
1990
+ readonly triangleCount: number;
1991
+ /** The mesh's box */
1992
+ readonly bounds: { min: Vec3; max: Vec3 };
1993
+ /** The first place a ray meets the surface, or null. `direction` can be any length but zero */
1994
+ raycast(origin: Vec3, direction: Vec3, opts?: RaycastOptions): MeshHit | null;
1995
+ /** Whether a point is inside (only meaningful for a closed mesh, see `isClosed`) */
1996
+ contains(p: Vec3): boolean;
1997
+ /** The nearest point of the surface, or null when nothing is within `maxDistance` */
1998
+ closest(p: Vec3, opts?: { maxDistance?: number }): MeshClosest | null;
1999
+ /** Distance to the surface (Infinity when nothing is within `maxDistance`) */
2000
+ distance(p: Vec3, maxDistance?: number): number;
2001
+ }
2002
+
2003
+ /** Prepares a mesh for many ray, inside and distance questions (builds a BVH once). */
2004
+ export function meshQuery(mesh: Mesh): MeshQuery;
2005
+
2006
+ /** One-off `meshQuery(mesh).raycast(...)`. For many rays on one mesh, keep a `meshQuery`. */
2007
+ export function raycastMesh(mesh: Mesh, origin: Vec3, direction: Vec3, opts?: RaycastOptions): MeshHit | null;
2008
+
2009
+ /** One-off `meshQuery(mesh).contains(p)`. */
2010
+ export function pointInMesh(mesh: Mesh, p: Vec3): boolean;
2011
+
2012
+ /** One-off `meshQuery(mesh).distance(p, maxDistance)`. */
2013
+ export function distanceToMesh(mesh: Mesh, p: Vec3, maxDistance?: number): number;
2014
+
2015
+ /** One-off `meshQuery(mesh).closest(p, opts)`. */
2016
+ export function closestPointOnMesh(mesh: Mesh, p: Vec3, opts?: { maxDistance?: number }): MeshClosest | null;
2017
+
2018
+ /** Whether a mesh is closed (watertight): every edge, matched by position, shared by exactly two faces. */
2019
+ export function isClosed(mesh: Mesh): boolean;
2020
+
2021
+ /** What `raycastScene` found: a `MeshHit` in world space, plus the object. */
2022
+ export interface SceneHit extends MeshHit {
2023
+ object: SceneObject;
2024
+ /** The object, then its groups up to the top: `[part, group, ..., root]` */
2025
+ path: SceneObject[];
2026
+ /** Where the hit is, in the mesh's own space (`point` is in the world) */
2027
+ local: Vec3;
2028
+ }
2029
+
2030
+ export interface RaycastSceneOptions extends RaycastOptions {
2031
+ /** Return false to skip an object */
2032
+ filter?: (obj: SceneObject) => boolean;
2033
+ /** Only these objects (and their children) */
2034
+ objects?: SceneObjectInput[];
2035
+ }
2036
+
2037
+ /**
2038
+ * The first object a ray meets, in world space (the 3D version of `pickHit`).
2039
+ * Hidden objects, `pickable: false` ones and objects without faces are skipped.
2040
+ */
2041
+ export function raycastScene(
2042
+ scene: Scene | SceneObjectInput[],
2043
+ origin: Vec3,
2044
+ direction: Vec3,
2045
+ opts?: RaycastSceneOptions,
2046
+ ): SceneHit | null;
2047
+
2048
+ /** Forgets the prepared query `raycastScene` keeps for a mesh (after editing it in place once `dirty` was cleared). */
2049
+ export function clearQueryCache(mesh: Mesh): void;
package/trid/index.js CHANGED
@@ -91,3 +91,6 @@ export { weldVertices, cleanMesh, validateMesh, fixWinding } from "./modeling/cl
91
91
  export { subdivideMesh } from "./modeling/subdivide.js";
92
92
  export { blobMesh, blobField, sdfMesh } from "./modeling/blobs.js";
93
93
  export { bevelMesh } from "./modeling/bevel.js";
94
+ export {
95
+ meshQuery, raycastMesh, pointInMesh, distanceToMesh, closestPointOnMesh, isClosed, raycastScene, clearQueryCache,
96
+ } from "./modeling/query.js";
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Default vertical field of view: 50°, a normal lens (like most 3D tools).
3
+ * Wider shows more but makes everything smaller; set `camera.fov` to change it.
4
+ */
5
+ export const DEFAULT_FOV = (50 * Math.PI) / 180;
6
+
1
7
  /**
2
8
  * Converts normalized device coordinates (-1 to +1)
3
9
  * into actual screen (pixel) coordinates.
@@ -36,11 +42,11 @@ export function screen(p, w, h) {
36
42
  * - larger z => appears smaller
37
43
  *
38
44
  * @param {{x:number, y:number, z:number}} p - 3D point (camera space)
39
- * @param {number} fov - Vertical field of view in radians (default: 90°)
45
+ * @param {number} fov - Vertical field of view in radians (default: `DEFAULT_FOV`, 50°)
40
46
  * @param {number} aspect - Canvas width / height (default: 1)
41
47
  * @returns {{x:number, y:number}} 2D projected point
42
48
  */
43
- export function project(p, fov = Math.PI / 2, aspect = 1) {
49
+ export function project(p, fov = DEFAULT_FOV, aspect = 1) {
44
50
  const f = 1 / Math.tan(fov / 2);
45
51
  return {
46
52
  x: (p.x * f) / (p.z * aspect),
@@ -1,4 +1,6 @@
1
- import { project, screen } from "./projection.js";
1
+ import { project, screen, DEFAULT_FOV } from "./projection.js";
2
+
3
+ export { DEFAULT_FOV };
2
4
 
3
5
  /**
4
6
  * Camera object. Every field is optional and falls back to its default.
@@ -11,14 +13,11 @@ import { project, screen } from "./projection.js";
11
13
  * @property {number} [z=0] - Position on Z
12
14
  * @property {number} [yaw=0] - Turn around Y in radians (> 0 turns right)
13
15
  * @property {number} [pitch=0] - Tilt around X in radians (> 0 looks up)
14
- * @property {number} [fov=Math.PI/2] - Vertical field of view in radians
16
+ * @property {number} [fov=DEFAULT_FOV] - Vertical field of view in radians (default 50°)
15
17
  * @property {number} [near=0.01] - Near plane distance; closer geometry is clipped
16
18
  * @property {number} [far=1000] - Far distance; farther geometry isn't drawn (WebGL renderer only)
17
19
  */
18
20
 
19
- /** Default vertical field of view (90°). */
20
- export const DEFAULT_FOV = Math.PI / 2;
21
-
22
21
  /** Default near plane distance. */
23
22
  export const DEFAULT_NEAR = 0.01;
24
23
 
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Questions about a mesh's surface, asked in code: where a ray first meets
3
+ * it, whether a point is inside it, how far a point is from it, and whether
4
+ * it's closed. Also the same ray question against a whole scene.
5
+ *
6
+ * const q = meshQuery(body); // prepared once (a BVH)
7
+ * const hit = q.raycast(from, { x: 0, y: 0, z: 1 });
8
+ * q.contains(p); q.distance(p); q.closest(p);
9
+ *
10
+ * A query is a snapshot: it copies the mesh's triangles when it's made, so
11
+ * editing the mesh afterwards doesn't change its answers. Make a new one.
12
+ *
13
+ * Faces are clockwise from outside (like the built-in shapes), so normals
14
+ * here point out of the mesh, whichever side a ray comes from.
15
+ */
16
+ import { invertMat4, determinantMat4, transformPoint, transformDirection, normalMatrix, transformNormal } from "../math/matrix.js";
17
+ import { worldObjects, ancestors } from "../sceneGraph.js";
18
+ import { isPickable } from "../tools/helpers.js";
19
+ import { samePlace } from "./cleanup.js";
20
+
21
+ /** Most triangles in a BVH leaf */
22
+ const LEAF = 4;
23
+ /** Bins per split when building the BVH (surface area heuristic) */
24
+ const BINS = 12;
25
+
26
+ const SIDES = ["both", "front", "back"];
27
+
28
+ /** Odd-looking directions, so rays rarely run exactly along edges (for `contains`) */
29
+ const PROBES = [
30
+ [0.5383, 0.6412, 0.5468], [-0.7071, 0.1934, 0.6797], [0.2311, -0.8417, -0.4878],
31
+ [-0.3122, -0.4519, 0.8357], [0.8829, -0.2711, 0.3833], [-0.5867, 0.7713, -0.2467],
32
+ [0.1471, 0.3378, -0.9296],
33
+ ].map(([x, y, z]) => {
34
+ const l = Math.hypot(x, y, z);
35
+ return [x / l, y / l, z / l];
36
+ });
37
+
38
+ const finite = (v) => !!v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
39
+
40
+ function checkPoint(p, what) {
41
+ if (!finite(p)) throw new Error(`${what} must be a point { x, y, z } of finite numbers`);
42
+ }
43
+
44
+ function unitDirection(d) {
45
+ const l = finite(d) ? Math.hypot(d.x, d.y, d.z) : NaN;
46
+ if (!(l > 0) || !Number.isFinite(l)) throw new Error("A ray needs a direction { x, y, z } that isn't zero");
47
+ return { x: d.x / l, y: d.y / l, z: d.z / l };
48
+ }
49
+
50
+ function checkSide(side) {
51
+ if (!SIDES.includes(side)) throw new Error(`Unknown side "${side}": use "both", "front" or "back"`);
52
+ }
53
+
54
+ /**
55
+ * Prepares a mesh for many questions: splits its faces into triangles (fans
56
+ * from each face's first corner, like `pickHit`) and builds a bounding volume
57
+ * hierarchy (boxes inside boxes) so each question only looks at the few
58
+ * triangles near it. Making it costs about as much as one slow question.
59
+ *
60
+ * @param {Object} mesh - A mesh with `vertices` and `faces`
61
+ * @returns {MeshQuery}
62
+ */
63
+ export function meshQuery(mesh) {
64
+ return new MeshQuery(mesh);
65
+ }
66
+
67
+ class MeshQuery {
68
+ constructor(mesh) {
69
+ const v = mesh?.vertices ?? [];
70
+ const corners = [], faceOf = [];
71
+ (mesh?.faces ?? []).forEach((f, fi) => {
72
+ if (!Array.isArray(f)) return;
73
+ for (let i = 1; i + 1 < f.length; i++) {
74
+ if (!finite(v[f[0]]) || !finite(v[f[i]]) || !finite(v[f[i + 1]])) continue;
75
+ corners.push(f[0], f[i], f[i + 1]);
76
+ faceOf.push(fi);
77
+ }
78
+ });
79
+ const n = faceOf.length;
80
+ const tris = new Float64Array(n * 9);
81
+ for (let t = 0; t < n; t++) {
82
+ for (let k = 0; k < 3; k++) {
83
+ const p = v[corners[t * 3 + k]];
84
+ tris[t * 9 + k * 3] = p.x;
85
+ tris[t * 9 + k * 3 + 1] = p.y;
86
+ tris[t * 9 + k * 3 + 2] = p.z;
87
+ }
88
+ }
89
+ /** Triangles, 9 numbers each (in BVH order after building) */
90
+ this._tris = tris;
91
+ this._corners = Int32Array.from(corners);
92
+ this._faceOf = Int32Array.from(faceOf);
93
+ /** Number of triangles */
94
+ this.triangleCount = n;
95
+ buildBVH(this);
96
+
97
+ const b = this._box;
98
+ this.bounds = n
99
+ ? { min: { x: b[0], y: b[1], z: b[2] }, max: { x: b[3], y: b[4], z: b[5] } }
100
+ : { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } };
101
+ const size = n ? Math.max(b[3] - b[0], b[4] - b[1], b[5] - b[2]) || 1 : 1;
102
+ // Tolerances relative to the mesh's size
103
+ this._minDist = size * 1e-9;
104
+ this._detEps = size * size * 1e-14;
105
+ this._edgeEps = 1e-7;
106
+ this._hit = { t: 0, tri: -1, u: 0, w: 0 };
107
+ this._near = new Float64Array(5);
108
+ }
109
+
110
+ /**
111
+ * The first place a ray meets the surface, or null.
112
+ *
113
+ * @param {Vec3} origin
114
+ * @param {Vec3} direction - Any length but zero
115
+ * @param {{maxDistance?:number, side?:"both"|"front"|"back"}} [opts]
116
+ * `side`: "front" only counts faces hit from outside, "back" from inside
117
+ * @returns {{point, normal, distance, face, front, triangle, barycentric} | null}
118
+ */
119
+ raycast(origin, direction, { maxDistance = Infinity, side = "both" } = {}) {
120
+ checkPoint(origin, "raycast: origin");
121
+ checkSide(side);
122
+ const d = unitDirection(direction);
123
+ if (!this._cast(origin, d, maxDistance, side)) return null;
124
+ const { t, tri, u, w } = this._hit;
125
+ return this._describe(tri, u, w, { x: origin.x + d.x * t, y: origin.y + d.y * t, z: origin.z + d.z * t }, t, d);
126
+ }
127
+
128
+ /**
129
+ * Whether a point is inside. Only meaningful for a closed mesh (see
130
+ * `isClosed`). Rays are sent in a few directions and their crossings
131
+ * counted; rays that pass too close to an edge are ignored.
132
+ *
133
+ * @param {Vec3} p
134
+ * @returns {boolean}
135
+ */
136
+ contains(p) {
137
+ checkPoint(p, "contains: the point");
138
+ const b = this._box;
139
+ if (!this.triangleCount || p.x < b[0] || p.y < b[1] || p.z < b[2] || p.x > b[3] || p.y > b[4] || p.z > b[5]) return false;
140
+ let inside = 0, outside = 0, unsureIn = 0, unsureOut = 0;
141
+ for (const dir of PROBES) {
142
+ const r = this._crossings(p, dir);
143
+ if (r.sure) (r.count % 2 ? inside++ : outside++);
144
+ else (r.count % 2 ? unsureIn++ : unsureOut++);
145
+ if (inside >= 2 || outside >= 2) break;
146
+ }
147
+ if (inside !== outside) return inside > outside;
148
+ return unsureIn > unsureOut;
149
+ }
150
+
151
+ /**
152
+ * The nearest point of the surface, or null when there's none within `maxDistance`.
153
+ *
154
+ * @param {Vec3} p
155
+ * @param {{maxDistance?:number}} [opts]
156
+ * @returns {{point, normal, distance, face, triangle, barycentric} | null}
157
+ */
158
+ closest(p, { maxDistance = Infinity } = {}) {
159
+ checkPoint(p, "closest: the point");
160
+ const found = this._nearest(p, maxDistance);
161
+ if (found < 0) return null;
162
+ const c = this._near;
163
+ const w1 = c[3], w2 = c[4];
164
+ const point = { x: c[0], y: c[1], z: c[2] };
165
+ return this._describe(found, w1, w2, point, Math.hypot(p.x - c[0], p.y - c[1], p.z - c[2]), null);
166
+ }
167
+
168
+ /**
169
+ * Distance from a point to the surface (Infinity when nothing is within `maxDistance`).
170
+ *
171
+ * @param {Vec3} p
172
+ * @param {number} [maxDistance=Infinity] - Stop looking past this (faster)
173
+ * @returns {number}
174
+ */
175
+ distance(p, maxDistance = Infinity) {
176
+ checkPoint(p, "distance: the point");
177
+ const found = this._nearest(p, maxDistance);
178
+ if (found < 0) return Infinity;
179
+ const c = this._near;
180
+ return Math.hypot(p.x - c[0], p.y - c[1], p.z - c[2]);
181
+ }
182
+
183
+ /** Unit normal of triangle `tri`, pointing out (clockwise faces). */
184
+ _normal(tri) {
185
+ const q = this._tris, i = tri * 9;
186
+ const e1x = q[i + 3] - q[i], e1y = q[i + 4] - q[i + 1], e1z = q[i + 5] - q[i + 2];
187
+ const e2x = q[i + 6] - q[i], e2y = q[i + 7] - q[i + 1], e2z = q[i + 8] - q[i + 2];
188
+ const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
189
+ const l = Math.hypot(nx, ny, nz) || 1;
190
+ return { x: nx / l, y: ny / l, z: nz / l };
191
+ }
192
+
193
+ _describe(tri, u, w, point, distance, dir) {
194
+ const normal = this._normal(tri);
195
+ const out = {
196
+ point,
197
+ normal,
198
+ distance,
199
+ face: this._faceOf[tri],
200
+ triangle: [this._corners[tri * 3], this._corners[tri * 3 + 1], this._corners[tri * 3 + 2]],
201
+ barycentric: [1 - u - w, u, w],
202
+ };
203
+ if (dir) out.front = dir.x * normal.x + dir.y * normal.y + dir.z * normal.z < 0;
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * First hit of the ray o + t·d (d any length), 0 < t < maxT in units of d.
209
+ * Fills `this._hit` and returns true when something is hit.
210
+ */
211
+ _cast(o, d, maxT, side) {
212
+ if (!this.triangleCount) return false;
213
+ const dl = Math.hypot(d.x, d.y, d.z);
214
+ const minT = this._minDist / dl, detEps = this._detEps * dl;
215
+ const sign = side === "front" ? 1 : side === "back" ? -1 : 0;
216
+ const ix = 1 / d.x, iy = 1 / d.y, iz = 1 / d.z;
217
+ const box = this._box, first = this._first, count = this._count, tris = this._tris, stack = this._stack;
218
+ const hit = this._hit;
219
+ let best = maxT, found = -1, bu = 0, bw = 0;
220
+ let top = 0;
221
+ if (enter(box, 0, o, ix, iy, iz, best) === Infinity) return false;
222
+ stack[top++] = 0;
223
+ while (top) {
224
+ const node = stack[--top];
225
+ const n = count[node];
226
+ if (n > 0) {
227
+ for (let k = first[node], end = first[node] + n; k < end; k++) {
228
+ const i = k * 9;
229
+ const e1x = tris[i + 3] - tris[i], e1y = tris[i + 4] - tris[i + 1], e1z = tris[i + 5] - tris[i + 2];
230
+ const e2x = tris[i + 6] - tris[i], e2y = tris[i + 7] - tris[i + 1], e2z = tris[i + 8] - tris[i + 2];
231
+ const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
232
+ const det = e1x * px + e1y * py + e1z * pz;
233
+ // det > 0: the ray meets the face from outside
234
+ if (Math.abs(det) <= detEps || det * sign < 0) continue;
235
+ const inv = 1 / det;
236
+ const sx = o.x - tris[i], sy = o.y - tris[i + 1], sz = o.z - tris[i + 2];
237
+ const u = (sx * px + sy * py + sz * pz) * inv;
238
+ if (u < 0 || u > 1) continue;
239
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
240
+ const w = (d.x * qx + d.y * qy + d.z * qz) * inv;
241
+ if (w < 0 || u + w > 1) continue;
242
+ const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
243
+ if (t > minT && t < best) {
244
+ best = t;
245
+ found = k;
246
+ bu = u;
247
+ bw = w;
248
+ }
249
+ }
250
+ } else {
251
+ // Visit the nearer child first; skip boxes past the best hit
252
+ const a = first[node], b = a + 1;
253
+ const ta = enter(box, a * 6, o, ix, iy, iz, best), tb = enter(box, b * 6, o, ix, iy, iz, best);
254
+ if (ta <= tb) {
255
+ if (tb !== Infinity) stack[top++] = b;
256
+ if (ta !== Infinity) stack[top++] = a;
257
+ } else {
258
+ if (ta !== Infinity) stack[top++] = a;
259
+ stack[top++] = b;
260
+ }
261
+ }
262
+ }
263
+ if (found < 0) return false;
264
+ hit.t = best;
265
+ hit.tri = found;
266
+ hit.u = bu;
267
+ hit.w = bw;
268
+ return true;
269
+ }
270
+
271
+ /** How many faces a ray from p crosses, and whether every crossing was clear of edges. */
272
+ _crossings(p, [dx, dy, dz]) {
273
+ const tris = this._tris, box = this._box, first = this._first, count = this._count, stack = this._stack;
274
+ const ix = 1 / dx, iy = 1 / dy, iz = 1 / dz, minT = this._minDist, eps = this._edgeEps, detEps = this._detEps;
275
+ let hits = 0, sure = true, top = 0;
276
+ stack[top++] = 0;
277
+ while (top) {
278
+ const node = stack[--top];
279
+ if (enter(box, node * 6, p, ix, iy, iz, Infinity) === Infinity) continue;
280
+ const n = count[node];
281
+ if (n === 0) {
282
+ stack[top++] = first[node];
283
+ stack[top++] = first[node] + 1;
284
+ continue;
285
+ }
286
+ for (let k = first[node], end = first[node] + n; k < end; k++) {
287
+ const i = k * 9;
288
+ const e1x = tris[i + 3] - tris[i], e1y = tris[i + 4] - tris[i + 1], e1z = tris[i + 5] - tris[i + 2];
289
+ const e2x = tris[i + 6] - tris[i], e2y = tris[i + 7] - tris[i + 1], e2z = tris[i + 8] - tris[i + 2];
290
+ const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
291
+ const det = e1x * px + e1y * py + e1z * pz;
292
+ if (Math.abs(det) <= detEps) continue;
293
+ const inv = 1 / det;
294
+ const sx = p.x - tris[i], sy = p.y - tris[i + 1], sz = p.z - tris[i + 2];
295
+ const u = (sx * px + sy * py + sz * pz) * inv;
296
+ if (u < -eps || u > 1 + eps) continue;
297
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
298
+ const w = (dx * qx + dy * qy + dz * qz) * inv;
299
+ if (w < -eps || u + w > 1 + eps) continue;
300
+ const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
301
+ if (t <= -minT) continue;
302
+ // Near an edge or the start point: this ray can't be trusted
303
+ if (u < eps || w < eps || u + w > 1 - eps || t <= minT) sure = false;
304
+ if (t > minT && u >= 0 && w >= 0 && u + w <= 1) hits++;
305
+ }
306
+ }
307
+ return { count: hits, sure };
308
+ }
309
+
310
+ /** Nearest triangle to p within maxDist (its closest point in `this._near`), or -1. */
311
+ _nearest(p, maxDist) {
312
+ if (!this.triangleCount) return -1;
313
+ const box = this._box, first = this._first, count = this._count, tris = this._tris, stack = this._stack;
314
+ const near = this._near, tmp = new Float64Array(5);
315
+ let best2 = maxDist === Infinity ? Infinity : maxDist * maxDist, found = -1, top = 0;
316
+ if (boxDist2(box, 0, p) > best2) return -1;
317
+ stack[top++] = 0;
318
+ while (top) {
319
+ const node = stack[--top];
320
+ if (boxDist2(box, node * 6, p) > best2) continue;
321
+ const n = count[node];
322
+ if (n > 0) {
323
+ for (let k = first[node], end = first[node] + n; k < end; k++) {
324
+ const d2 = closestOnTriangle(tris, k * 9, p, tmp);
325
+ if (d2 <= best2) {
326
+ best2 = d2;
327
+ found = k;
328
+ near.set(tmp);
329
+ }
330
+ }
331
+ } else {
332
+ const a = first[node], b = a + 1;
333
+ const da = boxDist2(box, a * 6, p), db = boxDist2(box, b * 6, p);
334
+ // Push the farther first, so the nearer is looked at first
335
+ if (da <= db) {
336
+ if (db <= best2) stack[top++] = b;
337
+ if (da <= best2) stack[top++] = a;
338
+ } else {
339
+ if (da <= best2) stack[top++] = a;
340
+ if (db <= best2) stack[top++] = b;
341
+ }
342
+ }
343
+ }
344
+ return found;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Builds the BVH on `q`, sorting its triangles so each leaf's are together.
350
+ * Nodes: `_box` (6 numbers each), `_count` (> 0 for a leaf: its triangles
351
+ * from `_first`; 0 for an inner node: its children are `_first` and `_first + 1`).
352
+ */
353
+ function buildBVH(q) {
354
+ const n = q.triangleCount, src = q._tris;
355
+ const triBox = new Float64Array(n * 6), cen = new Float64Array(n * 3);
356
+ for (let t = 0; t < n; t++) {
357
+ for (let a = 0; a < 3; a++) {
358
+ const x = src[t * 9 + a], y = src[t * 9 + 3 + a], z = src[t * 9 + 6 + a];
359
+ const lo = Math.min(x, y, z), hi = Math.max(x, y, z);
360
+ triBox[t * 6 + a] = lo;
361
+ triBox[t * 6 + 3 + a] = hi;
362
+ cen[t * 3 + a] = (lo + hi) / 2;
363
+ }
364
+ }
365
+ const order = new Int32Array(n);
366
+ for (let t = 0; t < n; t++) order[t] = t;
367
+
368
+ const maxNodes = Math.max(1, 2 * n - 1);
369
+ const box = new Float64Array(maxNodes * 6), first = new Int32Array(maxNodes), count = new Int32Array(maxNodes);
370
+ let nodes = 1, depth = 1;
371
+ const binCount = new Int32Array(BINS), binBox = new Float64Array(BINS * 6);
372
+ const rightArea = new Float64Array(BINS), rightCount = new Int32Array(BINS);
373
+
374
+ const work = [[0, 0, n, 1]];
375
+ while (work.length) {
376
+ const [node, start, end, level] = work.pop();
377
+ depth = Math.max(depth, level);
378
+ // Node box and centroid box
379
+ const k = node * 6;
380
+ box.fill(Infinity, k, k + 3);
381
+ box.fill(-Infinity, k + 3, k + 6);
382
+ let cminx = Infinity, cminy = Infinity, cminz = Infinity, cmaxx = -Infinity, cmaxy = -Infinity, cmaxz = -Infinity;
383
+ for (let i = start; i < end; i++) {
384
+ const t = order[i];
385
+ for (let a = 0; a < 3; a++) {
386
+ if (triBox[t * 6 + a] < box[k + a]) box[k + a] = triBox[t * 6 + a];
387
+ if (triBox[t * 6 + 3 + a] > box[k + 3 + a]) box[k + 3 + a] = triBox[t * 6 + 3 + a];
388
+ }
389
+ const cx = cen[t * 3], cy = cen[t * 3 + 1], cz = cen[t * 3 + 2];
390
+ if (cx < cminx) cminx = cx;
391
+ if (cy < cminy) cminy = cy;
392
+ if (cz < cminz) cminz = cz;
393
+ if (cx > cmaxx) cmaxx = cx;
394
+ if (cy > cmaxy) cmaxy = cy;
395
+ if (cz > cmaxz) cmaxz = cz;
396
+ }
397
+ const size = end - start;
398
+ if (size <= LEAF) {
399
+ first[node] = start;
400
+ count[node] = size;
401
+ continue;
402
+ }
403
+
404
+ // Split along the longest side of the centroids' box
405
+ const ex = cmaxx - cminx, ey = cmaxy - cminy, ez = cmaxz - cminz;
406
+ const axis = ex >= ey && ex >= ez ? 0 : ey >= ez ? 1 : 2;
407
+ const lo = axis === 0 ? cminx : axis === 1 ? cminy : cminz;
408
+ const extent = axis === 0 ? ex : axis === 1 ? ey : ez;
409
+ let mid = -1;
410
+ if (extent > 0) {
411
+ // Surface area heuristic over BINS bins
412
+ const scale = BINS / extent;
413
+ const binOf = (t) => Math.min(BINS - 1, Math.floor((cen[t * 3 + axis] - lo) * scale));
414
+ binCount.fill(0);
415
+ for (let b = 0; b < BINS; b++) {
416
+ binBox.fill(Infinity, b * 6, b * 6 + 3);
417
+ binBox.fill(-Infinity, b * 6 + 3, b * 6 + 6);
418
+ }
419
+ for (let i = start; i < end; i++) {
420
+ const t = order[i], b = binOf(t);
421
+ binCount[b]++;
422
+ for (let a = 0; a < 3; a++) {
423
+ if (triBox[t * 6 + a] < binBox[b * 6 + a]) binBox[b * 6 + a] = triBox[t * 6 + a];
424
+ if (triBox[t * 6 + 3 + a] > binBox[b * 6 + 3 + a]) binBox[b * 6 + 3 + a] = triBox[t * 6 + 3 + a];
425
+ }
426
+ }
427
+ // Areas from the right, then sweep from the left
428
+ const acc = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
429
+ let c = 0;
430
+ for (let b = BINS - 1; b > 0; b--) {
431
+ grow(acc, binBox, b * 6);
432
+ c += binCount[b];
433
+ rightArea[b] = c ? area(acc) : 0;
434
+ rightCount[b] = c;
435
+ }
436
+ acc.splice(0, 6, Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity);
437
+ c = 0;
438
+ let bestCost = Infinity, bestBin = -1;
439
+ for (let b = 1; b < BINS; b++) {
440
+ grow(acc, binBox, (b - 1) * 6);
441
+ c += binCount[b - 1];
442
+ if (!c || !rightCount[b]) continue;
443
+ const cost = area(acc) * c + rightArea[b] * rightCount[b];
444
+ if (cost < bestCost) {
445
+ bestCost = cost;
446
+ bestBin = b;
447
+ }
448
+ }
449
+ if (bestBin > 0) {
450
+ // Partition: bins below bestBin to the left
451
+ let i = start, j = end - 1;
452
+ while (i <= j) {
453
+ if (binOf(order[i]) < bestBin) i++;
454
+ else {
455
+ const s = order[i];
456
+ order[i] = order[j];
457
+ order[j--] = s;
458
+ }
459
+ }
460
+ mid = i;
461
+ }
462
+ }
463
+ // All centroids at one place, or no useful split: cut the list in half
464
+ if (mid <= start || mid >= end) mid = (start + end) >> 1;
465
+
466
+ const left = nodes;
467
+ nodes += 2;
468
+ first[node] = left;
469
+ count[node] = 0;
470
+ work.push([left + 1, mid, end, level + 1], [left, start, mid, level + 1]);
471
+ }
472
+
473
+ // Triangles in leaf order, so leaves read them in a row
474
+ const tris = new Float64Array(n * 9), corners = new Int32Array(n * 3), faceOf = new Int32Array(n);
475
+ for (let i = 0; i < n; i++) {
476
+ const t = order[i];
477
+ tris.set(src.subarray(t * 9, t * 9 + 9), i * 9);
478
+ corners.set(q._corners.subarray(t * 3, t * 3 + 3), i * 3);
479
+ faceOf[i] = q._faceOf[t];
480
+ }
481
+ q._tris = tris;
482
+ q._corners = corners;
483
+ q._faceOf = faceOf;
484
+ q._box = box.subarray(0, nodes * 6);
485
+ q._first = first.subarray(0, nodes);
486
+ q._count = count.subarray(0, nodes);
487
+ // An empty mesh: one empty leaf whose box nothing enters
488
+ if (!n) q._box.set([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]);
489
+ q._stack = new Int32Array(depth * 2 + 2);
490
+ }
491
+
492
+ function grow(acc, b, k) {
493
+ for (let a = 0; a < 3; a++) {
494
+ if (b[k + a] < acc[a]) acc[a] = b[k + a];
495
+ if (b[k + 3 + a] > acc[3 + a]) acc[3 + a] = b[k + 3 + a];
496
+ }
497
+ }
498
+
499
+ function area(b) {
500
+ const x = b[3] - b[0], y = b[4] - b[1], z = b[5] - b[2];
501
+ return x * y + y * z + z * x;
502
+ }
503
+
504
+ /** Where the ray o + t·(1/inv) enters box k, within [0, far]; Infinity if it doesn't. */
505
+ function enter(box, k, o, ix, iy, iz, far) {
506
+ let t0 = 0, t1 = far;
507
+ const lx = box[k], ly = box[k + 1], lz = box[k + 2], hx = box[k + 3], hy = box[k + 4], hz = box[k + 5];
508
+ if (ix === Infinity || ix === -Infinity) {
509
+ if (o.x < lx || o.x > hx) return Infinity;
510
+ } else {
511
+ let a = (lx - o.x) * ix, b = (hx - o.x) * ix;
512
+ if (a > b) [a, b] = [b, a];
513
+ if (a > t0) t0 = a;
514
+ if (b < t1) t1 = b;
515
+ }
516
+ if (iy === Infinity || iy === -Infinity) {
517
+ if (o.y < ly || o.y > hy) return Infinity;
518
+ } else {
519
+ let a = (ly - o.y) * iy, b = (hy - o.y) * iy;
520
+ if (a > b) [a, b] = [b, a];
521
+ if (a > t0) t0 = a;
522
+ if (b < t1) t1 = b;
523
+ }
524
+ if (iz === Infinity || iz === -Infinity) {
525
+ if (o.z < lz || o.z > hz) return Infinity;
526
+ } else {
527
+ let a = (lz - o.z) * iz, b = (hz - o.z) * iz;
528
+ if (a > b) [a, b] = [b, a];
529
+ if (a > t0) t0 = a;
530
+ if (b < t1) t1 = b;
531
+ }
532
+ // A little slack, so rays along a box's face aren't lost to rounding
533
+ return t0 <= t1 * (1 + 1e-12) + 1e-12 ? t0 : Infinity;
534
+ }
535
+
536
+ /** Squared distance from p to box k (0 inside). */
537
+ function boxDist2(box, k, p) {
538
+ const dx = Math.max(box[k] - p.x, 0, p.x - box[k + 3]);
539
+ const dy = Math.max(box[k + 1] - p.y, 0, p.y - box[k + 4]);
540
+ const dz = Math.max(box[k + 2] - p.z, 0, p.z - box[k + 5]);
541
+ return dx * dx + dy * dy + dz * dz;
542
+ }
543
+
544
+ /**
545
+ * Closest point on the triangle at `i` to p (Ericson's method): writes
546
+ * x, y, z and the weights of the 2nd and 3rd corners into `out`, returns
547
+ * the squared distance.
548
+ */
549
+ function closestOnTriangle(q, i, p, out) {
550
+ const ax = q[i], ay = q[i + 1], az = q[i + 2];
551
+ const abx = q[i + 3] - ax, aby = q[i + 4] - ay, abz = q[i + 5] - az;
552
+ const acx = q[i + 6] - ax, acy = q[i + 7] - ay, acz = q[i + 8] - az;
553
+ const apx = p.x - ax, apy = p.y - ay, apz = p.z - az;
554
+ const d1 = abx * apx + aby * apy + abz * apz, d2 = acx * apx + acy * apy + acz * apz;
555
+ let v, w;
556
+ if (d1 <= 0 && d2 <= 0) {
557
+ v = 0; w = 0;
558
+ } else {
559
+ const bpx = p.x - q[i + 3], bpy = p.y - q[i + 4], bpz = p.z - q[i + 5];
560
+ const d3 = abx * bpx + aby * bpy + abz * bpz, d4 = acx * bpx + acy * bpy + acz * bpz;
561
+ const cpx = p.x - q[i + 6], cpy = p.y - q[i + 7], cpz = p.z - q[i + 8];
562
+ const d5 = abx * cpx + aby * cpy + abz * cpz, d6 = acx * cpx + acy * cpy + acz * cpz;
563
+ const vc = d1 * d4 - d3 * d2, vb = d5 * d2 - d1 * d6, va = d3 * d6 - d5 * d4;
564
+ if (d3 >= 0 && d4 <= d3) {
565
+ v = 1; w = 0;
566
+ } else if (d6 >= 0 && d5 <= d6) {
567
+ v = 0; w = 1;
568
+ } else if (vc <= 0 && d1 >= 0 && d3 <= 0) {
569
+ v = d1 / (d1 - d3); w = 0;
570
+ } else if (vb <= 0 && d2 >= 0 && d6 <= 0) {
571
+ v = 0; w = d2 / (d2 - d6);
572
+ } else if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
573
+ w = (d4 - d3) / (d4 - d3 + (d5 - d6)); v = 1 - w;
574
+ } else {
575
+ const den = 1 / (va + vb + vc);
576
+ v = vb * den; w = vc * den;
577
+ }
578
+ }
579
+ const x = ax + abx * v + acx * w, y = ay + aby * v + acy * w, z = az + abz * v + acz * w;
580
+ out[0] = x; out[1] = y; out[2] = z; out[3] = v; out[4] = w;
581
+ return (p.x - x) ** 2 + (p.y - y) ** 2 + (p.z - z) ** 2;
582
+ }
583
+
584
+ // One-off questions: each builds a query and throws it away. For many
585
+ // questions on one mesh, use meshQuery.
586
+
587
+ /** The first place a ray meets a mesh, or null. Same as `meshQuery(mesh).raycast(...)`. */
588
+ export function raycastMesh(mesh, origin, direction, opts) {
589
+ return meshQuery(mesh).raycast(origin, direction, opts);
590
+ }
591
+
592
+ /** Whether a point is inside a closed mesh. Same as `meshQuery(mesh).contains(p)`. */
593
+ export function pointInMesh(mesh, p) {
594
+ return meshQuery(mesh).contains(p);
595
+ }
596
+
597
+ /** Distance from a point to a mesh's surface. Same as `meshQuery(mesh).distance(p, maxDistance)`. */
598
+ export function distanceToMesh(mesh, p, maxDistance) {
599
+ return meshQuery(mesh).distance(p, maxDistance);
600
+ }
601
+
602
+ /** The nearest point of a mesh's surface. Same as `meshQuery(mesh).closest(p, opts)`. */
603
+ export function closestPointOnMesh(mesh, p, opts) {
604
+ return meshQuery(mesh).closest(p, opts);
605
+ }
606
+
607
+ /**
608
+ * Whether a mesh is closed (watertight): every edge, matched by vertex
609
+ * position (texture seams split vertices), is shared by exactly two faces.
610
+ * Only a closed mesh has an inside.
611
+ *
612
+ * @param {Object} mesh
613
+ * @returns {boolean}
614
+ */
615
+ export function isClosed(mesh) {
616
+ const v = mesh?.vertices ?? [];
617
+ if (!mesh?.faces?.length || !v.length) return false;
618
+ let size = 0;
619
+ for (const p of v) if (finite(p)) size = Math.max(size, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
620
+ const same = samePlace(v, (size || 1) * 1e-7);
621
+ const edges = new Map();
622
+ for (const f of mesh.faces) {
623
+ if (!Array.isArray(f)) return false;
624
+ for (let i = 0; i < f.length; i++) {
625
+ const a = same[f[i]], b = same[f[(i + 1) % f.length]];
626
+ if (a === undefined || b === undefined) return false;
627
+ if (a === b) continue; // a collapsed edge (a sphere's poles)
628
+ const key = a < b ? `${a},${b}` : `${b},${a}`;
629
+ edges.set(key, (edges.get(key) ?? 0) + 1);
630
+ }
631
+ }
632
+ if (!edges.size) return false;
633
+ for (const n of edges.values()) if (n !== 2) return false;
634
+ return true;
635
+ }
636
+
637
+ // Scene rays
638
+
639
+ /** Prepared queries for meshes in scenes, rebuilt when a mesh changes. */
640
+ const cache = new WeakMap();
641
+
642
+ function cachedQuery(mesh) {
643
+ const c = cache.get(mesh);
644
+ if (c && !mesh.dirty && c.vertices === mesh.vertices && c.faces === mesh.faces
645
+ && c.vertexCount === mesh.vertices.length && c.faceCount === mesh.faces.length) return c.query;
646
+ const query = meshQuery(mesh);
647
+ cache.set(mesh, { query, vertices: mesh.vertices, faces: mesh.faces, vertexCount: mesh.vertices.length, faceCount: mesh.faces.length });
648
+ return query;
649
+ }
650
+
651
+ /**
652
+ * Forgets the prepared query `raycastScene` keeps for a mesh, so the next
653
+ * ray rebuilds it. Needed only after changing a mesh's vertices in place
654
+ * once a renderer has already cleared its `dirty` flag.
655
+ *
656
+ * @param {Object} mesh
657
+ */
658
+ export function clearQueryCache(mesh) {
659
+ if (mesh) cache.delete(mesh);
660
+ }
661
+
662
+ /**
663
+ * The first object a ray meets in a scene, in world space: the 3D version
664
+ * of `pickHit`. Each object's position, rotation, scale and groups are
665
+ * taken into account. Hidden objects, objects with `pickable: false` and
666
+ * objects without faces are skipped.
667
+ *
668
+ * Each mesh's query is prepared once and kept until the mesh changes
669
+ * (new arrays, a different count, or `mesh.dirty`).
670
+ *
671
+ * @param {Object} scene - A Scene, or a list of objects
672
+ * @param {Vec3} origin - In the world
673
+ * @param {Vec3} direction - In the world, any length but zero
674
+ * @param {Object} [opts]
675
+ * @param {(obj:Object) => boolean} [opts.filter] - Return false to skip an object
676
+ * @param {number} [opts.maxDistance=Infinity]
677
+ * @param {"both"|"front"|"back"} [opts.side="both"]
678
+ * @param {Object[]} [opts.objects] - Only these objects (and their children)
679
+ * @returns {Object|null} The hit: object, path, point, local, normal, distance, face, front, triangle, barycentric
680
+ */
681
+ export function raycastScene(scene, origin, direction, { filter = () => true, maxDistance = Infinity, side = "both", objects } = {}) {
682
+ checkPoint(origin, "raycastScene: origin");
683
+ checkSide(side);
684
+ const d = unitDirection(direction);
685
+ let best = maxDistance, hit = null;
686
+ for (const object of worldObjects(objects ?? scene)) {
687
+ const mesh = object.mesh;
688
+ if (!mesh?.faces?.length || !Array.isArray(mesh.vertices) || !isPickable(object) || !filter(object)) continue;
689
+ const world = object.world;
690
+ if (!(Math.abs(determinantMat4(world)) > 1e-18)) continue; // scaled to nothing
691
+ const inv = invertMat4(world);
692
+ // The ray in the mesh's space; t stays the world distance since d is unit length
693
+ const lo = transformPoint(inv, origin), ld = transformDirection(inv, d);
694
+ const q = cachedQuery(mesh);
695
+ if (!q._cast(lo, ld, best, side)) continue;
696
+ const { t, tri, u, w } = q._hit;
697
+ best = t;
698
+ hit = { object, world, q, tri, u, w, t, lo, ld };
699
+ }
700
+ if (!hit) return null;
701
+ const { object, world, q, tri, u, w, t, lo, ld } = hit;
702
+ const local = { x: lo.x + ld.x * t, y: lo.y + ld.y * t, z: lo.z + ld.z * t };
703
+ const out = q._describe(tri, u, w, { x: origin.x + d.x * t, y: origin.y + d.y * t, z: origin.z + d.z * t }, t, ld);
704
+ out.normal = transformNormal(normalMatrix(world), out.normal);
705
+ return { object, path: ancestors(object), local, ...out };
706
+ }
@@ -125,7 +125,7 @@ export class CanvasRenderer {
125
125
  // For effects
126
126
  const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
127
127
  const time = (performance.now() - this._start) / 1000;
128
- const background = this._rgba(ctx, scene.background);
128
+ const background = this._rgba(ctx, scene.background ?? "transparent");
129
129
 
130
130
  for (const obj of drawn) {
131
131
  const mats = materialsOf(obj);
@@ -2,19 +2,29 @@ import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } f
2
2
  import { transformPoint } from "../math/matrix.js";
3
3
 
4
4
  /**
5
- * Clears the entire canvas with a solid background color.
5
+ * Clears the entire canvas, then paints it with a background color.
6
6
  * This should usually be called once per frame before drawing.
7
7
  *
8
+ * A see-through color (`"transparent"`, or `rgba(...)` with an alpha below 1),
9
+ * or no color (`null`), lets the page behind the canvas show through.
10
+ *
8
11
  * @param {CanvasRenderingContext2D} ctx - Canvas 2D context
9
12
  * @param {number} w - Canvas width
10
13
  * @param {number} h - Canvas height
11
- * @param {string} color - Background color (CSS color)
14
+ * @param {string|null} [color] - Background color (CSS color); none: left see-through
12
15
  */
13
16
  export function clear(ctx, w, h, color) {
17
+ ctx.clearRect(0, 0, w, h);
18
+ if (!isVisible(color)) return;
14
19
  ctx.fillStyle = color;
15
20
  ctx.fillRect(0, 0, w, h);
16
21
  }
17
22
 
23
+ /** Whether a background color paints anything (not null, empty or "transparent"). */
24
+ function isVisible(color) {
25
+ return color != null && color !== "" && String(color).trim().toLowerCase() !== "transparent";
26
+ }
27
+
18
28
  /**
19
29
  * Draws a square point centered at a given 2D position.
20
30
  * Useful for debugging or drawing vertices.
@@ -91,7 +91,7 @@ export function runCanvasPost(scene, list, start) {
91
91
  canvas,
92
92
  rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
93
93
  };
94
- frame.background = frame.rgba(scene.background ?? "#000000");
94
+ frame.background = frame.rgba(scene.background ?? "transparent");
95
95
  for (const e of effects) {
96
96
  ctx.save();
97
97
  try {
@@ -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. */
@@ -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