trid3d 0.1.1 → 0.2.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 +11 -6
- package/package.json +2 -1
- package/trid/export/capture.js +150 -0
- package/trid/export/obj.js +141 -0
- package/trid/index.d.ts +216 -11
- package/trid/index.js +10 -3
- package/trid/loaders/obj.js +18 -2
- package/trid/render/canvasRenderer.js +94 -61
- package/trid/render/draw.js +15 -4
- package/trid/render/shapes.js +285 -23
- package/trid/render/uvMapping.js +225 -0
- package/trid/render/webglRenderer.js +148 -54
- package/trid/scene.js +23 -0
- package/trid/tools/hoverHighlight.js +2 -1
- package/trid/tools/orbitCamera.js +18 -0
package/README.md
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
|
|
11
11
|
Trid gives you a scene, a camera, shapes, model loading and mouse/keyboard tools, all in plain ES modules you can drop next to your page.
|
|
12
12
|
|
|
13
|
-
> **Status:** early. Objects can be wireframes or filled
|
|
13
|
+
> **Status:** early. Objects can be wireframes or filled, shaded and textured, with shader effects.
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
17
17
|
- **Scene** — holds a canvas, a camera and your objects, and runs the frame loop for you.
|
|
18
|
-
- **Shapes** — cubes, boxes and
|
|
18
|
+
- **Shapes** — cubes, boxes, 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.
|
|
19
19
|
- **Filled faces** — give an object a `material` to fill and shade its faces, flat or smooth, with one directional light.
|
|
20
|
-
- **Textures** — images, canvases or videos on faces, with texture coordinates from built-in shapes and OBJ files.
|
|
20
|
+
- **Textures** — images, canvases or videos on faces, with texture coordinates from built-in shapes and OBJ files, or made from the shape (planar, box, sphere and cylinder mapping). Repeat, offset and rotate them, or give each face its own material.
|
|
21
21
|
- **Effects** — stack shader effects like toon, rim and edge glow, gradients and fog on a material, or write your own in GLSL.
|
|
22
22
|
- **Two renderers** — the canvas 2D context by default, or `WebGLRenderer` for exact depth and big models, with the same scene code.
|
|
23
23
|
- **OBJ files** — load models from a URL, or let users drop their own `.obj` files on the canvas.
|
|
24
|
+
- **Export** — `scene.screenshot()` and `scene.record()` save images and videos of the canvas; `exportOBJ` writes meshes, objects or whole scenes as OBJ files.
|
|
24
25
|
- **Camera** — position, yaw, pitch and field of view, with near-plane clipping.
|
|
25
26
|
- **Tools** — orbit and fly cameras, object dragging, hover highlight, an FPS meter and an OBJ uploader, one line each. Writing your own is a plain object with a few methods.
|
|
26
27
|
- **Pure math** — every transform and projection step is exported, so you can use the pipeline piece by piece.
|
|
@@ -81,6 +82,7 @@ Or let users load their own: `scene.use(new ObjUploader())` accepts dropped `.ob
|
|
|
81
82
|
| `trid/math/` | Transforms, camera directions, projection |
|
|
82
83
|
| `trid/render/` | Renderers (Canvas 2D, WebGL), effects, shapes, drawing, frame loop |
|
|
83
84
|
| `trid/loaders/` | OBJ and texture loading |
|
|
85
|
+
| `trid/export/` | Screenshots, video recording and OBJ export |
|
|
84
86
|
| `trid/tools/` | Camera, object and overlay tools |
|
|
85
87
|
| `docs/` | Documentation site with live demos |
|
|
86
88
|
|
|
@@ -99,7 +101,10 @@ See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.
|
|
|
99
101
|
|
|
100
102
|
## Roadmap
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
Roughly in order:
|
|
105
|
+
|
|
106
|
+
- **More import and export formats** — `.mtl` material files for OBJ (colors and textures per part, both ways), STL; basic glTF later
|
|
104
107
|
- **Parent/child transforms** — e.g. a moon orbiting a planet
|
|
105
|
-
- **
|
|
108
|
+
- **More tools** — a gizmo to move, rotate and scale objects with the mouse, a selection outline, a grid/axes helper
|
|
109
|
+
- **Post effects** (WebGL) — bloom, vignette, outlines, antialiasing and color grading over the whole image
|
|
110
|
+
- **More tests** — `npm test` covers shapes and tools so far; the math functions are next
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trid3d",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A small 3D library for the HTML canvas: wireframes, filled and smooth faces, textures and shader effects, drawn with Canvas 2D or WebGL. No dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"3d",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"sideEffects": false,
|
|
39
39
|
"scripts": {
|
|
40
|
+
"test": "node --test \"test/*.test.js\"",
|
|
40
41
|
"docs:dev": "vitepress dev docs",
|
|
41
42
|
"docs:build": "vitepress build docs",
|
|
42
43
|
"docs:preview": "vitepress preview docs"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving what a scene shows: still images, videos, and a helper that
|
|
3
|
+
* downloads anything as a file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} ScreenshotOptions
|
|
8
|
+
* @property {string} [type="image/png"] - "image/png", "image/jpeg" or "image/webp"
|
|
9
|
+
* @property {number} [quality=0.92] - For JPEG and WebP, from 0 to 1
|
|
10
|
+
* @property {number} [width] - Image width in pixels (default: the canvas's).
|
|
11
|
+
* Line widths and tool overlays stay the same size in pixels.
|
|
12
|
+
* @property {number} [height] - Image height in pixels (default: the canvas's)
|
|
13
|
+
* @property {boolean} [tools=true] - Include what tools draw on top (FPS meter, labels…)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Draws a new frame of the scene and returns it as an image file.
|
|
18
|
+
*
|
|
19
|
+
* The frame is drawn on a separate canvas, so the page doesn't change
|
|
20
|
+
* and it works whether or not the scene is running.
|
|
21
|
+
*
|
|
22
|
+
* @param {import("../scene.js").Scene} scene
|
|
23
|
+
* @param {ScreenshotOptions} [opts]
|
|
24
|
+
* @returns {Promise<Blob>}
|
|
25
|
+
*/
|
|
26
|
+
export function screenshot(scene, { type = "image/png", quality = 0.92, width, height, tools = true } = {}) {
|
|
27
|
+
const canvas = document.createElement("canvas");
|
|
28
|
+
canvas.width = Math.max(1, Math.round(width ?? scene.canvas.width));
|
|
29
|
+
canvas.height = Math.max(1, Math.round(height ?? scene.canvas.height));
|
|
30
|
+
|
|
31
|
+
// Draw the scene on the new canvas, then put the real one back
|
|
32
|
+
const saved = { canvas: scene.canvas, ctx: scene.ctx };
|
|
33
|
+
scene.canvas = canvas;
|
|
34
|
+
scene.ctx = canvas.getContext("2d");
|
|
35
|
+
try {
|
|
36
|
+
scene.renderer.render(scene);
|
|
37
|
+
if (tools) scene.tools.draw(scene.ctx);
|
|
38
|
+
} finally {
|
|
39
|
+
scene.canvas = saved.canvas;
|
|
40
|
+
scene.ctx = saved.ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
canvas.toBlob(
|
|
45
|
+
(blob) => (blob ? resolve(blob) : reject(new Error("Trid: the browser couldn't make the image"))),
|
|
46
|
+
type,
|
|
47
|
+
quality,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Video formats to try, best first. Browsers each support a few of them. */
|
|
53
|
+
const VIDEO_TYPES = [
|
|
54
|
+
"video/webm;codecs=vp9",
|
|
55
|
+
"video/webm;codecs=vp8",
|
|
56
|
+
"video/webm",
|
|
57
|
+
"video/mp4;codecs=avc1",
|
|
58
|
+
"video/mp4",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} RecordOptions
|
|
63
|
+
* @property {number} [fps=60] - Frames per second of the video
|
|
64
|
+
* @property {string} [type] - Video format, like "video/webm" (default: the best one the browser has)
|
|
65
|
+
* @property {number} [bitsPerSecond=8000000] - Quality: higher is sharper and bigger
|
|
66
|
+
* @property {number} [duration] - Stop by itself after this many seconds
|
|
67
|
+
*
|
|
68
|
+
* @typedef {Object} Recording
|
|
69
|
+
* @property {string} type - The video format being recorded
|
|
70
|
+
* @property {() => Promise<Blob>} stop - Stops recording; resolves with the video
|
|
71
|
+
* @property {Promise<Blob>} done - Resolves with the video once recording stops
|
|
72
|
+
* @property {boolean} recording - Whether it's still recording
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Records the scene's canvas as a video, everything it shows, tools included.
|
|
77
|
+
* The scene has to be running (`scene.start`) for frames to be recorded.
|
|
78
|
+
*
|
|
79
|
+
* @param {import("../scene.js").Scene} scene
|
|
80
|
+
* @param {RecordOptions} [opts]
|
|
81
|
+
* @returns {Recording}
|
|
82
|
+
* @throws {Error} If the browser can't record a canvas
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const rec = scene.record({ duration: 3 });
|
|
86
|
+
* download(await rec.done, "clip.webm");
|
|
87
|
+
*/
|
|
88
|
+
export function record(scene, { fps = 60, type, bitsPerSecond = 8_000_000, duration } = {}) {
|
|
89
|
+
if (typeof MediaRecorder === "undefined" || !scene.canvas.captureStream) {
|
|
90
|
+
throw new Error("Trid: this browser can't record a canvas (no MediaRecorder or captureStream)");
|
|
91
|
+
}
|
|
92
|
+
const mimeType = type ?? VIDEO_TYPES.find((t) => MediaRecorder.isTypeSupported(t));
|
|
93
|
+
if (!mimeType) throw new Error("Trid: this browser can't record any video format Trid knows");
|
|
94
|
+
|
|
95
|
+
const stream = scene.canvas.captureStream(fps);
|
|
96
|
+
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: bitsPerSecond });
|
|
97
|
+
const chunks = [];
|
|
98
|
+
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
|
99
|
+
|
|
100
|
+
let timer = null;
|
|
101
|
+
const done = new Promise((resolve, reject) => {
|
|
102
|
+
recorder.onstop = () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
for (const track of stream.getTracks()) track.stop();
|
|
105
|
+
resolve(new Blob(chunks, { type: mimeType.split(";")[0] }));
|
|
106
|
+
};
|
|
107
|
+
recorder.onerror = (e) => reject(e.error ?? new Error("Trid: recording failed"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
recorder.start(250); // hand over data every 250 ms, so a long video isn't one huge piece at the end
|
|
111
|
+
if (duration != null) timer = setTimeout(() => recorder.state !== "inactive" && recorder.stop(), duration * 1000);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
type: mimeType,
|
|
115
|
+
done,
|
|
116
|
+
get recording() {
|
|
117
|
+
return recorder.state !== "inactive";
|
|
118
|
+
},
|
|
119
|
+
stop() {
|
|
120
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
121
|
+
return done;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Saves something as a file in the browser's downloads.
|
|
128
|
+
*
|
|
129
|
+
* @param {Blob|string|Promise<Blob|string>} data - A file (like a screenshot or video),
|
|
130
|
+
* or text (like an OBJ file)
|
|
131
|
+
* @param {string} filename - Name of the saved file, like "scene.png"
|
|
132
|
+
* @returns {Promise<void>}
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* download(scene.screenshot(), "scene.png");
|
|
136
|
+
* download(exportOBJ(mesh), "model.obj");
|
|
137
|
+
*/
|
|
138
|
+
export async function download(data, filename) {
|
|
139
|
+
const value = await data;
|
|
140
|
+
const blob = value instanceof Blob ? value : new Blob([value], { type: "text/plain" });
|
|
141
|
+
const url = URL.createObjectURL(blob);
|
|
142
|
+
const a = document.createElement("a");
|
|
143
|
+
a.href = url;
|
|
144
|
+
a.download = filename;
|
|
145
|
+
a.style.display = "none";
|
|
146
|
+
document.body.append(a);
|
|
147
|
+
a.click();
|
|
148
|
+
a.remove();
|
|
149
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000); // after the browser has started the download
|
|
150
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavefront OBJ export: the reverse of `parseOBJ`.
|
|
3
|
+
*
|
|
4
|
+
* Writes vertex positions (`v`), texture coordinates (`vt`), normals
|
|
5
|
+
* (`vn`), faces (`f`), lines (`l`) for edges that aren't part of a face,
|
|
6
|
+
* material names (`usemtl`) and one object (`o`) per scene object.
|
|
7
|
+
*
|
|
8
|
+
* Z is flipped back and faces turned back to counter-clockwise, so
|
|
9
|
+
* `parseOBJ(exportOBJ(mesh))` gives the same mesh, and the file opens
|
|
10
|
+
* the right way round in Blender and other 3D programs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { localToWorld, rotateXYZ } from "../math/transform.js";
|
|
14
|
+
import { edgeSegments } from "../render/draw.js";
|
|
15
|
+
import { materialsOf } from "../render/canvasRenderer.js";
|
|
16
|
+
import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} ExportOBJOptions
|
|
20
|
+
* @property {boolean} [uvs=true] - Write texture coordinates. For an object whose
|
|
21
|
+
* material has a `mapping`, `textureRepeat`, `textureOffset` or `textureRotation`,
|
|
22
|
+
* the coordinates the renderer uses are written, so the texture lands the same way.
|
|
23
|
+
* @property {boolean} [normals=true] - Write the mesh's normals (`mesh.normals`), when it has them
|
|
24
|
+
* @property {boolean} [lines=true] - Write edges that aren't the side of a face as lines (`l`)
|
|
25
|
+
* @property {number} [precision=6] - Decimals kept in numbers
|
|
26
|
+
* @property {string} [header="Exported by Trid"] - Comment at the top of the file; "" for none
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Writes meshes as the text of an OBJ file.
|
|
31
|
+
*
|
|
32
|
+
* `input` can be:
|
|
33
|
+
* - a mesh (`{ vertices, edges, faces }`): written as it is
|
|
34
|
+
* - a scene object (`{ mesh, pos, rot, scale }`): its position, rotation and
|
|
35
|
+
* scale are applied, so it's written where it is in the scene
|
|
36
|
+
* - a list of meshes or objects, or a `Scene`: each becomes its own object (`o`)
|
|
37
|
+
* in the file, named after its `name` field when it has one
|
|
38
|
+
*
|
|
39
|
+
* @param {Object|Object[]} input
|
|
40
|
+
* @param {ExportOBJOptions} [opts]
|
|
41
|
+
* @returns {string} The file's text
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* download(exportOBJ(scene), "scene.obj");
|
|
45
|
+
*/
|
|
46
|
+
export function exportOBJ(input, { uvs = true, normals = true, lines = true, precision = 6, header = "Exported by Trid" } = {}) {
|
|
47
|
+
const items = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : [input];
|
|
48
|
+
const num = (x) => {
|
|
49
|
+
const s = (Math.abs(x) < 1e-12 ? 0 : x).toFixed(precision);
|
|
50
|
+
return s.includes(".") ? s.replace(/\.?0+$/, "") : s; // 1.500000 → 1.5, 2.000000 → 2
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const out = [];
|
|
54
|
+
if (header) out.push(...header.split("\n").map((l) => "# " + l));
|
|
55
|
+
let vBase = 0, tBase = 0, nBase = 0; // OBJ indices count across the whole file, from 1
|
|
56
|
+
|
|
57
|
+
items.forEach((item, index) => {
|
|
58
|
+
if (!item) return;
|
|
59
|
+
const isObject = !!item.mesh;
|
|
60
|
+
const mesh = isObject ? item.mesh : item;
|
|
61
|
+
if (!mesh?.vertices) throw new Error("exportOBJ: expected a mesh, a scene object, a list of them or a Scene");
|
|
62
|
+
const faces = mesh.faces ?? [];
|
|
63
|
+
const placed = isObject && item.pos && item.rot && item.scale;
|
|
64
|
+
|
|
65
|
+
if (items.length > 1 || item.name) out.push(`o ${item.name ?? `object${index + 1}`}`);
|
|
66
|
+
|
|
67
|
+
// Positions, in the scene's place when it's an object; Z flipped back to OBJ's right-handed axes
|
|
68
|
+
for (const v of mesh.vertices) {
|
|
69
|
+
const p = placed ? localToWorld(v, item) : v;
|
|
70
|
+
out.push(`v ${num(p.x)} ${num(p.y)} ${num(-p.z)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Texture coordinates: the ones the first material draws with
|
|
74
|
+
const mat = isObject ? materialsOf(item)?.[0] : null;
|
|
75
|
+
const source = uvs ? uvsFor(mesh, mat?.mapping) : {};
|
|
76
|
+
const xf = mat ? uvTransform(mat) : null;
|
|
77
|
+
const faceUVs = source.faceUVs, meshUVs = source.uvs;
|
|
78
|
+
const writeUVs = !!(faceUVs && meshUVs && faceUVs.some(Boolean));
|
|
79
|
+
if (writeUVs) {
|
|
80
|
+
for (const t of meshUVs) {
|
|
81
|
+
const q = applyUVTransform(xf, t);
|
|
82
|
+
out.push(`vt ${num(q.u)} ${num(q.v)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Normals: turned like the object, and flipped back like the positions
|
|
87
|
+
const writeNormals = !!(normals && mesh.normals && mesh.faceNormals?.some(Boolean));
|
|
88
|
+
if (writeNormals) {
|
|
89
|
+
for (const n of mesh.normals) {
|
|
90
|
+
let r = n;
|
|
91
|
+
if (placed) {
|
|
92
|
+
r = rotateXYZ({ x: n.x / item.scale.x, y: n.y / item.scale.y, z: n.z / item.scale.z }, item.rot);
|
|
93
|
+
const len = Math.hypot(r.x, r.y, r.z) || 1;
|
|
94
|
+
r = { x: r.x / len, y: r.y / len, z: r.z / len };
|
|
95
|
+
}
|
|
96
|
+
out.push(`vn ${num(r.x)} ${num(r.y)} ${num(-r.z)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Faces, counter-clockwise again (the reverse of parseOBJ), with material names where they change
|
|
101
|
+
const names = mesh.materialNames;
|
|
102
|
+
const mats = isObject ? materialsOf(item) : null;
|
|
103
|
+
// Only when the names mean something: from the file, or a list of materials on the object
|
|
104
|
+
const named = !!(mesh.faceMaterials && (names || mats?.length > 1));
|
|
105
|
+
let current = null;
|
|
106
|
+
faces.forEach((f, fi) => {
|
|
107
|
+
if (f.length < 3) return;
|
|
108
|
+
if (named) {
|
|
109
|
+
const k = mesh.faceMaterials[fi] ?? 0;
|
|
110
|
+
const name = names?.[k] ?? mats?.[k]?.name ?? `material${k}`;
|
|
111
|
+
if (name !== current) out.push(`usemtl ${(current = name)}`);
|
|
112
|
+
}
|
|
113
|
+
const ts = writeUVs ? faceUVs[fi] : null;
|
|
114
|
+
const ns = writeNormals ? mesh.faceNormals[fi] : null;
|
|
115
|
+
const corners = [];
|
|
116
|
+
for (let k = f.length - 1; k >= 0; k--) {
|
|
117
|
+
const v = vBase + f[k] + 1;
|
|
118
|
+
const t = ts ? tBase + ts[k] + 1 : "";
|
|
119
|
+
const n = ns ? nBase + ns[k] + 1 : "";
|
|
120
|
+
corners.push(n !== "" ? `${v}/${t}/${n}` : t !== "" ? `${v}/${t}` : `${v}`);
|
|
121
|
+
}
|
|
122
|
+
out.push("f " + corners.join(" "));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Edges that aren't the side of any face (wireframe-only meshes, or extra lines)
|
|
126
|
+
if (lines && mesh.edges) {
|
|
127
|
+
const onFace = new Set();
|
|
128
|
+
const key = (a, b) => (a < b ? a * 4294967296 + b : b * 4294967296 + a);
|
|
129
|
+
for (const f of faces) for (let k = 0; k < f.length; k++) onFace.add(key(f[k], f[(k + 1) % f.length]));
|
|
130
|
+
for (const [a, b] of edgeSegments(mesh.edges)) {
|
|
131
|
+
if (!onFace.has(key(a, b))) out.push(`l ${vBase + a + 1} ${vBase + b + 1}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
vBase += mesh.vertices.length;
|
|
136
|
+
if (writeUVs) tBase += meshUVs.length;
|
|
137
|
+
if (writeNormals) nBase += mesh.normals.length;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return out.join("\n") + "\n";
|
|
141
|
+
}
|