trid3d 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +104 -2
- package/package.json +45 -4
- package/trid/index.d.ts +783 -0
- package/trid/index.js +53 -0
- package/trid/loaders/obj.js +206 -0
- package/trid/loaders/texture.js +56 -0
- package/trid/math/projection.js +49 -0
- package/trid/math/transform.js +302 -0
- package/trid/render/canvasRenderer.js +634 -0
- package/trid/render/color.js +22 -0
- package/trid/render/draw.js +146 -0
- package/trid/render/effects.js +265 -0
- package/trid/render/loop.js +42 -0
- package/trid/render/normals.js +90 -0
- package/trid/render/shapes.js +230 -0
- package/trid/render/webglRenderer.js +613 -0
- package/trid/scene.js +167 -0
- package/trid/tools/dragObject.js +89 -0
- package/trid/tools/flyCamera.js +111 -0
- package/trid/tools/fpsMeter.js +51 -0
- package/trid/tools/helpers.js +85 -0
- package/trid/tools/hoverHighlight.js +62 -0
- package/trid/tools/index.js +8 -0
- package/trid/tools/manager.js +241 -0
- package/trid/tools/objUploader.js +153 -0
- package/trid/tools/orbitCamera.js +132 -0
package/trid/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trid: a small 3D library for the HTML canvas.
|
|
3
|
+
*
|
|
4
|
+
* Everything the library offers, from one file:
|
|
5
|
+
*
|
|
6
|
+
* import { Scene, makeShape, OrbitCamera } from "trid3d"; // with npm
|
|
7
|
+
* import { Scene, makeShape, OrbitCamera } from "./trid/index.js"; // with the folder
|
|
8
|
+
*
|
|
9
|
+
* The individual modules can still be imported directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Scene
|
|
13
|
+
export { Scene } from "./scene.js";
|
|
14
|
+
|
|
15
|
+
// Shapes and meshes
|
|
16
|
+
export { makeShape, scaleMesh } from "./render/shapes.js";
|
|
17
|
+
|
|
18
|
+
// Renderers
|
|
19
|
+
export { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./render/canvasRenderer.js";
|
|
20
|
+
export { WebGLRenderer, DEFAULT_FAR } from "./render/webglRenderer.js";
|
|
21
|
+
|
|
22
|
+
// Effects
|
|
23
|
+
export {
|
|
24
|
+
toon, rim, edgeGlow, unlit, gradient, fog,
|
|
25
|
+
buildEffects, effectsKey, uniformType, SURFACE_GLSL,
|
|
26
|
+
} from "./render/effects.js";
|
|
27
|
+
|
|
28
|
+
// Normals (smooth shading)
|
|
29
|
+
export { cornerNormals, creaseOf, newell, DEFAULT_CREASE } from "./render/normals.js";
|
|
30
|
+
|
|
31
|
+
// Loading files
|
|
32
|
+
export { parseOBJ, loadOBJ, readOBJFile, pickOBJFile, fitMesh } from "./loaders/obj.js";
|
|
33
|
+
export { loadTexture, textureReady, textureSize, textureIsLive } from "./loaders/texture.js";
|
|
34
|
+
|
|
35
|
+
// Tools
|
|
36
|
+
export {
|
|
37
|
+
OrbitCamera, FlyCamera, DragObject, HoverHighlight, FpsMeter, ObjUploader,
|
|
38
|
+
ToolManager, pick, screenBounds, worldPerPixel,
|
|
39
|
+
} from "./tools/index.js";
|
|
40
|
+
|
|
41
|
+
// Drawing and the frame loop
|
|
42
|
+
export { clear, point, line, drawWireframe, drawInstance, edgeSegments } from "./render/draw.js";
|
|
43
|
+
export { loop } from "./render/loop.js";
|
|
44
|
+
export { parseColor } from "./render/color.js";
|
|
45
|
+
|
|
46
|
+
// Math
|
|
47
|
+
export {
|
|
48
|
+
localToWorld, worldToCamera, cameraToScreen, worldToScreen,
|
|
49
|
+
cameraForward, cameraRight, cameraUp, clipNear,
|
|
50
|
+
scale, translate, rotateX, rotateY, rotateZ, rotateXYZ,
|
|
51
|
+
DEFAULT_FOV, DEFAULT_NEAR,
|
|
52
|
+
} from "./math/transform.js";
|
|
53
|
+
export { project, screen } from "./math/projection.js";
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavefront OBJ loading.
|
|
3
|
+
*
|
|
4
|
+
* Reads vertex positions (`v`), texture coordinates (`vt`), normals
|
|
5
|
+
* (`vn`), faces (`f`) and polylines (`l`). Everything else (materials,
|
|
6
|
+
* groups) is ignored for now.
|
|
7
|
+
*
|
|
8
|
+
* OBJ files are right-handed with +Z toward the viewer. Trid is
|
|
9
|
+
* left-handed with the camera looking down +Z, so Z is flipped on
|
|
10
|
+
* load: a model that faces you in Blender faces you in Trid too.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ObjMesh
|
|
15
|
+
* @property {{x:number,y:number,z:number}[]} vertices
|
|
16
|
+
* @property {number[][]} edges - Unique edges as [a, b] pairs, ready to draw
|
|
17
|
+
* @property {number[][]} faces - Polygons as vertex index lists
|
|
18
|
+
* @property {{x:number,y:number,z:number}[]} [normals] - Normals from the file (`vn`), if any
|
|
19
|
+
* @property {(number[]|null)[]} [faceNormals] - For each face, the normal index of each corner
|
|
20
|
+
* (null when the file gives none for that face). Used by smooth shading.
|
|
21
|
+
* @property {{u:number,v:number}[]} [uvs] - Texture coordinates from the file (`vt`), if any
|
|
22
|
+
* @property {(number[]|null)[]} [faceUVs] - For each face, the texture coordinate index of
|
|
23
|
+
* each corner (null when the file gives none for that face). Used by textures.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} ObjOptions
|
|
26
|
+
* @property {number} [fit] - If set, center the model on the origin and scale it
|
|
27
|
+
* so its largest side is this long. Handy because OBJ files come in any size.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parses the text of an OBJ file into a mesh.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text - Contents of a .obj file
|
|
34
|
+
* @param {ObjOptions} [opts]
|
|
35
|
+
* @returns {ObjMesh}
|
|
36
|
+
* @throws {Error} If a face or line points at a vertex that doesn't exist
|
|
37
|
+
*/
|
|
38
|
+
export function parseOBJ(text, { fit } = {}) {
|
|
39
|
+
const vertices = [];
|
|
40
|
+
const faces = [];
|
|
41
|
+
const lines = [];
|
|
42
|
+
const normals = [];
|
|
43
|
+
const faceNormals = []; // per face: normal index of each corner, or null
|
|
44
|
+
const uvs = [];
|
|
45
|
+
const faceUVs = []; // per face: texture coordinate index of each corner, or null
|
|
46
|
+
|
|
47
|
+
const src = text.split(/\r?\n/);
|
|
48
|
+
|
|
49
|
+
for (let n = 0; n < src.length; n++) {
|
|
50
|
+
const line = src[n].trim();
|
|
51
|
+
|
|
52
|
+
if (line === "" || line[0] === "#") continue;
|
|
53
|
+
|
|
54
|
+
const parts = line.split(/\s+/);
|
|
55
|
+
const kind = parts[0];
|
|
56
|
+
|
|
57
|
+
if (kind === "v") {
|
|
58
|
+
vertices.push({ x: +(parts[1] ?? 0), y: +(parts[2] ?? 0), z: -(parts[3] ?? 0) });
|
|
59
|
+
} else if (kind === "vt") {
|
|
60
|
+
uvs.push({ u: +(parts[1] ?? 0), v: +(parts[2] ?? 0) });
|
|
61
|
+
} else if (kind === "vn") {
|
|
62
|
+
normals.push({ x: +(parts[1] ?? 0), y: +(parts[2] ?? 0), z: -(parts[3] ?? 0) });
|
|
63
|
+
} else if (kind === "f" || kind === "l") {
|
|
64
|
+
// "7", "7/1", "7//3", "7/1/3" all point at vertex 7. Negative = counted from the end.
|
|
65
|
+
const ids = parts.slice(1).map((p) => {
|
|
66
|
+
const i = parseInt(p, 10);
|
|
67
|
+
const idx = i < 0 ? vertices.length + i : i - 1;
|
|
68
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= vertices.length) {
|
|
69
|
+
throw new Error(`OBJ line ${n + 1}: bad vertex index "${p}"`);
|
|
70
|
+
}
|
|
71
|
+
return idx;
|
|
72
|
+
});
|
|
73
|
+
if (kind === "f" && ids.length >= 3) {
|
|
74
|
+
faces.push(ids.reverse()); // flipping Z flips the winding
|
|
75
|
+
// "7//3" and "7/1/3": the third number is the corner's normal
|
|
76
|
+
const ns = parts.slice(1).map((p) => {
|
|
77
|
+
const s = p.split("/")[2];
|
|
78
|
+
if (!s) return -1;
|
|
79
|
+
const i = parseInt(s, 10);
|
|
80
|
+
const idx = i < 0 ? normals.length + i : i - 1;
|
|
81
|
+
return idx >= 0 && idx < normals.length ? idx : -1;
|
|
82
|
+
});
|
|
83
|
+
faceNormals.push(ns.includes(-1) ? null : ns.reverse());
|
|
84
|
+
// "7/1" and "7/1/3": the second number is the corner's texture coordinate
|
|
85
|
+
const ts = parts.slice(1).map((p) => {
|
|
86
|
+
const s = p.split("/")[1];
|
|
87
|
+
if (!s) return -1;
|
|
88
|
+
const i = parseInt(s, 10);
|
|
89
|
+
const idx = i < 0 ? uvs.length + i : i - 1;
|
|
90
|
+
return idx >= 0 && idx < uvs.length ? idx : -1;
|
|
91
|
+
});
|
|
92
|
+
faceUVs.push(ts.includes(-1) ? null : ts.reverse());
|
|
93
|
+
} else if (kind === "l" && ids.length >= 2) lines.push(ids);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const edges = [];
|
|
99
|
+
const addEdge = (a, b) => {
|
|
100
|
+
if (a === b) return;
|
|
101
|
+
|
|
102
|
+
// (2 to the power of 32) = 4294967296
|
|
103
|
+
// The parser uses it to turn an edge into a single number
|
|
104
|
+
const key = a < b ? a * 4294967296 + b : b * 4294967296 + a;
|
|
105
|
+
|
|
106
|
+
if (seen.has(key)) return;
|
|
107
|
+
|
|
108
|
+
seen.add(key);
|
|
109
|
+
edges.push([a, b]);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
for (const f of faces) {
|
|
113
|
+
for (let i = 0; i < f.length; i++) addEdge(f[i], f[(i + 1) % f.length]);
|
|
114
|
+
}
|
|
115
|
+
for (const l of lines) {
|
|
116
|
+
for (let i = 0; i < l.length - 1; i++) addEdge(l[i], l[i + 1]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mesh = { vertices, edges, faces };
|
|
120
|
+
// Normals from the file, used by smooth shading
|
|
121
|
+
if (faceNormals.some((n) => n)) {
|
|
122
|
+
mesh.normals = normals;
|
|
123
|
+
mesh.faceNormals = faceNormals;
|
|
124
|
+
}
|
|
125
|
+
// Texture coordinates from the file
|
|
126
|
+
if (faceUVs.some((t) => t)) {
|
|
127
|
+
mesh.uvs = uvs;
|
|
128
|
+
mesh.faceUVs = faceUVs;
|
|
129
|
+
}
|
|
130
|
+
if (fit != null) fitMesh(mesh, fit);
|
|
131
|
+
return mesh;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Centers a mesh on the origin and scales it so its largest side is `size`.
|
|
136
|
+
* Changes the mesh in place.
|
|
137
|
+
*
|
|
138
|
+
* @param {{ vertices:{x:number,y:number,z:number}[] }} mesh
|
|
139
|
+
* @param {number} [size=1]
|
|
140
|
+
* @returns The same mesh
|
|
141
|
+
*/
|
|
142
|
+
export function fitMesh(mesh, size = 1) {
|
|
143
|
+
const vs = mesh.vertices;
|
|
144
|
+
if (vs.length === 0) return mesh;
|
|
145
|
+
|
|
146
|
+
const min = { x: Infinity, y: Infinity, z: Infinity };
|
|
147
|
+
const max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
148
|
+
for (const v of vs) {
|
|
149
|
+
for (const k of ["x", "y", "z"]) {
|
|
150
|
+
if (v[k] < min[k]) min[k] = v[k];
|
|
151
|
+
if (v[k] > max[k]) max[k] = v[k];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cx = (min.x + max.x) / 2, cy = (min.y + max.y) / 2, cz = (min.z + max.z) / 2;
|
|
156
|
+
const largest = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
|
|
157
|
+
const s = largest > 0 ? size / largest : 1;
|
|
158
|
+
|
|
159
|
+
for (const v of vs) {
|
|
160
|
+
v.x = (v.x - cx) * s;
|
|
161
|
+
v.y = (v.y - cy) * s;
|
|
162
|
+
v.z = (v.z - cz) * s;
|
|
163
|
+
}
|
|
164
|
+
return mesh;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Downloads an OBJ file and parses it.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} url
|
|
171
|
+
* @param {ObjOptions} [opts]
|
|
172
|
+
* @returns {Promise<ObjMesh>}
|
|
173
|
+
*/
|
|
174
|
+
export async function loadOBJ(url, opts) {
|
|
175
|
+
const res = await fetch(url);
|
|
176
|
+
if (!res.ok) throw new Error(`Could not load ${url} (${res.status})`);
|
|
177
|
+
return parseOBJ(await res.text(), opts);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Parses an OBJ file the user picked or dropped (a `File` or `Blob`).
|
|
182
|
+
*
|
|
183
|
+
* @param {Blob} file
|
|
184
|
+
* @param {ObjOptions} [opts]
|
|
185
|
+
* @returns {Promise<ObjMesh>}
|
|
186
|
+
*/
|
|
187
|
+
export async function readOBJFile(file, opts) {
|
|
188
|
+
return parseOBJ(await file.text(), opts);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Opens the browser's file dialog for .obj files.
|
|
193
|
+
* Must be called from a user action, like a button click.
|
|
194
|
+
*
|
|
195
|
+
* @returns {Promise<File|null>} The chosen file, or null if the dialog was cancelled
|
|
196
|
+
*/
|
|
197
|
+
export function pickOBJFile() {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const input = document.createElement("input");
|
|
200
|
+
input.type = "file";
|
|
201
|
+
input.accept = ".obj";
|
|
202
|
+
input.addEventListener("change", () => resolve(input.files?.[0] ?? null));
|
|
203
|
+
input.addEventListener("cancel", () => resolve(null));
|
|
204
|
+
input.click();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Textures: images drawn on faces.
|
|
3
|
+
*
|
|
4
|
+
* A material's `texture` can be anything the canvas can draw:
|
|
5
|
+
* an `<img>`, a `<canvas>`, a `<video>` or an `ImageBitmap`.
|
|
6
|
+
* Canvases and videos are read again every frame, so they can change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads an image to use as a texture.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} url
|
|
13
|
+
* @returns {Promise<HTMLImageElement>} The image, fully loaded
|
|
14
|
+
* @throws {Error} If the image can't be loaded
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const crate = await loadTexture("textures/crate.png");
|
|
18
|
+
* scene.add({ mesh: makeShape("cube"), material: { texture: crate } });
|
|
19
|
+
*/
|
|
20
|
+
export function loadTexture(url) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const img = new Image();
|
|
23
|
+
// Lets WebGL use images from other sites when they allow it (CORS)
|
|
24
|
+
img.crossOrigin = "anonymous";
|
|
25
|
+
img.onload = () => resolve(img);
|
|
26
|
+
img.onerror = () => reject(new Error(`Could not load texture ${url}`));
|
|
27
|
+
img.src = url;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether a texture source has pixels to draw yet. */
|
|
32
|
+
export function textureReady(src) {
|
|
33
|
+
if (!src) return false;
|
|
34
|
+
if (typeof HTMLImageElement !== "undefined" && src instanceof HTMLImageElement) {
|
|
35
|
+
return src.complete && src.naturalWidth > 0;
|
|
36
|
+
}
|
|
37
|
+
if (typeof HTMLVideoElement !== "undefined" && src instanceof HTMLVideoElement) {
|
|
38
|
+
return src.readyState >= 2;
|
|
39
|
+
}
|
|
40
|
+
return (src.width ?? 0) > 0 && (src.height ?? 0) > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pixel size of a texture source. */
|
|
44
|
+
export function textureSize(src) {
|
|
45
|
+
return {
|
|
46
|
+
w: src.naturalWidth || src.videoWidth || src.width || 0,
|
|
47
|
+
h: src.naturalHeight || src.videoHeight || src.height || 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a source can change over time (canvas, video), so it's read every frame. */
|
|
52
|
+
export function textureIsLive(src) {
|
|
53
|
+
return (typeof HTMLCanvasElement !== "undefined" && src instanceof HTMLCanvasElement)
|
|
54
|
+
|| (typeof HTMLVideoElement !== "undefined" && src instanceof HTMLVideoElement)
|
|
55
|
+
|| (typeof OffscreenCanvas !== "undefined" && src instanceof OffscreenCanvas);
|
|
56
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts normalized device coordinates (-1 to +1)
|
|
3
|
+
* into actual screen (pixel) coordinates.
|
|
4
|
+
*
|
|
5
|
+
* Normalized space:
|
|
6
|
+
* x: -1 (left) -> +1 (right)
|
|
7
|
+
* y: -1 (bottom)-> +1 (top)
|
|
8
|
+
*
|
|
9
|
+
* Canvas space:
|
|
10
|
+
* x: 0 -> width
|
|
11
|
+
* y: 0 -> height (top-down)
|
|
12
|
+
*
|
|
13
|
+
* @param {{x:number, y:number}} p - Normalized 2D point
|
|
14
|
+
* @param {number} w - Canvas width
|
|
15
|
+
* @param {number} h - Canvas height
|
|
16
|
+
* @returns {{x:number, y:number}} Screen-space point
|
|
17
|
+
*/
|
|
18
|
+
export function screen(p, w, h) {
|
|
19
|
+
return {
|
|
20
|
+
x: (p.x + 1) / 2 * w,
|
|
21
|
+
y: (1 - (p.y + 1) / 2) * h,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Projects a 3D point onto a 2D plane using
|
|
27
|
+
* a perspective projection.
|
|
28
|
+
*
|
|
29
|
+
* The result is in normalized device coordinates (-1 to +1).
|
|
30
|
+
* `fov` is the vertical field of view; X is divided by `aspect`
|
|
31
|
+
* so shapes are not stretched on non-square canvases.
|
|
32
|
+
*
|
|
33
|
+
* NOTE:
|
|
34
|
+
* - z must be > 0 (in front of the camera)
|
|
35
|
+
* - smaller z => appears larger
|
|
36
|
+
* - larger z => appears smaller
|
|
37
|
+
*
|
|
38
|
+
* @param {{x:number, y:number, z:number}} p - 3D point (camera space)
|
|
39
|
+
* @param {number} fov - Vertical field of view in radians (default: 90°)
|
|
40
|
+
* @param {number} aspect - Canvas width / height (default: 1)
|
|
41
|
+
* @returns {{x:number, y:number}} 2D projected point
|
|
42
|
+
*/
|
|
43
|
+
export function project(p, fov = Math.PI / 2, aspect = 1) {
|
|
44
|
+
const f = 1 / Math.tan(fov / 2);
|
|
45
|
+
return {
|
|
46
|
+
x: (p.x * f) / (p.z * aspect),
|
|
47
|
+
y: (p.y * f) / p.z,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { project, screen } from "./projection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Camera object. Every field is optional and falls back to its default.
|
|
5
|
+
*
|
|
6
|
+
* The camera looks down +Z when yaw and pitch are 0.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {Object} Camera
|
|
9
|
+
* @property {number} [x=0] - Position on X
|
|
10
|
+
* @property {number} [y=0] - Position on Y
|
|
11
|
+
* @property {number} [z=0] - Position on Z
|
|
12
|
+
* @property {number} [yaw=0] - Turn around Y in radians (> 0 turns right)
|
|
13
|
+
* @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
|
|
15
|
+
* @property {number} [near=0.01] - Near plane distance; closer geometry is clipped
|
|
16
|
+
* @property {number} [far=1000] - Far distance; farther geometry isn't drawn (WebGL renderer only)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Default vertical field of view (90°). */
|
|
20
|
+
export const DEFAULT_FOV = Math.PI / 2;
|
|
21
|
+
|
|
22
|
+
/** Default near plane distance. */
|
|
23
|
+
export const DEFAULT_NEAR = 0.01;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Converts a vertex from local (mesh) space to world space
|
|
27
|
+
* using an instance transform.
|
|
28
|
+
*
|
|
29
|
+
* Transformation order:
|
|
30
|
+
* 1. Scale
|
|
31
|
+
* 2. Rotate (X then Y then Z)
|
|
32
|
+
* 3. Translate to world position
|
|
33
|
+
*
|
|
34
|
+
* @param {{x:number,y:number,z:number}} v - Local-space vertex
|
|
35
|
+
* @param {{
|
|
36
|
+
* pos:{x:number,y:number,z:number},
|
|
37
|
+
* rot:{x:number,y:number,z:number},
|
|
38
|
+
* scale:{x:number,y:number,z:number}
|
|
39
|
+
* }} inst - Mesh instance transform
|
|
40
|
+
*
|
|
41
|
+
* @returns {{x:number,y:number,z:number}} World-space vertex
|
|
42
|
+
*/
|
|
43
|
+
export function localToWorld(v, inst) {
|
|
44
|
+
let p = scale(v, inst.scale.x, inst.scale.y, inst.scale.z);
|
|
45
|
+
p = rotateXYZ(p, inst.rot);
|
|
46
|
+
p = translate(p, inst.pos.x, inst.pos.y, inst.pos.z);
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Converts a world-space point into camera space.
|
|
53
|
+
*
|
|
54
|
+
* Steps:
|
|
55
|
+
* 1. Move the point relative to the camera position
|
|
56
|
+
* 2. Undo the camera's yaw, then its pitch
|
|
57
|
+
*
|
|
58
|
+
* In camera space the camera sits at the origin looking down +Z.
|
|
59
|
+
*
|
|
60
|
+
* @param {{x:number,y:number,z:number}} pWorld - World-space point
|
|
61
|
+
* @param {Camera} camera - Camera object
|
|
62
|
+
*
|
|
63
|
+
* @returns {{x:number,y:number,z:number}} Camera-space point
|
|
64
|
+
*/
|
|
65
|
+
export function worldToCamera(pWorld, camera) {
|
|
66
|
+
let p = translate(pWorld, -(camera.x ?? 0), -(camera.y ?? 0), -(camera.z ?? 0));
|
|
67
|
+
p = rotateY(p, camera.yaw ?? 0);
|
|
68
|
+
p = rotateX(p, camera.pitch ?? 0);
|
|
69
|
+
return p;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Converts a camera-space point into screen-space coordinates.
|
|
74
|
+
*
|
|
75
|
+
* Steps:
|
|
76
|
+
* 1. Perspective projection (3D → 2D) using the camera's fov
|
|
77
|
+
* and the canvas aspect ratio
|
|
78
|
+
* 2. Convert normalized coords to screen pixels
|
|
79
|
+
*
|
|
80
|
+
* NOTE: z must be > 0 (in front of the camera). Use `clipNear`
|
|
81
|
+
* on segments first if they may cross behind the camera.
|
|
82
|
+
*
|
|
83
|
+
* @param {{x:number,y:number,z:number}} pCam - Camera-space point
|
|
84
|
+
* @param {Camera} camera - Camera object
|
|
85
|
+
* @param {number} w - Canvas width
|
|
86
|
+
* @param {number} h - Canvas height
|
|
87
|
+
*
|
|
88
|
+
* @returns {{x:number,y:number}} Screen-space point
|
|
89
|
+
*/
|
|
90
|
+
export function cameraToScreen(pCam, camera, w, h) {
|
|
91
|
+
return screen(project(pCam, camera.fov ?? DEFAULT_FOV, w / h), w, h);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Converts a world-space point into screen-space coordinates.
|
|
96
|
+
*
|
|
97
|
+
* Steps:
|
|
98
|
+
* 1. World space → camera space (`worldToCamera`)
|
|
99
|
+
* 2. Camera space → screen pixels (`cameraToScreen`)
|
|
100
|
+
*
|
|
101
|
+
* @param {{x:number,y:number,z:number}} pWorld - World-space point
|
|
102
|
+
* @param {Camera} camera - Camera object
|
|
103
|
+
* @param {number} w - Canvas width
|
|
104
|
+
* @param {number} h - Canvas height
|
|
105
|
+
*
|
|
106
|
+
* @returns {{x:number,y:number}} Screen-space point
|
|
107
|
+
*/
|
|
108
|
+
export function worldToScreen(pWorld, camera, w, h) {
|
|
109
|
+
return cameraToScreen(worldToCamera(pWorld, camera), camera, w, h);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Direction the camera looks at, in world space (unit vector).
|
|
114
|
+
*
|
|
115
|
+
* @param {Camera} camera - Camera object
|
|
116
|
+
* @returns {{x:number,y:number,z:number}} Forward vector
|
|
117
|
+
*/
|
|
118
|
+
export function cameraForward(camera) {
|
|
119
|
+
const yaw = camera.yaw ?? 0;
|
|
120
|
+
const pitch = camera.pitch ?? 0;
|
|
121
|
+
return {
|
|
122
|
+
x: Math.sin(yaw) * Math.cos(pitch),
|
|
123
|
+
y: Math.sin(pitch),
|
|
124
|
+
z: Math.cos(yaw) * Math.cos(pitch),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Direction of the screen's right side, in world space (unit vector).
|
|
130
|
+
*
|
|
131
|
+
* @param {Camera} camera - Camera object
|
|
132
|
+
* @returns {{x:number,y:number,z:number}} Right vector
|
|
133
|
+
*/
|
|
134
|
+
export function cameraRight(camera) {
|
|
135
|
+
const yaw = camera.yaw ?? 0;
|
|
136
|
+
return {
|
|
137
|
+
x: Math.cos(yaw),
|
|
138
|
+
y: 0,
|
|
139
|
+
z: -Math.sin(yaw),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Direction of the screen's top side, in world space (unit vector).
|
|
145
|
+
*
|
|
146
|
+
* @param {Camera} camera - Camera object
|
|
147
|
+
* @returns {{x:number,y:number,z:number}} Up vector
|
|
148
|
+
*/
|
|
149
|
+
export function cameraUp(camera) {
|
|
150
|
+
const yaw = camera.yaw ?? 0;
|
|
151
|
+
const pitch = camera.pitch ?? 0;
|
|
152
|
+
return {
|
|
153
|
+
x: -Math.sin(pitch) * Math.sin(yaw),
|
|
154
|
+
y: Math.cos(pitch),
|
|
155
|
+
z: -Math.sin(pitch) * Math.cos(yaw),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Clips a camera-space segment against the near plane (z = near).
|
|
161
|
+
*
|
|
162
|
+
* - Both ends in front: segment returned unchanged
|
|
163
|
+
* - Both ends behind: returns null (nothing to draw)
|
|
164
|
+
* - One end behind: that end is moved onto the near plane
|
|
165
|
+
*
|
|
166
|
+
* @param {{x:number,y:number,z:number}} a - Segment start (camera space)
|
|
167
|
+
* @param {{x:number,y:number,z:number}} b - Segment end (camera space)
|
|
168
|
+
* @param {number} near - Near plane distance (> 0)
|
|
169
|
+
*
|
|
170
|
+
* @returns {[{x:number,y:number,z:number}, {x:number,y:number,z:number}] | null}
|
|
171
|
+
*/
|
|
172
|
+
export function clipNear(a, b, near) {
|
|
173
|
+
const aIn = a.z >= near;
|
|
174
|
+
const bIn = b.z >= near;
|
|
175
|
+
|
|
176
|
+
if (aIn && bIn) return [a, b];
|
|
177
|
+
if (!aIn && !bIn) return null;
|
|
178
|
+
|
|
179
|
+
const t = (near - a.z) / (b.z - a.z);
|
|
180
|
+
const p = {
|
|
181
|
+
x: a.x + (b.x - a.x) * t,
|
|
182
|
+
y: a.y + (b.y - a.y) * t,
|
|
183
|
+
z: near,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return aIn ? [a, p] : [p, b];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Scales a 3D point along each axis.
|
|
191
|
+
*
|
|
192
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
193
|
+
* @param {number} sx - Scale factor on X axis
|
|
194
|
+
* @param {number} sy - Scale factor on Y axis
|
|
195
|
+
* @param {number} sz - Scale factor on Z axis
|
|
196
|
+
* @returns {{x:number,y:number,z:number}} Scaled point
|
|
197
|
+
*/
|
|
198
|
+
export function scale(p, sx, sy, sz) {
|
|
199
|
+
return {
|
|
200
|
+
x: p.x * sx,
|
|
201
|
+
y: p.y * sy,
|
|
202
|
+
z: p.z * sz,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Translates a 3D point in space.
|
|
208
|
+
*
|
|
209
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
210
|
+
* @param {number} dx - Translation on X axis
|
|
211
|
+
* @param {number} dy - Translation on Y axis
|
|
212
|
+
* @param {number} dz - Translation on Z axis
|
|
213
|
+
* @returns {{x:number,y:number,z:number}} Translated point
|
|
214
|
+
*/
|
|
215
|
+
export function translate(p, dx, dy, dz) {
|
|
216
|
+
return {
|
|
217
|
+
x: p.x + dx,
|
|
218
|
+
y: p.y + dy,
|
|
219
|
+
z: p.z + dz,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Rotates a 3D point around the X axis (YZ plane).
|
|
225
|
+
*
|
|
226
|
+
* This creates an up/down “pitch” rotation.
|
|
227
|
+
*
|
|
228
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
229
|
+
* @param {number} angle - Rotation angle in radians
|
|
230
|
+
* @returns {{x:number,y:number,z:number}} Rotated point
|
|
231
|
+
*/
|
|
232
|
+
export function rotateX(p, angle) {
|
|
233
|
+
const c = Math.cos(angle);
|
|
234
|
+
const s = Math.sin(angle);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
x: p.x,
|
|
238
|
+
y: p.y * c - p.z * s,
|
|
239
|
+
z: p.y * s + p.z * c,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Rotates a 3D point around the Y axis (XZ plane).
|
|
245
|
+
*
|
|
246
|
+
* This creates a left/right “yaw” rotation.
|
|
247
|
+
*
|
|
248
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
249
|
+
* @param {number} angle - Rotation angle in radians
|
|
250
|
+
* @returns {{x:number,y:number,z:number}} Rotated point
|
|
251
|
+
*/
|
|
252
|
+
export function rotateY(p, angle) {
|
|
253
|
+
const c = Math.cos(angle);
|
|
254
|
+
const s = Math.sin(angle);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
x: p.x * c - p.z * s,
|
|
258
|
+
y: p.y,
|
|
259
|
+
z: p.x * s + p.z * c,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rotates a 3D point around the Z axis (XY plane).
|
|
265
|
+
*
|
|
266
|
+
* This creates a “roll” rotation (spins in 2D).
|
|
267
|
+
*
|
|
268
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
269
|
+
* @param {number} angle - Rotation angle in radians
|
|
270
|
+
* @returns {{x:number,y:number,z:number}} Rotated point
|
|
271
|
+
*/
|
|
272
|
+
export function rotateZ(p, angle) {
|
|
273
|
+
const c = Math.cos(angle);
|
|
274
|
+
const s = Math.sin(angle);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
x: p.x * c - p.y * s,
|
|
278
|
+
y: p.x * s + p.y * c,
|
|
279
|
+
z: p.z,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Applies Euler rotations to a point.
|
|
285
|
+
*
|
|
286
|
+
* Rotation order matters. This function applies:
|
|
287
|
+
* 1) rotateX
|
|
288
|
+
* 2) rotateY
|
|
289
|
+
* 3) rotateZ
|
|
290
|
+
*
|
|
291
|
+
* If you change the order, the final orientation will change.
|
|
292
|
+
*
|
|
293
|
+
* @param {{x:number,y:number,z:number}} p - Input point
|
|
294
|
+
* @param {{x:number,y:number,z:number}} rot - Rotation angles in radians
|
|
295
|
+
* @returns {{x:number,y:number,z:number}} Rotated point
|
|
296
|
+
*/
|
|
297
|
+
export function rotateXYZ(p, rot) {
|
|
298
|
+
let r = rotateX(p, rot.x);
|
|
299
|
+
r = rotateY(r, rot.y);
|
|
300
|
+
r = rotateZ(r, rot.z);
|
|
301
|
+
return r;
|
|
302
|
+
}
|