trid3d 0.2.0 → 0.3.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/README.md +38 -18
- package/package.json +1 -1
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +107 -21
- package/trid/index.d.ts +419 -8
- package/trid/index.js +19 -3
- package/trid/loaders/mtl.js +63 -0
- package/trid/loaders/obj.js +58 -6
- package/trid/loaders/texture.js +47 -6
- package/trid/math/matrix.js +200 -0
- package/trid/math/noise.js +162 -0
- package/trid/math/rotation.js +152 -0
- package/trid/render/canvasRenderer.js +40 -23
- package/trid/render/draw.js +6 -2
- package/trid/render/meshUtils.js +257 -0
- package/trid/render/proceduralTextures.js +211 -0
- package/trid/render/shapes.js +95 -27
- package/trid/render/webglRenderer.js +98 -27
- package/trid/scene.js +20 -9
- package/trid/sceneGraph.js +131 -0
- package/trid/tools/dragObject.js +33 -9
- package/trid/tools/helpers.js +109 -10
- package/trid/tools/hoverHighlight.js +4 -2
- package/trid/tools/index.js +3 -1
- package/trid/tools/orbitCamera.js +9 -0
- package/trid/tools/transformGizmo.js +316 -0
- package/trid/tools/viewGizmo.js +178 -0
package/README.md
CHANGED
|
@@ -10,21 +10,23 @@
|
|
|
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.
|
|
13
|
+
> **Status:** early (0.x): the API can still change between minor versions. See [Upgrading](#upgrading).
|
|
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
|
+
- **Groups** — give any object `children` and they move, turn and scale with it (models made of parts, a moon orbiting a planet).
|
|
18
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, or made from the shape (planar, box, sphere and cylinder mapping). Repeat, offset and rotate them, or give each face its own material.
|
|
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.
|
|
24
|
-
- **Export** — `scene.screenshot()` and `scene.record()` save images and videos of the canvas; `exportOBJ`
|
|
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.
|
|
25
27
|
- **Camera** — position, yaw, pitch and field of view, with near-plane clipping.
|
|
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.
|
|
27
|
-
- **Pure math** — every transform and projection step is exported, so you can use the pipeline piece by piece.
|
|
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.
|
|
29
|
+
- **Pure math** — every transform and projection step is exported, plus 4×4 matrices and rotations, so you can use the pipeline piece by piece.
|
|
28
30
|
|
|
29
31
|
## Install
|
|
30
32
|
|
|
@@ -36,7 +38,7 @@ No bundler? Use the CDN with an import map, or copy the `trid/` folder next to y
|
|
|
36
38
|
|
|
37
39
|
```html
|
|
38
40
|
<script type="importmap">
|
|
39
|
-
{ "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.
|
|
41
|
+
{ "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.3/trid/index.js" } }
|
|
40
42
|
</script>
|
|
41
43
|
```
|
|
42
44
|
|
|
@@ -67,8 +69,8 @@ Without a bundler, serve the folder (`python3 -m http.server`) instead of openin
|
|
|
67
69
|
```js
|
|
68
70
|
import { loadOBJ } from "trid3d";
|
|
69
71
|
|
|
70
|
-
const mesh = await loadOBJ("models/teapot.obj", { fit: 1 }); // centered, 1 unit wide
|
|
71
|
-
scene.add({ mesh });
|
|
72
|
+
const mesh = await loadOBJ("models/teapot.obj", { fit: 1, materials: true }); // centered, 1 unit wide
|
|
73
|
+
scene.add({ mesh, material: mesh.materials }); // colors and textures from its .mtl file
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
Or let users load their own: `scene.use(new ObjUploader())` accepts dropped `.obj` files, and `uploader.open()` shows a file dialog.
|
|
@@ -79,12 +81,14 @@ Or let users load their own: `scene.use(new ObjUploader())` accepts dropped `.ob
|
|
|
79
81
|
| --- | --- |
|
|
80
82
|
| `trid/index.js` | Entry file: exports everything (`"trid3d"`) |
|
|
81
83
|
| `trid/scene.js` | The `Scene` class |
|
|
82
|
-
| `trid/
|
|
83
|
-
| `trid/
|
|
84
|
-
| `trid/
|
|
85
|
-
| `trid/
|
|
86
|
-
| `trid/
|
|
84
|
+
| `trid/sceneGraph.js` | Groups: `children`, world matrices |
|
|
85
|
+
| `trid/math/` | Transforms, camera directions, projection, matrices, rotations, random numbers and noise |
|
|
86
|
+
| `trid/render/` | Renderers (Canvas 2D, WebGL), effects, shapes, mesh helpers, procedural textures, drawing, frame loop |
|
|
87
|
+
| `trid/loaders/` | OBJ, MTL and texture loading |
|
|
88
|
+
| `trid/export/` | Screenshots, video recording, OBJ and MTL export |
|
|
89
|
+
| `trid/tools/` | Camera, object, gizmo and overlay tools |
|
|
87
90
|
| `docs/` | Documentation site with live demos |
|
|
91
|
+
| `test/` | Tests (`npm test`) |
|
|
88
92
|
|
|
89
93
|
## Documentation
|
|
90
94
|
|
|
@@ -99,12 +103,28 @@ npm run docs:dev
|
|
|
99
103
|
|
|
100
104
|
See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.md) for building and editing the docs.
|
|
101
105
|
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npm test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes, mesh helpers, noise, textures, OBJ / MTL, picking, tools, groups and matrices, and the WebGL renderer's GPU memory on a fake WebGL context.
|
|
113
|
+
|
|
114
|
+
## Upgrading
|
|
115
|
+
|
|
116
|
+
### From 0.2 to 0.3
|
|
117
|
+
|
|
118
|
+
- **Canvas textures are read once.** A canvas used as a `texture` is now uploaded once (faster, and sharper with mipmaps). If you draw on it again, call `updateTexture(canvas)` after drawing, or give its material `liveTexture: true` to read it every frame. Videos still play on their own. See [Changing a canvas](https://zachr622.github.io/Trid/textures#changing-a-canvas).
|
|
119
|
+
- **The WebGL renderer frees unused GPU memory**: meshes and textures not drawn for 300 frames are freed (and sent again if they come back). Set `new WebGLRenderer({ releaseAfter: Infinity })` to keep the old behavior.
|
|
120
|
+
- **Objects can have `children`**, and `mesh` is optional for a group. Code that walks `scene.objects` only sees the top level; use `worldObjects(scene)` to include children.
|
|
121
|
+
- `parseOBJ` now sets `materialNames` / `faceMaterials` for a file with a single material too.
|
|
122
|
+
|
|
102
123
|
## Roadmap
|
|
103
124
|
|
|
104
125
|
Roughly in order:
|
|
105
126
|
|
|
106
|
-
- **More import and export formats** —
|
|
107
|
-
- **
|
|
108
|
-
- **More tools** — a gizmo to move, rotate and scale objects with the mouse, a selection outline, a grid/axes helper
|
|
127
|
+
- **More import and export formats** — STL; basic glTF later
|
|
128
|
+
- **More tools** — a selection outline, a grid helper
|
|
109
129
|
- **Post effects** (WebGL) — bloom, vignette, outlines, antialiasing and color grading over the whole image
|
|
110
|
-
- **More tests** —
|
|
130
|
+
- **More tests** — the canvas renderer's drawing and the remaining math functions
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/trid/export/obj.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* the right way round in Blender and other 3D programs.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { transformPoint, normalMatrix, transformNormal, determinantMat4 } from "../math/matrix.js";
|
|
14
|
+
import { forEachPlaced, worldMatrix } from "../sceneGraph.js";
|
|
14
15
|
import { edgeSegments } from "../render/draw.js";
|
|
15
16
|
import { materialsOf } from "../render/canvasRenderer.js";
|
|
16
17
|
import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
@@ -24,8 +25,90 @@ import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
|
24
25
|
* @property {boolean} [lines=true] - Write edges that aren't the side of a face as lines (`l`)
|
|
25
26
|
* @property {number} [precision=6] - Decimals kept in numbers
|
|
26
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
|
|
27
30
|
*/
|
|
28
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
|
+
|
|
29
112
|
/**
|
|
30
113
|
* Writes meshes as the text of an OBJ file.
|
|
31
114
|
*
|
|
@@ -33,6 +116,8 @@ import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
|
33
116
|
* - a mesh (`{ vertices, edges, faces }`): written as it is
|
|
34
117
|
* - a scene object (`{ mesh, pos, rot, scale }`): its position, rotation and
|
|
35
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
|
|
36
121
|
* - a list of meshes or objects, or a `Scene`: each becomes its own object (`o`)
|
|
37
122
|
* in the file, named after its `name` field when it has one
|
|
38
123
|
*
|
|
@@ -43,8 +128,7 @@ import { uvsFor, uvTransform, applyUVTransform } from "../render/uvMapping.js";
|
|
|
43
128
|
* @example
|
|
44
129
|
* download(exportOBJ(scene), "scene.obj");
|
|
45
130
|
*/
|
|
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];
|
|
131
|
+
export function exportOBJ(input, { uvs = true, normals = true, lines = true, precision = 6, header = "Exported by Trid", mtllib } = {}) {
|
|
48
132
|
const num = (x) => {
|
|
49
133
|
const s = (Math.abs(x) < 1e-12 ? 0 : x).toFixed(precision);
|
|
50
134
|
return s.includes(".") ? s.replace(/\.?0+$/, "") : s; // 1.500000 → 1.5, 2.000000 → 2
|
|
@@ -52,21 +136,25 @@ export function exportOBJ(input, { uvs = true, normals = true, lines = true, pre
|
|
|
52
136
|
|
|
53
137
|
const out = [];
|
|
54
138
|
if (header) out.push(...header.split("\n").map((l) => "# " + l));
|
|
139
|
+
if (mtllib) out.push(`mtllib ${mtllib}`);
|
|
55
140
|
let vBase = 0, tBase = 0, nBase = 0; // OBJ indices count across the whole file, from 1
|
|
56
141
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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) => {
|
|
61
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;
|
|
62
149
|
const faces = mesh.faces ?? [];
|
|
63
|
-
|
|
150
|
+
// A mirrored object's faces are already turned around once
|
|
151
|
+
const mirrored = world ? determinantMat4(world) < 0 : false;
|
|
64
152
|
|
|
65
|
-
if (
|
|
153
|
+
if (entries.length > 1 || item.name) out.push(`o ${item.name ?? `object${index + 1}`}`);
|
|
66
154
|
|
|
67
155
|
// Positions, in the scene's place when it's an object; Z flipped back to OBJ's right-handed axes
|
|
68
156
|
for (const v of mesh.vertices) {
|
|
69
|
-
const p =
|
|
157
|
+
const p = world ? transformPoint(world, v) : v;
|
|
70
158
|
out.push(`v ${num(p.x)} ${num(p.y)} ${num(-p.z)}`);
|
|
71
159
|
}
|
|
72
160
|
|
|
@@ -86,13 +174,9 @@ export function exportOBJ(input, { uvs = true, normals = true, lines = true, pre
|
|
|
86
174
|
// Normals: turned like the object, and flipped back like the positions
|
|
87
175
|
const writeNormals = !!(normals && mesh.normals && mesh.faceNormals?.some(Boolean));
|
|
88
176
|
if (writeNormals) {
|
|
177
|
+
const nm = world ? normalMatrix(world) : null;
|
|
89
178
|
for (const n of mesh.normals) {
|
|
90
|
-
|
|
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
|
-
}
|
|
179
|
+
const r = nm ? transformNormal(nm, n) : n;
|
|
96
180
|
out.push(`vn ${num(r.x)} ${num(r.y)} ${num(-r.z)}`);
|
|
97
181
|
}
|
|
98
182
|
}
|
|
@@ -100,20 +184,22 @@ export function exportOBJ(input, { uvs = true, normals = true, lines = true, pre
|
|
|
100
184
|
// Faces, counter-clockwise again (the reverse of parseOBJ), with material names where they change
|
|
101
185
|
const names = mesh.materialNames;
|
|
102
186
|
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
|
-
|
|
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));
|
|
105
190
|
let current = null;
|
|
106
191
|
faces.forEach((f, fi) => {
|
|
107
192
|
if (f.length < 3) return;
|
|
108
193
|
if (named) {
|
|
109
|
-
const k = mesh.faceMaterials[fi] ?? 0;
|
|
110
|
-
const name = names?.[k] ?? mats?.[k]?.name ?? `material${k}`;
|
|
194
|
+
const k = mesh.faceMaterials?.[fi] ?? 0;
|
|
195
|
+
const name = slots ? slots.nameOf(index, k) : names?.[k] ?? mats?.[k]?.name ?? `material${k}`;
|
|
111
196
|
if (name !== current) out.push(`usemtl ${(current = name)}`);
|
|
112
197
|
}
|
|
113
198
|
const ts = writeUVs ? faceUVs[fi] : null;
|
|
114
199
|
const ns = writeNormals ? mesh.faceNormals[fi] : null;
|
|
115
200
|
const corners = [];
|
|
116
|
-
|
|
201
|
+
const order = mirrored ? f.map((_, k) => k) : f.map((_, k) => f.length - 1 - k);
|
|
202
|
+
for (const k of order) {
|
|
117
203
|
const v = vBase + f[k] + 1;
|
|
118
204
|
const t = ts ? tBase + ts[k] + 1 : "";
|
|
119
205
|
const n = ns ? nBase + ns[k] + 1 : "";
|