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 +29 -14
- package/package.json +1 -1
- package/trid/index.d.ts +103 -7
- package/trid/index.js +3 -0
- package/trid/math/projection.js +8 -2
- package/trid/math/transform.js +4 -5
- package/trid/modeling/query.js +706 -0
- package/trid/render/canvasRenderer.js +1 -1
- package/trid/render/draw.js +12 -2
- package/trid/render/post.js +1 -1
- package/trid/render/shapes.js +42 -23
- package/trid/render/webglRenderer.js +1 -1
- package/trid/scene.js +1 -1
package/README.md
CHANGED
|
@@ -4,20 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Trid</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center">
|
|
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,
|
|
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,
|
|
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.
|
|
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"), {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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) =>
|
|
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
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
|
|
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
|
-
/**
|
|
1430
|
-
export function clear(ctx: CanvasRenderingContext2D, w: number, h: number, color
|
|
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 (
|
|
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";
|
package/trid/math/projection.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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),
|
package/trid/math/transform.js
CHANGED
|
@@ -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=
|
|
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);
|
package/trid/render/draw.js
CHANGED
|
@@ -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
|
|
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.
|
package/trid/render/post.js
CHANGED
|
@@ -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 ?? "
|
|
94
|
+
frame.background = frame.rgba(scene.background ?? "transparent");
|
|
95
95
|
for (const e of effects) {
|
|
96
96
|
ctx.save();
|
|
97
97
|
try {
|
package/trid/render/shapes.js
CHANGED
|
@@ -249,6 +249,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
|
|
|
249
249
|
return { vertices, edges, faces, uvs, faceUVs };
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** Profile points closer to the axis than this are poles (one vertex). */
|
|
253
|
+
const POLE = 1e-9;
|
|
254
|
+
|
|
252
255
|
/**
|
|
253
256
|
* Spins a profile around the Y axis: vases, bottles, cups, columns, chess
|
|
254
257
|
* pieces. Cylinders, cones and tori are built with it.
|
|
@@ -256,6 +259,9 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
|
|
|
256
259
|
* Walk the profile so the outside is on its right in the (r, y) plane:
|
|
257
260
|
* bottom to top for a vase. Faces are clockwise as seen from outside.
|
|
258
261
|
*
|
|
262
|
+
* A point on the axis (`r: 0`) is a pole: one vertex, with triangles around
|
|
263
|
+
* it, like a cone's tip or the closed end of a spinning top.
|
|
264
|
+
*
|
|
259
265
|
* Texture coordinates: u goes around, v follows the profile's length.
|
|
260
266
|
* Caps map the texture as a disc. Edges are the profile's rings plus one
|
|
261
267
|
* line along the profile per segment.
|
|
@@ -292,52 +298,67 @@ export function lathe(profile, { segments = 32, capBottom = false, capTop = fals
|
|
|
292
298
|
}
|
|
293
299
|
const total = lengths[lengths.length - 1] || 1;
|
|
294
300
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
// One ring of vertices per profile point, or a single vertex for a pole (on the axis)
|
|
302
|
+
const pole = profile.map((p) => !(Math.abs(p.r) > POLE));
|
|
303
|
+
const start = [];
|
|
304
|
+
for (const [r, p] of profile.entries()) {
|
|
305
|
+
start.push(vertices.length);
|
|
306
|
+
if (pole[r]) vertices.push({ x: 0, y: p.y, z: 0 });
|
|
307
|
+
else {
|
|
308
|
+
for (let s = 0; s < segments; s++) {
|
|
309
|
+
const t = (s / segments) * Math.PI * 2;
|
|
310
|
+
vertices.push({ x: Math.cos(t) * p.r, y: p.y, z: Math.sin(t) * p.r });
|
|
311
|
+
}
|
|
299
312
|
}
|
|
300
313
|
}
|
|
314
|
+
const at = (r, s) => (pole[r] ? start[r] : start[r] + (s % segments));
|
|
301
315
|
|
|
302
316
|
const rows = pts.length - 1;
|
|
303
317
|
for (let r = 0; r < rows; r++) {
|
|
304
|
-
const
|
|
305
|
-
|
|
318
|
+
const next = (r + 1) % profile.length;
|
|
319
|
+
if (pole[r] && pole[next]) continue; // along the axis: nothing to draw
|
|
306
320
|
for (let s = 0; s < segments; s++) {
|
|
307
|
-
const
|
|
308
|
-
faces.push([a + s, b + s, b + s1, a + s1]);
|
|
309
|
-
const u0 = s / segments, u1 = (s + 1) / segments;
|
|
321
|
+
const u0 = s / segments, u1 = (s + 1) / segments, um = (u0 + u1) / 2;
|
|
310
322
|
const v0 = lengths[r] / total, v1 = lengths[r + 1] / total;
|
|
311
|
-
|
|
323
|
+
// Next to a pole, a triangle; its corner there sits in the middle of u
|
|
324
|
+
if (pole[r]) {
|
|
325
|
+
faces.push([at(r, s), at(next, s), at(next, s + 1)]);
|
|
326
|
+
faceUVs.push([uv(um, v0), uv(u0, v1), uv(u1, v1)]);
|
|
327
|
+
} else if (pole[next]) {
|
|
328
|
+
faces.push([at(r, s), at(next, s), at(r, s + 1)]);
|
|
329
|
+
faceUVs.push([uv(u0, v0), uv(um, v1), uv(u1, v0)]);
|
|
330
|
+
} else {
|
|
331
|
+
faces.push([at(r, s), at(next, s), at(next, s + 1), at(r, s + 1)]);
|
|
332
|
+
faceUVs.push([uv(u0, v0), uv(u0, v1), uv(u1, v1), uv(u1, v0)]);
|
|
333
|
+
}
|
|
312
334
|
}
|
|
313
335
|
}
|
|
314
336
|
|
|
315
|
-
// Rings (
|
|
337
|
+
// Rings (none at a pole) and lines along the profile
|
|
316
338
|
profile.forEach((p, r) => {
|
|
317
|
-
if (
|
|
339
|
+
if (!pole[r]) edges.push(Array.from({ length: segments }, (_, s) => at(r, s)));
|
|
318
340
|
});
|
|
319
341
|
for (let s = 0; s < segments; s++) {
|
|
320
|
-
if (closed && profile.length > 2) edges.push(profile.map((_, r) => r
|
|
321
|
-
else for (let r = 0; r < profile.length - 1; r++)
|
|
342
|
+
if (closed && profile.length > 2) edges.push(profile.map((_, r) => at(r, s)));
|
|
343
|
+
else for (let r = 0; r < profile.length - 1; r++) if (!(pole[r] && pole[r + 1])) edges.push([at(r, s), at(r + 1, s)]);
|
|
322
344
|
}
|
|
323
345
|
|
|
324
346
|
const disc = (s) => {
|
|
325
347
|
const t = (s / segments) * Math.PI * 2;
|
|
326
348
|
return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
|
|
327
349
|
};
|
|
328
|
-
if (capBottom && !closed &&
|
|
350
|
+
if (capBottom && !closed && !pole[0]) {
|
|
329
351
|
const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
|
|
330
352
|
for (let s = 0; s < segments; s++) {
|
|
331
|
-
faces.push([c, s, (s + 1)
|
|
353
|
+
faces.push([c, at(0, s), at(0, s + 1)]);
|
|
332
354
|
faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
|
|
333
355
|
}
|
|
334
356
|
}
|
|
335
357
|
const last = profile.length - 1;
|
|
336
|
-
if (capTop && !closed &&
|
|
358
|
+
if (capTop && !closed && !pole[last]) {
|
|
337
359
|
const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
|
|
338
|
-
const a = last * segments;
|
|
339
360
|
for (let s = 0; s < segments; s++) {
|
|
340
|
-
faces.push([c,
|
|
361
|
+
faces.push([c, at(last, s + 1), at(last, s)]);
|
|
341
362
|
faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
|
|
342
363
|
}
|
|
343
364
|
}
|
|
@@ -350,11 +371,9 @@ function cylinder(r = 0.3, h = 0.8, segments = 32) {
|
|
|
350
371
|
return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
|
|
351
372
|
}
|
|
352
373
|
|
|
353
|
-
/** A cone along Y with its tip up, or a tapered cylinder when `rTop` > 0. */
|
|
374
|
+
/** A cone along Y with its tip up (a single point, so it's closed), or a tapered cylinder when `rTop` > 0. */
|
|
354
375
|
function cone(r = 0.35, h = 0.8, segments = 32, rTop = 0) {
|
|
355
|
-
|
|
356
|
-
const profile = [{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 1e-6, y: h / 2 }];
|
|
357
|
-
return lathe(profile, { segments, capBottom: true, capTop: rTop > 0 });
|
|
376
|
+
return lathe([{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 0, y: h / 2 }], { segments, capBottom: true, capTop: rTop > 0 });
|
|
358
377
|
}
|
|
359
378
|
|
|
360
379
|
/** A ring in the XZ plane: `R` from the center to the middle of the tube. */
|
|
@@ -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
|