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
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
-
import { clear, drawInstance, edgeSegments } from "./draw.js";
|
|
2
|
+
import { clear, drawInstance, edgeSegments, wireEdges } from "./draw.js";
|
|
3
3
|
import { parseColor } from "./color.js";
|
|
4
4
|
import { newell, cornerNormals, creaseOf } from "./normals.js";
|
|
5
5
|
import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
|
|
6
|
+
import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
|
|
6
7
|
|
|
7
8
|
export { newell };
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* An object's materials as a list (`material` can be one material or a
|
|
12
|
+
* list of them, picked per face by `mesh.faceMaterials`). Null when the
|
|
13
|
+
* object is a wireframe.
|
|
14
|
+
*
|
|
15
|
+
* @returns {Material[]|null}
|
|
16
|
+
*/
|
|
17
|
+
export function materialsOf(obj) {
|
|
18
|
+
const m = obj.material;
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
if (!Array.isArray(m)) return [m];
|
|
21
|
+
return m.length && m[0] ? m : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
/** Direction light comes from when the scene doesn't set one: above, left, in front. */
|
|
10
25
|
export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
|
|
11
26
|
|
|
@@ -16,6 +31,10 @@ export const DEFAULT_AMBIENT = 0.25;
|
|
|
16
31
|
* Material: how an object's faces look. Objects without one are drawn
|
|
17
32
|
* as wireframes using their `color` and `lineWidth`.
|
|
18
33
|
*
|
|
34
|
+
* An object's `material` can also be a list: each face then uses the
|
|
35
|
+
* entry `mesh.faceMaterials[face]` points at (the first one by default).
|
|
36
|
+
* Built-in cubes and boxes number their sides 0 to 5.
|
|
37
|
+
*
|
|
19
38
|
* @typedef {Object} Material
|
|
20
39
|
* @property {string} [fill] - Face color, any CSS color (default: the scene's color)
|
|
21
40
|
* @property {boolean} [shade=true] - Darken faces that turn away from the light
|
|
@@ -26,6 +45,11 @@ export const DEFAULT_AMBIENT = 0.25;
|
|
|
26
45
|
* @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
|
|
27
46
|
* built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
|
|
28
47
|
* @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
|
|
48
|
+
* @property {import("./uvMapping.js").Mapping} [mapping="uv"] - Where texture coordinates come
|
|
49
|
+
* from: the mesh's own ("uv"), or made from its shape ("planar", "box", "sphere", "cylinder")
|
|
50
|
+
* @property {number|{u:number,v:number}} [textureRepeat=1] - How many times the image repeats across
|
|
51
|
+
* @property {{u:number,v:number}} [textureOffset] - Moves the image (1 = its whole width or height)
|
|
52
|
+
* @property {number} [textureRotation=0] - Turns the image around its center, in radians
|
|
29
53
|
* @property {Object[]} [effects] - Effects applied in order, like `[toon(), rim()]`.
|
|
30
54
|
* See trid/render/effects.js. The canvas renderer runs them once per face.
|
|
31
55
|
*/
|
|
@@ -79,7 +103,7 @@ export class CanvasRenderer {
|
|
|
79
103
|
this._frame++;
|
|
80
104
|
|
|
81
105
|
// Wireframes only: nothing to sort
|
|
82
|
-
if (!scene.objects.some((o) => o
|
|
106
|
+
if (!scene.objects.some((o) => materialsOf(o))) {
|
|
83
107
|
for (const obj of scene.objects) {
|
|
84
108
|
drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth);
|
|
85
109
|
}
|
|
@@ -97,17 +121,17 @@ export class CanvasRenderer {
|
|
|
97
121
|
const background = this._rgba(ctx, scene.background);
|
|
98
122
|
|
|
99
123
|
for (const obj of scene.objects) {
|
|
124
|
+
const mats = materialsOf(obj);
|
|
100
125
|
// Split big faces (cached per mesh); extra vertices are midpoints of existing ones
|
|
101
|
-
const split =
|
|
126
|
+
const split = mats && this.maxFaceSize ? this._split(obj.mesh) : null;
|
|
102
127
|
const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
|
|
103
128
|
const world = verts.map((v) => localToWorld(v, obj));
|
|
104
129
|
const cam = world.map((p) => worldToCamera(p, camera));
|
|
105
130
|
const color = obj.color ?? scene.color;
|
|
106
131
|
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
107
|
-
const mat = obj.material;
|
|
108
132
|
|
|
109
|
-
if (!
|
|
110
|
-
for (const [i, j] of edgeSegments(obj.mesh
|
|
133
|
+
if (!mats) {
|
|
134
|
+
for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
|
|
111
135
|
const seg = clipNear(cam[i], cam[j], near);
|
|
112
136
|
if (!seg) continue;
|
|
113
137
|
items.push({
|
|
@@ -119,19 +143,14 @@ export class CanvasRenderer {
|
|
|
119
143
|
continue;
|
|
120
144
|
}
|
|
121
145
|
|
|
122
|
-
const fill = mat.fill ?? (mat.texture ? "#ffffff" : scene.color);
|
|
123
|
-
const shade = mat.shade !== false;
|
|
124
|
-
const outline = mat.wireframe ? color : null;
|
|
125
|
-
|
|
126
|
-
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
127
|
-
const crease = shade ? creaseOf(mat.smooth) : null;
|
|
128
|
-
const corners = crease ? cornerNormals(obj.mesh, crease) : null;
|
|
129
146
|
const sourceFaces = obj.mesh.faces ?? [];
|
|
130
147
|
const base = obj.mesh.vertices.length;
|
|
131
|
-
|
|
148
|
+
|
|
149
|
+
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
150
|
+
const normalAt = (corners, F, i) => {
|
|
132
151
|
if (i < base) return corners[F][sourceFaces[F].indexOf(i)];
|
|
133
152
|
const [a, b] = split.mids[i - base];
|
|
134
|
-
const na = normalAt(F, a), nb = normalAt(F, b);
|
|
153
|
+
const na = normalAt(corners, F, a), nb = normalAt(corners, F, b);
|
|
135
154
|
return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
|
|
136
155
|
};
|
|
137
156
|
const lightAt = (n, back) => {
|
|
@@ -140,47 +159,69 @@ export class CanvasRenderer {
|
|
|
140
159
|
const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
|
|
141
160
|
return ambient + (1 - ambient) * Math.max(0, d);
|
|
142
161
|
};
|
|
143
|
-
|
|
144
|
-
// Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
|
|
145
|
-
const meshUVs = obj.mesh.uvs, faceUVs = obj.mesh.faceUVs;
|
|
146
|
-
let tex = null;
|
|
147
|
-
if (mat.texture && textureReady(mat.texture)) {
|
|
148
|
-
if (faceUVs && meshUVs) tex = mat.texture;
|
|
149
|
-
else if (!this._warned.has(obj.mesh)) {
|
|
150
|
-
this._warned.add(obj.mesh);
|
|
151
|
-
console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const uvAt = (F, i) => {
|
|
155
|
-
const t = faceUVs?.[F];
|
|
156
|
-
if (!t || !meshUVs) return null;
|
|
157
|
-
if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
|
|
158
|
-
const [a, b] = split.mids[i - base];
|
|
159
|
-
const ta = uvAt(F, a), tb = uvAt(F, b);
|
|
160
|
-
return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
|
|
161
|
-
};
|
|
162
162
|
const flatLight = (face, back) => {
|
|
163
163
|
const nw = normalize(newell(face.map((i) => world[i])));
|
|
164
164
|
const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
165
165
|
return ambient + (1 - ambient) * Math.max(0, d);
|
|
166
166
|
};
|
|
167
|
-
|
|
168
|
-
// Effects run once per face, at its center (see trid/render/effects.js)
|
|
169
|
-
const effects = mat.effects?.length ? mat.effects : null;
|
|
170
167
|
const toWorldNormal = (n) =>
|
|
171
168
|
normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
|
|
172
169
|
|
|
170
|
+
// Everything a material needs, worked out once per object and frame
|
|
171
|
+
const prepare = (mat) => {
|
|
172
|
+
const shade = mat.shade !== false;
|
|
173
|
+
const crease = shade ? creaseOf(mat.smooth) : null;
|
|
174
|
+
// Texture coordinates: the mesh's own, or made from its shape (mat.mapping)
|
|
175
|
+
const { uvs: meshUVs, faceUVs } = uvsFor(obj.mesh, mat.mapping);
|
|
176
|
+
const xf = uvTransform(mat);
|
|
177
|
+
const rawUV = (F, i) => {
|
|
178
|
+
const t = faceUVs?.[F];
|
|
179
|
+
if (!t || !meshUVs) return null;
|
|
180
|
+
if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
|
|
181
|
+
const [a, b] = split.mids[i - base];
|
|
182
|
+
const ta = rawUV(F, a), tb = rawUV(F, b);
|
|
183
|
+
return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
|
|
184
|
+
};
|
|
185
|
+
let tex = null;
|
|
186
|
+
if (mat.texture && textureReady(mat.texture)) {
|
|
187
|
+
if (faceUVs && meshUVs) tex = mat.texture;
|
|
188
|
+
else if (!this._warned.has(obj.mesh)) {
|
|
189
|
+
this._warned.add(obj.mesh);
|
|
190
|
+
console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
mat, shade, tex,
|
|
195
|
+
fill: mat.fill ?? (mat.texture ? "#ffffff" : scene.color),
|
|
196
|
+
outline: mat.wireframe ? color : null,
|
|
197
|
+
corners: crease ? cornerNormals(obj.mesh, crease) : null,
|
|
198
|
+
effects: mat.effects?.length ? mat.effects : null,
|
|
199
|
+
uvAt: (F, i) => applyUVTransform(xf, rawUV(F, i)),
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
// Which material each face uses (mesh.faceMaterials), prepared when first needed
|
|
203
|
+
const looks = new Array(mats.length);
|
|
204
|
+
const faceMats = obj.mesh.faceMaterials;
|
|
205
|
+
const lookOf = (F) => {
|
|
206
|
+
let k = faceMats?.[F] ?? 0;
|
|
207
|
+
if (!(k >= 0 && k < mats.length && mats[k])) k = 0;
|
|
208
|
+
return (looks[k] ??= prepare(mats[k]));
|
|
209
|
+
};
|
|
210
|
+
|
|
173
211
|
const faces = split ? split.faces : sourceFaces;
|
|
174
212
|
for (let f = 0; f < faces.length; f++) {
|
|
175
213
|
const face = faces[f];
|
|
176
214
|
if (face.length < 3) continue;
|
|
215
|
+
const F = split ? split.origins[f] : f; // the mesh face this one comes from
|
|
216
|
+
const L = lookOf(F);
|
|
217
|
+
const { fill, shade, outline, corners } = L;
|
|
177
218
|
const pts = face.map((i) => cam[i]);
|
|
178
219
|
|
|
179
220
|
// Seen from behind? (camera sits at the origin of camera space)
|
|
180
221
|
const n = newell(pts);
|
|
181
222
|
const c = centroid(pts);
|
|
182
223
|
const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
|
|
183
|
-
if (back && !mat.doubleSided) continue;
|
|
224
|
+
if (back && !L.mat.doubleSided) continue;
|
|
184
225
|
|
|
185
226
|
const clipped = clipPolygon(pts, near);
|
|
186
227
|
if (clipped.length < 3) continue;
|
|
@@ -188,13 +229,13 @@ export class CanvasRenderer {
|
|
|
188
229
|
// Which edges are real outlines (split faces have extra inner edges); null = all
|
|
189
230
|
const edges = split && clipped === pts ? split.masks[f] : null;
|
|
190
231
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const ts = face.map((i) => uvAt(F, i));
|
|
232
|
+
// Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
|
|
233
|
+
if (L.tex && clipped === pts) {
|
|
234
|
+
const ts = face.map((i) => L.uvAt(F, i));
|
|
194
235
|
if (ts.every(Boolean)) {
|
|
195
236
|
let lit;
|
|
196
237
|
if (!shade) lit = face.map(() => 1);
|
|
197
|
-
else if (corners) lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
238
|
+
else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
|
|
198
239
|
else lit = face.map(() => flatLight(face, back));
|
|
199
240
|
const screen = pts.map(toScreen);
|
|
200
241
|
const n = face.length;
|
|
@@ -203,7 +244,7 @@ export class CanvasRenderer {
|
|
|
203
244
|
items.push({
|
|
204
245
|
depth: c.z,
|
|
205
246
|
poly: [screen[0], screen[k], screen[k + 1]],
|
|
206
|
-
texture: { src: tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!mat.pixelated, tint: fill },
|
|
247
|
+
texture: { src: L.tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!L.mat.pixelated, tint: fill },
|
|
207
248
|
lit: [lit[0], lit[k], lit[k + 1]],
|
|
208
249
|
outline, lineWidth,
|
|
209
250
|
edges: n === 3 ? edges : fan,
|
|
@@ -213,12 +254,12 @@ export class CanvasRenderer {
|
|
|
213
254
|
}
|
|
214
255
|
}
|
|
215
256
|
|
|
216
|
-
|
|
257
|
+
// Effects run once per face, at its center (see trid/render/effects.js)
|
|
258
|
+
if (L.effects) {
|
|
217
259
|
// Normal: the face's own, or the average of its corners when smooth
|
|
218
260
|
let nw;
|
|
219
261
|
if (corners) {
|
|
220
|
-
const
|
|
221
|
-
const sum = face.map((i) => toWorldNormal(normalAt(F, i)))
|
|
262
|
+
const sum = face.map((i) => toWorldNormal(normalAt(corners, F, i)))
|
|
222
263
|
.reduce((a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }), { x: 0, y: 0, z: 0 });
|
|
223
264
|
nw = normalize(sum);
|
|
224
265
|
} else {
|
|
@@ -230,16 +271,16 @@ export class CanvasRenderer {
|
|
|
230
271
|
const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
|
|
231
272
|
const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
232
273
|
const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
|
|
233
|
-
const
|
|
274
|
+
const rgba = this._rgba(ctx, fill);
|
|
234
275
|
const s = {
|
|
235
|
-
base, normal: nw, position, local: centroid(face.map((i) => verts[i])),
|
|
276
|
+
base: rgba, normal: nw, position, local: centroid(face.map((i) => verts[i])),
|
|
236
277
|
view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
|
|
237
|
-
uv: centerUV(face.map((i) => uvAt(
|
|
278
|
+
uv: centerUV(face.map((i) => L.uvAt(F, i))),
|
|
238
279
|
depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
|
|
239
280
|
rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
|
|
240
281
|
};
|
|
241
|
-
let col = [
|
|
242
|
-
for (const e of effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
|
|
282
|
+
let col = [rgba[0] * lit, rgba[1] * lit, rgba[2] * lit, rgba[3]];
|
|
283
|
+
for (const e of L.effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
|
|
243
284
|
const k = col.map((x) => Math.min(1, Math.max(0, x)));
|
|
244
285
|
items.push({
|
|
245
286
|
depth: c.z,
|
|
@@ -251,8 +292,7 @@ export class CanvasRenderer {
|
|
|
251
292
|
}
|
|
252
293
|
|
|
253
294
|
if (corners) {
|
|
254
|
-
const
|
|
255
|
-
const lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
295
|
+
const lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
|
|
256
296
|
if (clipped === pts) {
|
|
257
297
|
// One gradient per triangle: brightness changes linearly across a triangle
|
|
258
298
|
const screen = pts.map(toScreen);
|
|
@@ -276,14 +316,7 @@ export class CanvasRenderer {
|
|
|
276
316
|
continue;
|
|
277
317
|
}
|
|
278
318
|
|
|
279
|
-
|
|
280
|
-
if (shade) {
|
|
281
|
-
const nw = normalize(newell(face.map((i) => world[i])));
|
|
282
|
-
const s = back ? -1 : 1;
|
|
283
|
-
const d = s * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
284
|
-
brightness = ambient + (1 - ambient) * Math.max(0, d);
|
|
285
|
-
}
|
|
286
|
-
|
|
319
|
+
const brightness = shade ? flatLight(face, back) : 1;
|
|
287
320
|
items.push({
|
|
288
321
|
depth: c.z,
|
|
289
322
|
poly: clipped.map(toScreen),
|
package/trid/render/draw.js
CHANGED
|
@@ -59,11 +59,11 @@ export function line(ctx, p1, p2, color, width = 4) {
|
|
|
59
59
|
* - An edge with 3+ indices is a closed loop: the last index
|
|
60
60
|
* connects back to the first
|
|
61
61
|
*
|
|
62
|
-
* @param {number[][]} edges - Edge definitions using vertex indices
|
|
62
|
+
* @param {number[][]} [edges] - Edge definitions using vertex indices (none: no segments)
|
|
63
63
|
* @returns {Generator<[number, number]>} Index pairs to connect
|
|
64
64
|
*/
|
|
65
65
|
export function* edgeSegments(edges) {
|
|
66
|
-
for (const e of edges) {
|
|
66
|
+
for (const e of edges ?? []) {
|
|
67
67
|
const closed = e.length > 2;
|
|
68
68
|
const count = closed ? e.length : e.length - 1;
|
|
69
69
|
for (let i = 0; i < count; i++) {
|
|
@@ -72,6 +72,17 @@ export function* edgeSegments(edges) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The lines to draw for a mesh shown as a wireframe: its `edges`, or the
|
|
77
|
+
* outline of each face when it has no `edges` (a face-only mesh).
|
|
78
|
+
*
|
|
79
|
+
* @param {{ edges?: number[][], faces?: number[][] }} mesh
|
|
80
|
+
* @returns {number[][]} Edge definitions (faces are closed loops)
|
|
81
|
+
*/
|
|
82
|
+
export function wireEdges(mesh) {
|
|
83
|
+
return mesh.edges ?? mesh.faces ?? [];
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
/**
|
|
76
87
|
* Draws a wireframe object by connecting projected vertices
|
|
77
88
|
* using index-based edge definitions.
|
|
@@ -115,7 +126,7 @@ export function drawWireframe(ctx, points2, edges, color, width = 4) {
|
|
|
115
126
|
*
|
|
116
127
|
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
117
128
|
* @param {{
|
|
118
|
-
* mesh:{ vertices:{x:number,y:number,z:number}[], edges
|
|
129
|
+
* mesh:{ vertices:{x:number,y:number,z:number}[], edges?:number[][], faces?:number[][] },
|
|
119
130
|
* pos:{x:number,y:number,z:number},
|
|
120
131
|
* rot:{x:number,y:number,z:number},
|
|
121
132
|
* scale:{x:number,y:number,z:number}
|
|
@@ -134,7 +145,7 @@ export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
|
|
|
134
145
|
ctx.strokeStyle = color;
|
|
135
146
|
|
|
136
147
|
ctx.beginPath();
|
|
137
|
-
for (const [i, j] of edgeSegments(inst.mesh
|
|
148
|
+
for (const [i, j] of edgeSegments(wireEdges(inst.mesh))) {
|
|
138
149
|
const seg = clipNear(ptsCam[i], ptsCam[j], near);
|
|
139
150
|
if (!seg) continue;
|
|
140
151
|
const a = cameraToScreen(seg[0], camera, w, h);
|
package/trid/render/shapes.js
CHANGED
|
@@ -45,6 +45,8 @@ function rectPrismWire(w = 0.6, h = 0.3, d = 0.4) {
|
|
|
45
45
|
[0, 3, 7, 4], // right
|
|
46
46
|
[1, 5, 6, 2], // left
|
|
47
47
|
],
|
|
48
|
+
// Which material each face uses when `material` is a list: one per side, in the order above
|
|
49
|
+
faceMaterials: [0, 1, 2, 3, 4, 5],
|
|
48
50
|
};
|
|
49
51
|
return withFaceUVs(mesh);
|
|
50
52
|
}
|
|
@@ -161,6 +163,268 @@ function sphereWire(radius = 0.35, rings = 10, segments = 16) {
|
|
|
161
163
|
return { vertices, edges, faces, uvs, faceUVs };
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Spins a profile around the Y axis: vases, bottles, cups, columns, chess
|
|
168
|
+
* pieces. Cylinders, cones and tori are built with it.
|
|
169
|
+
*
|
|
170
|
+
* Walk the profile so the outside is on its right in the (r, y) plane:
|
|
171
|
+
* bottom to top for a vase. Faces are clockwise as seen from outside.
|
|
172
|
+
*
|
|
173
|
+
* Texture coordinates: u goes around, v follows the profile's length.
|
|
174
|
+
* Caps map the texture as a disc. Edges are the profile's rings plus one
|
|
175
|
+
* line along the profile per segment.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* // A vase
|
|
179
|
+
* const vase = lathe(
|
|
180
|
+
* [{ r: 0.2, y: -0.5 }, { r: 0.35, y: -0.2 }, { r: 0.15, y: 0.3 }, { r: 0.2, y: 0.5 }],
|
|
181
|
+
* { capBottom: true },
|
|
182
|
+
* );
|
|
183
|
+
*
|
|
184
|
+
* @param {{r:number, y:number}[]} profile - Points: distance from the Y axis and height
|
|
185
|
+
* @param {Object} [opts]
|
|
186
|
+
* @param {number} [opts.segments=32] - Points around the axis
|
|
187
|
+
* @param {boolean} [opts.capBottom=false] - Close the bottom with a flat cap
|
|
188
|
+
* @param {boolean} [opts.capTop=false] - Close the top with a flat cap
|
|
189
|
+
* @param {boolean} [opts.closed=false] - The last point joins the first (torus-like shapes)
|
|
190
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
191
|
+
*/
|
|
192
|
+
export function lathe(profile, { segments = 32, capBottom = false, capTop = false, closed = false } = {}) {
|
|
193
|
+
segments = Math.max(3, Math.floor(segments));
|
|
194
|
+
const vertices = [];
|
|
195
|
+
const edges = [];
|
|
196
|
+
const faces = [];
|
|
197
|
+
const uvs = [];
|
|
198
|
+
const faceUVs = [];
|
|
199
|
+
const uv = (u, v) => uvs.push({ u, v }) - 1;
|
|
200
|
+
|
|
201
|
+
// v along the profile, by length
|
|
202
|
+
const pts = closed ? [...profile, profile[0]] : profile;
|
|
203
|
+
const lengths = [0];
|
|
204
|
+
for (let i = 1; i < pts.length; i++) {
|
|
205
|
+
lengths.push(lengths[i - 1] + Math.hypot(pts[i].r - pts[i - 1].r, pts[i].y - pts[i - 1].y));
|
|
206
|
+
}
|
|
207
|
+
const total = lengths[lengths.length - 1] || 1;
|
|
208
|
+
|
|
209
|
+
for (const p of profile) {
|
|
210
|
+
for (let s = 0; s < segments; s++) {
|
|
211
|
+
const t = (s / segments) * Math.PI * 2;
|
|
212
|
+
vertices.push({ x: Math.cos(t) * p.r, y: p.y, z: Math.sin(t) * p.r });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const rows = pts.length - 1;
|
|
217
|
+
for (let r = 0; r < rows; r++) {
|
|
218
|
+
const a = r * segments;
|
|
219
|
+
const b = ((r + 1) % profile.length) * segments;
|
|
220
|
+
for (let s = 0; s < segments; s++) {
|
|
221
|
+
const s1 = (s + 1) % segments;
|
|
222
|
+
faces.push([a + s, b + s, b + s1, a + s1]);
|
|
223
|
+
const u0 = s / segments, u1 = (s + 1) / segments;
|
|
224
|
+
const v0 = lengths[r] / total, v1 = lengths[r + 1] / total;
|
|
225
|
+
faceUVs.push([uv(u0, v0), uv(u0, v1), uv(u1, v1), uv(u1, v0)]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Rings (skipped where the profile touches the axis) and lines along the profile
|
|
230
|
+
profile.forEach((p, r) => {
|
|
231
|
+
if (p.r > 1e-5) edges.push(Array.from({ length: segments }, (_, s) => r * segments + s));
|
|
232
|
+
});
|
|
233
|
+
for (let s = 0; s < segments; s++) {
|
|
234
|
+
if (closed && profile.length > 2) edges.push(profile.map((_, r) => r * segments + s));
|
|
235
|
+
else for (let r = 0; r < profile.length - 1; r++) edges.push([r * segments + s, (r + 1) * segments + s]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const disc = (s) => {
|
|
239
|
+
const t = (s / segments) * Math.PI * 2;
|
|
240
|
+
return uv(0.5 + Math.cos(t) * 0.5, 0.5 + Math.sin(t) * 0.5);
|
|
241
|
+
};
|
|
242
|
+
if (capBottom && !closed && profile[0].r > 0) {
|
|
243
|
+
const c = vertices.push({ x: 0, y: profile[0].y, z: 0 }) - 1;
|
|
244
|
+
for (let s = 0; s < segments; s++) {
|
|
245
|
+
faces.push([c, s, (s + 1) % segments]);
|
|
246
|
+
faceUVs.push([uv(0.5, 0.5), disc(s), disc(s + 1)]);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const last = profile.length - 1;
|
|
250
|
+
if (capTop && !closed && profile[last].r > 0) {
|
|
251
|
+
const c = vertices.push({ x: 0, y: profile[last].y, z: 0 }) - 1;
|
|
252
|
+
const a = last * segments;
|
|
253
|
+
for (let s = 0; s < segments; s++) {
|
|
254
|
+
faces.push([c, a + (s + 1) % segments, a + s]);
|
|
255
|
+
faceUVs.push([uv(0.5, 0.5), disc(s + 1), disc(s)]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { vertices, edges, faces, uvs, faceUVs };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A capped cylinder along Y, centered on the origin. */
|
|
263
|
+
function cylinder(r = 0.3, h = 0.8, segments = 32) {
|
|
264
|
+
return lathe([{ r, y: -h / 2 }, { r, y: h / 2 }], { segments, capBottom: true, capTop: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A cone along Y with its tip up, or a tapered cylinder when `rTop` > 0. */
|
|
268
|
+
function cone(r = 0.35, h = 0.8, segments = 32, rTop = 0) {
|
|
269
|
+
// A tiny top ring keeps the tip's faces non-degenerate for UVs and normals
|
|
270
|
+
const profile = [{ r, y: -h / 2 }, { r: rTop > 0 ? rTop : 1e-6, y: h / 2 }];
|
|
271
|
+
return lathe(profile, { segments, capBottom: true, capTop: rTop > 0 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A ring in the XZ plane: `R` from the center to the middle of the tube. */
|
|
275
|
+
function torus(R = 0.35, tube = 0.12, segments = 48, tubeSegments = 20) {
|
|
276
|
+
tubeSegments = Math.max(3, Math.floor(tubeSegments));
|
|
277
|
+
const profile = [];
|
|
278
|
+
for (let i = 0; i < tubeSegments; i++) {
|
|
279
|
+
const p = (i / tubeSegments) * Math.PI * 2;
|
|
280
|
+
profile.push({ r: R + Math.cos(p) * tube, y: Math.sin(p) * tube });
|
|
281
|
+
}
|
|
282
|
+
return lathe(profile, { segments, closed: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A flat grid in the XZ plane facing +Y, `size` wide, with `div` cells per
|
|
287
|
+
* side. `height(x, z)` lifts each vertex (terrain). The texture covers the
|
|
288
|
+
* whole grid once.
|
|
289
|
+
*/
|
|
290
|
+
function plane(size = 1, div = 8, height = null) {
|
|
291
|
+
div = Math.max(1, Math.floor(div));
|
|
292
|
+
const vertices = [];
|
|
293
|
+
const uvs = [];
|
|
294
|
+
const n = div + 1;
|
|
295
|
+
const k = (i, j) => j * n + i;
|
|
296
|
+
for (let j = 0; j < n; j++) {
|
|
297
|
+
for (let i = 0; i < n; i++) {
|
|
298
|
+
const x = (i / div - 0.5) * size, z = (j / div - 0.5) * size;
|
|
299
|
+
vertices.push({ x, y: height ? height(x, z) : 0, z });
|
|
300
|
+
uvs.push({ u: i / div, v: j / div });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const faces = [];
|
|
304
|
+
const edges = [];
|
|
305
|
+
for (let j = 0; j < div; j++) {
|
|
306
|
+
for (let i = 0; i < div; i++) faces.push([k(i + 1, j + 1), k(i + 1, j), k(i, j), k(i, j + 1)]);
|
|
307
|
+
}
|
|
308
|
+
for (let j = 0; j < n; j++) {
|
|
309
|
+
for (let i = 0; i < div; i++) {
|
|
310
|
+
edges.push([k(i, j), k(i + 1, j)]);
|
|
311
|
+
edges.push([k(j, i), k(j, i + 1)]);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Grid vertices and UVs share indices
|
|
315
|
+
return { vertices, edges, faces, uvs, faceUVs: faces.map((f) => [...f]) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Twice the signed area: > 0 when the points go counter-clockwise (x right, y up). */
|
|
319
|
+
function signedArea(pts) {
|
|
320
|
+
let a = 0;
|
|
321
|
+
for (let i = 0; i < pts.length; i++) {
|
|
322
|
+
const p = pts[i], q = pts[(i + 1) % pts.length];
|
|
323
|
+
a += p.x * q.y - q.x * p.y;
|
|
324
|
+
}
|
|
325
|
+
return a;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Cuts a simple polygon (no holes, sides don't cross) into triangles, by
|
|
330
|
+
* ear clipping. Works for concave outlines. Falls back to a fan if the
|
|
331
|
+
* outline crosses itself.
|
|
332
|
+
*
|
|
333
|
+
* @param {{x:number, y:number}[]} pts - The outline, counter-clockwise (x right, y up)
|
|
334
|
+
* @returns {number[][]} Index triples into `pts`, counter-clockwise too
|
|
335
|
+
*/
|
|
336
|
+
export function triangulate(pts) {
|
|
337
|
+
const idx = pts.map((_, i) => i);
|
|
338
|
+
const tris = [];
|
|
339
|
+
const cross = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
340
|
+
const inside = (p, a, b, c) => cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0;
|
|
341
|
+
|
|
342
|
+
let guard = idx.length * idx.length;
|
|
343
|
+
while (idx.length > 3 && guard-- > 0) {
|
|
344
|
+
let clipped = false;
|
|
345
|
+
for (let i = 0; i < idx.length; i++) {
|
|
346
|
+
const i0 = idx[(i + idx.length - 1) % idx.length], i1 = idx[i], i2 = idx[(i + 1) % idx.length];
|
|
347
|
+
const a = pts[i0], b = pts[i1], c = pts[i2];
|
|
348
|
+
if (cross(a, b, c) <= 1e-12) continue; // reflex or flat corner
|
|
349
|
+
if (idx.some((j) => j !== i0 && j !== i1 && j !== i2 && inside(pts[j], a, b, c))) continue;
|
|
350
|
+
tris.push([i0, i1, i2]);
|
|
351
|
+
idx.splice(i, 1);
|
|
352
|
+
clipped = true;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
if (!clipped) break;
|
|
356
|
+
}
|
|
357
|
+
if (idx.length === 3) tris.push([idx[0], idx[1], idx[2]]);
|
|
358
|
+
else for (let i = 1; i < idx.length - 1; i++) tris.push([idx[0], idx[i], idx[i + 1]]);
|
|
359
|
+
return tris;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Turns a flat outline in the XY plane into a solid `depth` thick along Z,
|
|
364
|
+
* centered on the origin: blades, axes, keys, stars, letters, floor plans.
|
|
365
|
+
* The outline may go either way round and may be concave (no holes).
|
|
366
|
+
*
|
|
367
|
+
* Texture coordinates: the front and back map the outline's bounding box,
|
|
368
|
+
* the sides wrap u along the outline and v across the depth. Edges are the
|
|
369
|
+
* front and back outlines plus a line at each corner.
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* const star = Array.from({ length: 10 }, (_, i) => {
|
|
373
|
+
* const a = (i / 10) * Math.PI * 2, r = i % 2 ? 0.2 : 0.5;
|
|
374
|
+
* return { x: Math.cos(a) * r, y: Math.sin(a) * r };
|
|
375
|
+
* });
|
|
376
|
+
* scene.add({ mesh: extrude(star, 0.15), material: { fill: "#fc5" } });
|
|
377
|
+
*
|
|
378
|
+
* @param {{x:number, y:number}[]} outline - The flat shape (3 points or more)
|
|
379
|
+
* @param {number} [depth=0.2] - Thickness along Z
|
|
380
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
381
|
+
*/
|
|
382
|
+
export function extrude(outline, depth = 0.2) {
|
|
383
|
+
const pts = signedArea(outline) < 0 ? [...outline].reverse() : outline;
|
|
384
|
+
const n = pts.length;
|
|
385
|
+
const vertices = [];
|
|
386
|
+
const faces = [];
|
|
387
|
+
const uvs = [];
|
|
388
|
+
const faceUVs = [];
|
|
389
|
+
const uv = (u, v) => uvs.push({ u, v }) - 1;
|
|
390
|
+
|
|
391
|
+
// 0..n-1 at the front (-z), n..2n-1 at the back (+z)
|
|
392
|
+
for (const p of pts) vertices.push({ x: p.x, y: p.y, z: -depth / 2 });
|
|
393
|
+
for (const p of pts) vertices.push({ x: p.x, y: p.y, z: depth / 2 });
|
|
394
|
+
|
|
395
|
+
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
|
|
396
|
+
const x0 = Math.min(...xs), y0 = Math.min(...ys);
|
|
397
|
+
const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
|
|
398
|
+
const capUV = (i) => uv((pts[i].x - x0) / w, (pts[i].y - y0) / h);
|
|
399
|
+
|
|
400
|
+
// Caps: counter-clockwise outlines look clockwise from the back (+z), so the
|
|
401
|
+
// back cap keeps that order and the front cap (seen from -z) is reversed
|
|
402
|
+
for (const [a, b, c] of triangulate(pts)) {
|
|
403
|
+
faces.push([n + a, n + b, n + c]);
|
|
404
|
+
faceUVs.push([capUV(a), capUV(b), capUV(c)]);
|
|
405
|
+
faces.push([c, b, a]);
|
|
406
|
+
faceUVs.push([capUV(c), capUV(b), capUV(a)]);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Sides, u by length along the outline
|
|
410
|
+
const len = [0];
|
|
411
|
+
for (let i = 0; i < n; i++) len.push(len[i] + Math.hypot(pts[(i + 1) % n].x - pts[i].x, pts[(i + 1) % n].y - pts[i].y));
|
|
412
|
+
const total = len[n] || 1;
|
|
413
|
+
for (let i = 0; i < n; i++) {
|
|
414
|
+
const j = (i + 1) % n;
|
|
415
|
+
const ua = len[i] / total, ub = len[i + 1] / total;
|
|
416
|
+
faces.push([n + j, n + i, i, j]);
|
|
417
|
+
faceUVs.push([uv(ub, 1), uv(ua, 1), uv(ua, 0), uv(ub, 0)]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const edges = [
|
|
421
|
+
pts.map((_, i) => i),
|
|
422
|
+
pts.map((_, i) => n + i),
|
|
423
|
+
...pts.map((_, i) => [i, n + i]),
|
|
424
|
+
];
|
|
425
|
+
return { vertices, edges, faces, uvs, faceUVs };
|
|
426
|
+
}
|
|
427
|
+
|
|
164
428
|
/**
|
|
165
429
|
* Scales a mesh along the X, Y, and Z axes.
|
|
166
430
|
*
|
|
@@ -192,32 +456,26 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
|
|
|
192
456
|
}
|
|
193
457
|
|
|
194
458
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* This provides a unified API for constructing different meshes
|
|
198
|
-
* (cube, box, sphere) while keeping the rendering pipeline generic.
|
|
199
|
-
*
|
|
200
|
-
* Supported shapes:
|
|
201
|
-
* - "cube" → wireframe cube
|
|
202
|
-
* - "box" → rectangular prism (box)
|
|
203
|
-
* - "sphere" → wireframe sphere
|
|
459
|
+
* Creates a built-in shape, centered on the origin.
|
|
204
460
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
461
|
+
* | Kind | Options (defaults) |
|
|
462
|
+
* | --- | --- |
|
|
463
|
+
* | `"cube"` | `size` (0.5) |
|
|
464
|
+
* | `"box"` | `w`, `h`, `d` (0.6 each) |
|
|
465
|
+
* | `"sphere"` | `r` (0.45), `rings` (12), `segments` (24) |
|
|
466
|
+
* | `"cylinder"` | `r` (0.3), `h` (0.8), `segments` (32) |
|
|
467
|
+
* | `"cone"` | `r` (0.35), `h` (0.8), `segments` (32), `rTop` (0: pointed; > 0: tapered cylinder) |
|
|
468
|
+
* | `"torus"` | `R` (0.35, center to tube middle), `tube` (0.12), `segments` (48), `tubeSegments` (20) |
|
|
469
|
+
* | `"plane"` | `size` (1), `div` (8 cells per side), `height` ((x, z) => y, optional) |
|
|
207
470
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* @param {number} [opts.h] - Box height
|
|
213
|
-
* @param {number} [opts.d] - Box depth
|
|
214
|
-
* @param {number} [opts.r] - Sphere radius
|
|
215
|
-
* @param {number} [opts.rings] - Number of latitude rings (sphere)
|
|
216
|
-
* @param {number} [opts.segments] - Segments per ring (sphere)
|
|
217
|
-
*
|
|
218
|
-
* @returns {{ vertices: {x:number,y:number,z:number}[], edges: number[][] }}
|
|
219
|
-
* A wireframe mesh object
|
|
471
|
+
* Cylinders, cones and tori stand along Y; the plane lies in XZ facing +Y.
|
|
472
|
+
* Every shape has edges (for wireframes), faces (clockwise from outside)
|
|
473
|
+
* and texture coordinates. For other round or flat shapes, see `lathe`
|
|
474
|
+
* and `extrude`.
|
|
220
475
|
*
|
|
476
|
+
* @param {string} kind - Shape name (case doesn't matter)
|
|
477
|
+
* @param {Object} [opts] - Size options for that shape (see the table)
|
|
478
|
+
* @returns {import("../index.d.ts").Mesh}
|
|
221
479
|
* @throws {Error} If the shape type is not supported
|
|
222
480
|
*/
|
|
223
481
|
export function makeShape(kind, opts = {}) {
|
|
@@ -225,6 +483,10 @@ export function makeShape(kind, opts = {}) {
|
|
|
225
483
|
case "cube": return cubeWire(opts.size ?? 0.5);
|
|
226
484
|
case "box": return rectPrismWire(opts.w ?? 0.6, opts.h ?? 0.6, opts.d ?? 0.6);
|
|
227
485
|
case "sphere":return sphereWire(opts.r ?? 0.45, opts.rings ?? 12, opts.segments ?? 24);
|
|
486
|
+
case "cylinder": return cylinder(opts.r, opts.h, opts.segments);
|
|
487
|
+
case "cone": return cone(opts.r, opts.h, opts.segments, opts.rTop);
|
|
488
|
+
case "torus": return torus(opts.R, opts.tube, opts.segments, opts.tubeSegments);
|
|
489
|
+
case "plane": return plane(opts.size, opts.div, opts.height);
|
|
228
490
|
default: throw new Error("Unknown shape: " + kind);
|
|
229
491
|
}
|
|
230
492
|
}
|