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.
@@ -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.material)) {
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 = obj.material && this.maxFaceSize ? this._split(obj.mesh) : null;
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 (!mat) {
110
- for (const [i, j] of edgeSegments(obj.mesh.edges)) {
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
- const normalAt = (F, i) => {
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
- if (tex && clipped === pts) {
192
- const F = split ? split.origins[f] : f;
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
- if (effects) {
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 F = split ? split.origins[f] : f;
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 base = this._rgba(ctx, fill);
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(split ? split.origins[f] : f, i))),
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 = [base[0] * lit, base[1] * lit, base[2] * lit, base[3]];
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 F = split ? split.origins[f] : f;
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
- let brightness = 1;
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),
@@ -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:number[][] },
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.edges)) {
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);
@@ -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
- * Factory function that creates a predefined wireframe shape.
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
- * Each shape type accepts its own configuration options via `opts`.
206
- * Unspecified options fall back to sensible defaults.
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
- * @param {string} kind - Shape type identifier
209
- * @param {Object} opts - Shape configuration options
210
- * @param {number} [opts.size] - Cube size
211
- * @param {number} [opts.w] - Box width
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
  }