trid3d 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,19 +10,22 @@
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 and shaded; textures and shaders are on the way.
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 spheres built in, or any mesh you define as vertices and edges.
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, 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
+ - **Mesh helpers** — clone, transform, displace, merge and measure meshes; seeded random numbers and smooth noise (`fbm2`, `fbm3`, tileable) for rocks, terrain and procedural textures.
19
21
  - **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.
22
+ - **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. Or paint one in code: wood, marble, bricks and more, all seamless.
21
23
  - **Effects** — stack shader effects like toon, rim and edge glow, gradients and fog on a material, or write your own in GLSL.
22
24
  - **Two renderers** — the canvas 2D context by default, or `WebGLRenderer` for exact depth and big models, with the same scene code.
23
- - **OBJ files** — load models from a URL, or let users drop their own `.obj` files on the canvas.
25
+ - **OBJ files** — load models from a URL (with their `.mtl` colors and textures), or let users drop their own `.obj` files on the canvas.
26
+ - **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.
24
27
  - **Camera** — position, yaw, pitch and field of view, with near-plane clipping.
25
- - **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.
28
+ - **Tools** — orbit and fly cameras, object dragging, hover highlight, a move / rotate / scale gizmo, a view axes gizmo, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles. Writing your own is a plain object with a few methods.
26
29
  - **Pure math** — every transform and projection step is exported, so you can use the pipeline piece by piece.
27
30
 
28
31
  ## Install
@@ -81,6 +84,7 @@ Or let users load their own: `scene.use(new ObjUploader())` accepts dropped `.ob
81
84
  | `trid/math/` | Transforms, camera directions, projection |
82
85
  | `trid/render/` | Renderers (Canvas 2D, WebGL), effects, shapes, drawing, frame loop |
83
86
  | `trid/loaders/` | OBJ and texture loading |
87
+ | `trid/export/` | Screenshots, video recording and OBJ export |
84
88
  | `trid/tools/` | Camera, object and overlay tools |
85
89
  | `docs/` | Documentation site with live demos |
86
90
 
@@ -99,7 +103,9 @@ See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.
99
103
 
100
104
  ## Roadmap
101
105
 
102
- - **More shapes** — cylinder, cone, torus, plane/grid
103
- - **More tools** — rotate/scale objects with the mouse, selection outline
104
- - **Parent/child transforms** — e.g. a moon orbiting a planet
105
- - **Tests** for the math functions
106
+ Roughly in order:
107
+
108
+ - **More import and export formats** — STL; basic glTF later
109
+ - **More tools** — a selection outline, a grid helper
110
+ - **Post effects** (WebGL) — bloom, vignette, outlines, antialiasing and color grading over the whole image
111
+ - **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.1.1",
3
+ "version": "0.3.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,119 @@
1
+ /**
2
+ * MTL export: the material file that goes with an OBJ file (colors,
3
+ * see-through and texture image names, one `newmtl` per material).
4
+ *
5
+ * The names match the `usemtl` lines `exportOBJ(input, { mtllib })` writes
6
+ * for the same input. `exportModel` gives all the files at once.
7
+ */
8
+
9
+ import { exportEntries, exportOBJ, materialSlots } from "./obj.js";
10
+ import { colorBytes } from "../render/proceduralTextures.js";
11
+ import { textureReady, textureSize } from "../loaders/texture.js";
12
+
13
+ /**
14
+ * The materials an export will name, with the file name for each texture.
15
+ *
16
+ * @param {Object|Object[]} input - An object, a group, a list of them or a Scene
17
+ * @param {Object} [opts]
18
+ * @param {(name:string, texture:Object) => string} [opts.textureFile] - File name for a material's
19
+ * texture (default: the material's name + ".png")
20
+ * @returns {{ name:string, material:Object|null, texture:Object|null, file:string|null }[]}
21
+ */
22
+ export function objMaterials(input, { textureFile = (name) => `${name}.png` } = {}) {
23
+ const { list } = materialSlots(exportEntries(input));
24
+ const files = new Map(); // one file per texture image, even when materials share it
25
+ return list.map(({ name, material }) => {
26
+ const texture = material?.texture ?? null;
27
+ if (texture && !files.has(texture)) files.set(texture, textureFile(name, texture));
28
+ return { name, material, texture, file: texture ? files.get(texture) : null };
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Writes the text of an MTL file.
34
+ *
35
+ * `input` can be what `exportOBJ` takes (an object, a group, a list or a
36
+ * Scene: their materials are written, with the same names), or a list of
37
+ * plain material descriptions `{ name, fill?, opacity?, texture? }` where
38
+ * `texture` is the image's file name.
39
+ *
40
+ * Each material gets `Kd` (its fill color; white under a texture), `d` when
41
+ * it's see-through, and `map_Kd` with the texture's file name.
42
+ *
43
+ * @example
44
+ * download(exportOBJ(scene, { mtllib: "scene.mtl" }), "scene.obj");
45
+ * download(exportMTL(scene), "scene.mtl");
46
+ *
47
+ * @param {Object|Object[]} input
48
+ * @param {Object} [opts]
49
+ * @param {(name:string, texture:Object) => string} [opts.textureFile] - See `objMaterials`
50
+ * @param {string} [opts.header="Exported by Trid"] - Comment at the top; "" for none
51
+ * @returns {string}
52
+ */
53
+ export function exportMTL(input, { textureFile, header = "Exported by Trid" } = {}) {
54
+ const items = Array.isArray(input) ? input : [input];
55
+ const plain = items.length > 0 && items.every((m) => m && !m.mesh && !m.children && !m.vertices && !m.objects && "name" in m);
56
+ const rows = plain
57
+ ? items.map((m) => ({ name: m.name, material: { fill: m.fill, opacity: m.opacity }, file: typeof m.texture === "string" ? m.texture : null }))
58
+ : objMaterials(input, { textureFile });
59
+
60
+ const num = (x) => (Math.round(x * 1e4) / 1e4).toString();
61
+ const out = header ? header.split("\n").map((l) => "# " + l) : [];
62
+ for (const { name, material, file } of rows) {
63
+ const fill = material?.fill ?? (file ? "#ffffff" : "#cccccc");
64
+ const [r, g, b, a] = colorBytes(fill);
65
+ const alpha = a * (material?.opacity ?? 1);
66
+ out.push("", `newmtl ${name}`);
67
+ out.push(`Kd ${num(r / 255)} ${num(g / 255)} ${num(b / 255)}`);
68
+ out.push("Ks 0 0 0", "illum 1");
69
+ if (alpha < 1) out.push(`d ${num(alpha)}`);
70
+ if (file) out.push(`map_Kd ${file}`);
71
+ }
72
+ return out.join("\n") + "\n";
73
+ }
74
+
75
+ /**
76
+ * A texture image as a PNG (or JPEG / WebP) file, to save next to an MTL file.
77
+ *
78
+ * @param {CanvasImageSource} src - An image, canvas, ImageBitmap or video frame
79
+ * @param {string} [type="image/png"]
80
+ * @returns {Promise<Blob>}
81
+ */
82
+ export async function textureToBlob(src, type = "image/png") {
83
+ if (!textureReady(src)) throw new Error("textureToBlob: the texture isn't loaded yet");
84
+ const { w, h } = textureSize(src);
85
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : Object.assign(document.createElement("canvas"), { width: w, height: h });
86
+ canvas.getContext("2d").drawImage(src, 0, 0, w, h);
87
+ if (canvas.convertToBlob) return canvas.convertToBlob({ type });
88
+ return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Can't encode the texture"))), type));
89
+ }
90
+
91
+ /**
92
+ * Every file of a model, ready to save: the OBJ, its MTL and one image per
93
+ * texture. Save them in the same folder (or zip them) and the model opens
94
+ * with its colors and textures in Blender and other 3D programs.
95
+ *
96
+ * @example
97
+ * const files = await exportModel(scene, { name: "sword" });
98
+ * for (const f of files) await download(f.data, f.name);
99
+ *
100
+ * @param {Object|Object[]} input - An object, a group, a list of them or a Scene
101
+ * @param {Object} [opts]
102
+ * @param {string} [opts.name="model"] - Base name: model.obj, model.mtl
103
+ * @param {Object} [opts.obj] - More `exportOBJ` options
104
+ * @returns {Promise<{ name:string, data:string|Blob }[]>}
105
+ */
106
+ export async function exportModel(input, { name = "model", obj = {} } = {}) {
107
+ const textureFile = (mat) => `${name}_${mat}.png`;
108
+ const files = [
109
+ { name: `${name}.obj`, data: exportOBJ(input, { ...obj, mtllib: `${name}.mtl` }) },
110
+ { name: `${name}.mtl`, data: exportMTL(input, { textureFile }) },
111
+ ];
112
+ const saved = new Set();
113
+ for (const { texture, file } of objMaterials(input, { textureFile })) {
114
+ if (!texture || saved.has(file)) continue;
115
+ saved.add(file);
116
+ files.push({ name: file, data: await textureToBlob(texture) });
117
+ }
118
+ return files;
119
+ }
@@ -0,0 +1,227 @@
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 { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
14
+ import { forEachPlaced, worldMatrix } from "../sceneGraph.js";
15
+ import { edgeSegments } from "../render/draw.js";
16
+ import { materialsOf } from "../render/canvasRenderer.js";
17
+ import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
18
+
19
+ /**
20
+ * @typedef {Object} ExportOBJOptions
21
+ * @property {boolean} [uvs=true] - Write texture coordinates. For an object whose
22
+ * material has a `mapping`, `textureRepeat`, `textureOffset` or `textureRotation`,
23
+ * the coordinates the renderer uses are written, so the texture lands the same way.
24
+ * @property {boolean} [normals=true] - Write the mesh's normals (`mesh.normals`), when it has them
25
+ * @property {boolean} [lines=true] - Write edges that aren't the side of a face as lines (`l`)
26
+ * @property {number} [precision=6] - Decimals kept in numbers
27
+ * @property {string} [header="Exported by Trid"] - Comment at the top of the file; "" for none
28
+ * @property {string} [mtllib] - Name of a .mtl file to link (`mtllib`): every object's materials
29
+ * then get a `usemtl` name, the same ones `exportMTL` writes for the same input
30
+ */
31
+
32
+ /**
33
+ * The meshes an export input holds, each with the object it belongs to and
34
+ * its world matrix (null for a plain mesh). Groups are walked.
35
+ *
36
+ * @param {Object|Object[]} input - A mesh, an object, a group, a list of them or a Scene
37
+ * @returns {{ item:Object, mesh:Object, world:number[]|null }[]}
38
+ */
39
+ export function exportEntries(input) {
40
+ const items = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : [input];
41
+ const entries = [];
42
+ for (const item of items) {
43
+ if (!item) continue;
44
+ if (item.vertices) entries.push({ item, mesh: item, world: null });
45
+ else if (item.mesh || item.children) {
46
+ forEachPlaced([item], (root) => (root.parent ? worldMatrix(root.parent) : null), (obj, world) => {
47
+ if (obj.mesh) entries.push({ item: obj, mesh: obj.mesh, world });
48
+ });
49
+ } else {
50
+ throw new Error("exportOBJ: expected a mesh, a scene object, a list of them or a Scene");
51
+ }
52
+ }
53
+ return entries;
54
+ }
55
+
56
+ /** The OBJ object name of an entry. */
57
+ const objectName = (entry, index, count) => entry.item.name ?? (count > 1 ? `object${index + 1}` : "object");
58
+
59
+ /** A name safe for OBJ / MTL lines (no spaces). */
60
+ const safe = (name) => String(name).trim().replace(/\s+/g, "_") || "material";
61
+
62
+ /**
63
+ * The material names of an export, shared by `exportOBJ` (with `mtllib`) and
64
+ * `exportMTL`: one per material object (shared materials get one name), named
65
+ * after `material.name`, else after the object (`sword`, or `sword_1` for its
66
+ * second material). A mesh without materials keeps its OBJ material names.
67
+ *
68
+ * @param {{ item:Object, mesh:Object, world:number[]|null }[]} entries - From `exportEntries`
69
+ * @returns {{ nameOf:(entryIndex:number, k:number) => string|null,
70
+ * list:{ name:string, material:Object|null }[] }}
71
+ */
72
+ export function materialSlots(entries) {
73
+ const byMaterial = new Map(); // material object → name
74
+ const used = new Set();
75
+ const list = [];
76
+ const unique = (base) => {
77
+ let name = safe(base), n = 2;
78
+ while (used.has(name)) name = `${safe(base)}_${n++}`;
79
+ used.add(name);
80
+ return name;
81
+ };
82
+ const slots = entries.map((entry, index) => {
83
+ const mats = entry.world ? materialsOf(entry.item) : null;
84
+ const objName = objectName(entry, index, entries.length);
85
+ if (mats) {
86
+ return mats.map((mat, k) => {
87
+ if (!mat) return null;
88
+ if (!byMaterial.has(mat)) {
89
+ const name = unique(mat.name ?? (mats.length > 1 ? `${objName}_${k}` : objName));
90
+ byMaterial.set(mat, name);
91
+ list.push({ name, material: mat });
92
+ }
93
+ return byMaterial.get(mat);
94
+ });
95
+ }
96
+ // No material objects: names from the OBJ file it came from, if any
97
+ return (entry.mesh.materialNames ?? []).map((n) => {
98
+ const name = safe(n);
99
+ if (!used.has(name)) {
100
+ used.add(name);
101
+ list.push({ name, material: null });
102
+ }
103
+ return name;
104
+ });
105
+ });
106
+ return {
107
+ nameOf: (i, k) => slots[i]?.[k] ?? slots[i]?.[0] ?? null,
108
+ list,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Writes meshes as the text of an OBJ file.
114
+ *
115
+ * `input` can be:
116
+ * - a mesh (`{ vertices, edges, faces }`): written as it is
117
+ * - a scene object (`{ mesh, pos, rot, scale }`): its position, rotation and
118
+ * scale are applied, so it's written where it is in the scene
119
+ * - a group (an object with `children`): every mesh in it is written, each
120
+ * where it's drawn, as its own object
121
+ * - a list of meshes or objects, or a `Scene`: each becomes its own object (`o`)
122
+ * in the file, named after its `name` field when it has one
123
+ *
124
+ * @param {Object|Object[]} input
125
+ * @param {ExportOBJOptions} [opts]
126
+ * @returns {string} The file's text
127
+ *
128
+ * @example
129
+ * download(exportOBJ(scene), "scene.obj");
130
+ */
131
+ export function exportOBJ(input, { uvs = true, normals = true, lines = true, precision = 6, header = "Exported by Trid", mtllib } = {}) {
132
+ const num = (x) => {
133
+ const s = (Math.abs(x) < 1e-12 ? 0 : x).toFixed(precision);
134
+ return s.includes(".") ? s.replace(/\.?0+$/, "") : s; // 1.500000 → 1.5, 2.000000 → 2
135
+ };
136
+
137
+ const out = [];
138
+ if (header) out.push(...header.split("\n").map((l) => "# " + l));
139
+ if (mtllib) out.push(`mtllib ${mtllib}`);
140
+ let vBase = 0, tBase = 0, nBase = 0; // OBJ indices count across the whole file, from 1
141
+
142
+ // Plain meshes as they are; objects and their children where they're drawn (world matrix)
143
+ const entries = exportEntries(input);
144
+ const slots = mtllib ? materialSlots(entries) : null;
145
+
146
+ entries.forEach(({ item, mesh, world }, index) => {
147
+ if (!mesh?.vertices) throw new Error("exportOBJ: expected a mesh, a scene object, a list of them or a Scene");
148
+ const isObject = !!world;
149
+ const faces = mesh.faces ?? [];
150
+ // A mirrored object's faces are already turned around once
151
+ const mirrored = world ? determinantMat4(world) < 0 : false;
152
+
153
+ if (entries.length > 1 || item.name) out.push(`o ${item.name ?? `object${index + 1}`}`);
154
+
155
+ // Positions, in the scene's place when it's an object; Z flipped back to OBJ's right-handed axes
156
+ for (const v of mesh.vertices) {
157
+ const p = world ? transformPoint(world, v) : v;
158
+ out.push(`v ${num(p.x)} ${num(p.y)} ${num(-p.z)}`);
159
+ }
160
+
161
+ // Texture coordinates: the ones the first material draws with
162
+ const mat = isObject ? materialsOf(item)?.[0] : null;
163
+ const source = uvs ? uvsFor(mesh, mat?.mapping) : {};
164
+ const xf = mat ? uvTransform(mat) : null;
165
+ const faceUVs = source.faceUVs, meshUVs = source.uvs;
166
+ const writeUVs = !!(faceUVs && meshUVs && faceUVs.some(Boolean));
167
+ if (writeUVs) {
168
+ for (const t of meshUVs) {
169
+ const q = applyUVTransform(xf, t);
170
+ out.push(`vt ${num(q.u)} ${num(q.v)}`);
171
+ }
172
+ }
173
+
174
+ // Normals: turned like the object, and flipped back like the positions
175
+ const writeNormals = !!(normals && mesh.normals && mesh.faceNormals?.some(Boolean));
176
+ if (writeNormals) {
177
+ const nm = world ? normalMatrix(world) : null;
178
+ for (const n of mesh.normals) {
179
+ const r = nm ? transformNormal(nm, n) : n;
180
+ out.push(`vn ${num(r.x)} ${num(r.y)} ${num(-r.z)}`);
181
+ }
182
+ }
183
+
184
+ // Faces, counter-clockwise again (the reverse of parseOBJ), with material names where they change
185
+ const names = mesh.materialNames;
186
+ const mats = isObject ? materialsOf(item) : null;
187
+ // Only when the names mean something: from the file, or a list of materials on the object.
188
+ // With an .mtl file, every material gets its name (the one exportMTL writes)
189
+ const named = slots ? slots.nameOf(index, 0) !== null : !!(mesh.faceMaterials && (names || mats?.length > 1));
190
+ let current = null;
191
+ faces.forEach((f, fi) => {
192
+ if (f.length < 3) return;
193
+ if (named) {
194
+ const k = mesh.faceMaterials?.[fi] ?? 0;
195
+ const name = slots ? slots.nameOf(index, k) : names?.[k] ?? mats?.[k]?.name ?? `material${k}`;
196
+ if (name !== current) out.push(`usemtl ${(current = name)}`);
197
+ }
198
+ const ts = writeUVs ? faceUVs[fi] : null;
199
+ const ns = writeNormals ? mesh.faceNormals[fi] : null;
200
+ const corners = [];
201
+ const order = mirrored ? f.map((_, k) => k) : f.map((_, k) => f.length - 1 - k);
202
+ for (const k of order) {
203
+ const v = vBase + f[k] + 1;
204
+ const t = ts ? tBase + ts[k] + 1 : "";
205
+ const n = ns ? nBase + ns[k] + 1 : "";
206
+ corners.push(n !== "" ? `${v}/${t}/${n}` : t !== "" ? `${v}/${t}` : `${v}`);
207
+ }
208
+ out.push("f " + corners.join(" "));
209
+ });
210
+
211
+ // Edges that aren't the side of any face (wireframe-only meshes, or extra lines)
212
+ if (lines && mesh.edges) {
213
+ const onFace = new Set();
214
+ const key = (a, b) => (a < b ? a * 4294967296 + b : b * 4294967296 + a);
215
+ for (const f of faces) for (let k = 0; k < f.length; k++) onFace.add(key(f[k], f[(k + 1) % f.length]));
216
+ for (const [a, b] of edgeSegments(mesh.edges)) {
217
+ if (!onFace.has(key(a, b))) out.push(`l ${vBase + a + 1} ${vBase + b + 1}`);
218
+ }
219
+ }
220
+
221
+ vBase += mesh.vertices.length;
222
+ if (writeUVs) tBase += meshUVs.length;
223
+ if (writeNormals) nBase += mesh.normals.length;
224
+ });
225
+
226
+ return out.join("\n") + "\n";
227
+ }