trid3d 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,15 +20,15 @@ Trid gives you a scene, shapes you can model in code, lights, effects, model loa
20
20
  - **Modeling** — smooth curves through a few points (`spline`), shapes swept along a 3D path (`sweep`: tails, horns, pipes, rails), surfaces through cross-sections (`loft`: bodies, bottles, hulls, heads), subdivision surfaces with creases (`subdivideMesh`: box modeling), shapes that melt together (`blobMesh`: creatures, clay; or any distance field with `sdfMesh`), rounded or cut edges on any mesh (`bevelMesh`), taper, twist, bend and bulge deformers, mesh cleanup and checks (weld, clean, validate, fix winding), and rays, inside tests and distances against meshes and scenes (`meshQuery`, `raycastScene`).
21
21
  - **Mesh helpers** — clone, transform, displace, merge and measure meshes; seeded random numbers and smooth noise (`fbm2`, `fbm3`, tileable) for rocks, terrain and procedural textures.
22
22
  - **Filled faces** — give an object a `material` to fill and shade its faces, flat or smooth.
23
- - **Lights** — ambient, directional and point lights, with colors and intensities, as many as you need (up to 8 at once besides ambient).
23
+ - **Lights and shadows** — ambient, directional and point lights, with colors and intensities, as many as you need (up to 8 at once besides ambient). A directional light can cast soft shadows (a shadow map with WebGL, ground shadows on the canvas).
24
24
  - **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.
25
25
  - **Effects** — stack shader effects like toon, rim and edge glow, gradients and fog on a material, or write your own in GLSL.
26
- - **Post effects** — bloom, vignette, color grading and film grain over the whole picture, or your own full-screen shaders (several passes if needed).
26
+ - **Post effects** — bloom, vignette, color grading, film grain and cartoon outlines over the whole picture, or your own full-screen shaders (several passes if needed, reading the scene's depth and normals if wanted).
27
27
  - **Two renderers** — the canvas 2D context by default, or `WebGLRenderer` for exact depth and big models, with the same scene code.
28
28
  - **OBJ files** — load models from a URL (with their `.mtl` colors and textures), or let users drop their own `.obj` files on the canvas.
29
29
  - **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.
30
30
  - **Camera** — position, yaw, pitch and field of view (50° by default), with near-plane clipping.
31
- - **Tools** — orbit and fly cameras, object dragging, hover highlight, a move / rotate / scale gizmo (with a pivot of your choice), a view axes gizmo, a floor grid, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles, with the hit point and texture coordinates under the mouse. Writing your own is a plain object with a few methods.
31
+ - **Tools** — orbit and fly cameras, object dragging, hover highlight, a selection outline, a move / rotate / scale gizmo (with a pivot of your choice), a view axes gizmo, a floor grid, an FPS meter and an OBJ uploader, one line each. Exact picking by triangles, with the hit point and texture coordinates under the mouse. Writing your own is a plain object with a few methods.
32
32
  - **Pure math** — every transform and projection step is exported, plus 4×4 matrices and rotations, so you can use the pipeline piece by piece.
33
33
 
34
34
  ## Install
@@ -41,7 +41,7 @@ No bundler? Use the CDN with an import map, or copy the `trid/` folder next to y
41
41
 
42
42
  ```html
43
43
  <script type="importmap">
44
- { "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.6/trid/index.js" } }
44
+ { "imports": { "trid3d": "https://cdn.jsdelivr.net/npm/trid3d@0.7/trid/index.js" } }
45
45
  </script>
46
46
  ```
47
47
 
@@ -122,10 +122,17 @@ See [`docs/README.md`](https://github.com/ZachR622/Trid/blob/master/docs/README.
122
122
  npm test
123
123
  ```
124
124
 
125
- Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes (lathe poles included), mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, mesh and scene queries, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, post effects, grids, the field of view, see-through backgrounds, and the WebGL renderer's GPU memory on a fake WebGL context.
125
+ Uses Node's built-in test runner (Node 21+), so there's nothing to install. The tests cover shapes (lathe poles included), mesh helpers, curves, sweeps, lofts, subdivision, blobs, bevels, deformers, mesh cleanup, mesh and scene queries, noise, textures, OBJ / MTL, picking, tools, groups and matrices, lights, shadows, post effects, grids, the field of view, see-through backgrounds, and the WebGL renderer's GPU memory on a fake WebGL context.
126
126
 
127
127
  ## Upgrading
128
128
 
129
+ ### From 0.6 to 0.7
130
+
131
+ Nothing changes for existing scenes: shadows, outlines and the selection outline are all off until asked for. Only names Trid now uses itself could clash with yours:
132
+
133
+ - In **post effect GLSL**, `Frame` has two more fields (`f.near`, `f.far`), and the uniforms `uNear`, `uFar`, `uGeometry`, `uGeoNear` and `uGeoRange` are taken. A post effect declaring its own uniform with one of those names needs to rename it.
134
+ - In **material effect GLSL**, the functions `tridShadow` and `tridShadowDepth` and the uniforms starting with `uShadow` (plus `uReceive`) are taken. Effect uniforms are renamed by Trid, so only helper functions with those names would clash.
135
+
129
136
  ### From 0.5 to 0.6
130
137
 
131
138
  - **The cone's tip is a single point** (a pole), so the cone is closed: `isClosed` and `validateMesh` no longer report a hole there. It has fewer vertices than before (its tip ring of 32 is now 1), and its side faces are triangles. Any `lathe` profile point with `r: 0` works the same way.
@@ -150,7 +157,8 @@ Uses Node's built-in test runner (Node 21+), so there's nothing to install. The
150
157
 
151
158
  Roughly in order:
152
159
 
153
- - **More import and export formats** — STL; basic glTF later
154
- - **More tools** — a selection outline
155
- - **More post effects** — outlines (from depth), antialiasing (FXAA)
160
+ - **More shadows** — point lights casting shadows, and several lights at once
161
+ - **Combining shapes** — union, subtract and intersect meshes (CSG)
156
162
  - **More tests** — the canvas renderer's drawing and the remaining math functions
163
+ - **More import and export formats** — STL; basic glTF later
164
+ - **More post effects** — antialiasing (FXAA)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trid3d",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A small 3D library for the HTML canvas: wireframes, filled and smooth faces, textures and shader effects, drawn with Canvas 2D or WebGL. No dependencies.",
5
5
  "keywords": [
6
6
  "3d",
package/trid/index.d.ts CHANGED
@@ -164,6 +164,10 @@ export interface SceneObjectInput {
164
164
  hoverable?: boolean;
165
165
  /** Set to false so `pick` / `pickHit` never find this object or its children (helpers like a grid) */
166
166
  pickable?: boolean;
167
+ /** Set to false so this object (and its children) casts no shadow */
168
+ castShadow?: boolean;
169
+ /** Set to false so no shadow falls on this object (and its children) */
170
+ receiveShadow?: boolean;
167
171
  }
168
172
 
169
173
  /**
@@ -459,8 +463,35 @@ export interface DirectionalLight {
459
463
  color?: string;
460
464
  intensity?: number;
461
465
  enabled?: boolean;
466
+ /** Cast shadows (the first directional light with it does): true, or settings */
467
+ shadows?: boolean | ShadowOptions;
462
468
  }
463
469
 
470
+ /** Shadow settings of a directional light. */
471
+ export interface ShadowOptions {
472
+ /** Shadow map width in pixels (WebGL, default 2048). Bigger: sharper, slower */
473
+ size?: number;
474
+ /** Edge blur, in shadow map pixels (default 1.5; 0 = sharp) */
475
+ softness?: number;
476
+ /** Raise if lit surfaces get speckles; too high and shadows come loose (default 1) */
477
+ bias?: number;
478
+ /** Floor height for the canvas renderer's ground shadows (default: found) */
479
+ ground?: number;
480
+ }
481
+
482
+ /** Settings used for `shadows: true`. */
483
+ export const DEFAULT_SHADOWS: Readonly<Required<Omit<ShadowOptions, "ground">> & { ground: null }>;
484
+ /** A light's shadow settings, defaults filled in. */
485
+ export function shadowOptions(value: boolean | ShadowOptions): Required<Omit<ShadowOptions, "ground">> & { ground: number | null };
486
+ /** False when the object or one of its groups sets `key` to false. */
487
+ export function shadowFlag(obj: SceneObject, key: "castShadow" | "receiveShadow"): boolean;
488
+ /** The shadow map's box around casting objects (`matrix`: world → -1..1, column-major). */
489
+ export function shadowFrame(casters: SceneObject[], dir: Vec3): { matrix: Float32Array; radius: number } | null;
490
+ /** The floor height for ground shadows: where receivers have the most area of upward flat faces. */
491
+ export function findGround(receivers: SceneObject[]): number | null;
492
+ /** A world polygon's part above `ground`, flattened onto it along the light (`dir`: where it comes from). */
493
+ export function flattenOnGround(pts: Vec3[], dir: Vec3, ground: number): Vec3[];
494
+
464
495
  /** Light from a point, like a bulb. */
465
496
  export interface PointLight {
466
497
  type: "point";
@@ -490,6 +521,8 @@ export interface SceneLights {
490
521
  lights: { point: boolean; dir: Vec3 | null; pos: Vec3 | null; color: [number, number, number]; range: number }[];
491
522
  /** Unit direction of the first directional light */
492
523
  mainDir: Vec3;
524
+ /** The light casting shadows (`index` in `lights`) and its settings, or null */
525
+ shadow: ({ index: number; dir: Vec3 } & Required<Omit<ShadowOptions, "ground">> & { ground: number | null }) | null;
493
526
  }
494
527
 
495
528
  /** A scene's lights, colors parsed and multiplied by intensity, directions unit length. */
@@ -704,6 +737,10 @@ export interface PostFrame {
704
737
  canvas: HTMLCanvasElement;
705
738
  /** Turns a uniform value (CSS color, array) into RGBA 0..1 */
706
739
  rgba(value: UniformValue): RGBA;
740
+ /** The camera's near distance */
741
+ near: number;
742
+ /** The camera's far distance */
743
+ far: number;
707
744
  }
708
745
 
709
746
  /** One pass of a post effect. */
@@ -727,8 +764,46 @@ export interface PostEffect<U extends Record<string, UniformValue> = Record<stri
727
764
  uniforms?: U;
728
765
  /** Set to false to turn it off without removing it */
729
766
  enabled?: boolean;
767
+ /** Also read the scene's depth and normals (WebGL): GLSL gets `depthAt(uv)`, `normalAt(uv)` and `maskAt(uv)` */
768
+ geometry?: boolean;
769
+ /** With `geometry`: the objects `maskAt` is 1 on (children included). Default: all */
770
+ objects?: SceneObject[];
771
+ /** Canvas renderer: draw the meshes' silhouettes and creases as lines, using `uColor`, `uWidth` and `uCreaseAngle` */
772
+ contour?: boolean;
730
773
  }
731
774
 
775
+ /** Cartoon-style lines around objects and along their sharp creases. */
776
+ export function outline(opts?: {
777
+ color?: string;
778
+ width?: number;
779
+ /** Degrees the surface must turn for an inner line (default 40); false = outer lines only */
780
+ creases?: number | false;
781
+ /** How readily a jump in distance makes a line (default 1; 0 = none) */
782
+ depth?: number;
783
+ /** Only outline these objects (and their children) */
784
+ objects?: SceneObject[];
785
+ }): PostEffect<{
786
+ uColor: string;
787
+ uWidth: number;
788
+ uCreaseAngle: number;
789
+ uDepth: number;
790
+ }>;
791
+
792
+ /** Crease angle `outline()` uses by default, in degrees (40). */
793
+ export const DEFAULT_OUTLINE_CREASE: number;
794
+
795
+ /** Whether a post effect reads the scene's depth and normals (`geometry: true`, with GLSL). */
796
+ export function wantsGeometry(effect: PostEffect): boolean;
797
+
798
+ /** The objects an effect's `objects` list covers, children included (null: all). */
799
+ export function maskedObjects(effect: PostEffect): Set<SceneObject> | null;
800
+
801
+ /** GLSL helpers `depthAt`, `normalAt` and `maskAt`, for effects with `geometry: true`. */
802
+ export const GEOMETRY_GLSL: string;
803
+
804
+ /** Each edge of a mesh's faces once, with the faces on its sides (`f2` = -1 on an open border). */
805
+ export function meshEdges(mesh: Mesh): { a: number; b: number; f1: number; f2: number }[];
806
+
732
807
  /** Darkens the corners of the picture. */
733
808
  export function vignette(opts?: { strength?: number; radius?: number; softness?: number; color?: string }): PostEffect<{
734
809
  uStrength: number;
@@ -1323,6 +1398,36 @@ export interface TransformGizmoOptions {
1323
1398
  onChange?: (op: TransformOp, targets: SceneObject[]) => void;
1324
1399
  /** On release: the whole operation */
1325
1400
  onDrop?: (op: TransformOp, targets: SceneObject[]) => void;
1401
+ /** Outline the targets, under the handles (default false). Options: see `SelectionOutline` */
1402
+ outline?: boolean | Omit<SelectionOutlineOptions, "targets">;
1403
+ }
1404
+
1405
+ export interface SelectionOutlineOptions {
1406
+ /** Objects to outline, or a function asked every frame (default none) */
1407
+ targets?: SceneObject[] | (() => SceneObject[]);
1408
+ /** Line color (default "#ffa64d") */
1409
+ color?: string;
1410
+ /** Line width in CSS pixels (default 2) */
1411
+ width?: number;
1412
+ /** Opacity of the outline where the objects are hidden behind others (default 0.35; 0 = not shown) */
1413
+ hidden?: number;
1414
+ }
1415
+
1416
+ /**
1417
+ * A colored line around selected objects, like a 3D editor's selection. Parts hidden
1418
+ * behind other objects get a faint outline. Add it before tools that draw handles.
1419
+ */
1420
+ export class SelectionOutline implements Tool {
1421
+ constructor(opts?: SelectionOutlineOptions);
1422
+ targets: SceneObject[] | (() => SceneObject[]);
1423
+ color: string;
1424
+ width: number;
1425
+ hidden: number;
1426
+ /** Hide the outline without removing the tool. */
1427
+ visible: boolean;
1428
+ /** The objects outlined this frame. */
1429
+ current(): SceneObject[];
1430
+ draw(ctx: CanvasRenderingContext2D, scene: Scene): void;
1326
1431
  }
1327
1432
 
1328
1433
  /**
@@ -1352,6 +1457,8 @@ export class TransformGizmo implements Tool {
1352
1457
  visible: boolean;
1353
1458
  /** True while a handle is being dragged. */
1354
1459
  readonly dragging: boolean;
1460
+ /** The outline around the targets (`outline` option), or null. */
1461
+ outline: SelectionOutline | null;
1355
1462
  /** The pivot in world space, or null when there are no targets. */
1356
1463
  center(): Vec3 | null;
1357
1464
  draw(ctx: CanvasRenderingContext2D, scene: Scene): void;
package/trid/index.js CHANGED
@@ -19,11 +19,12 @@ export { gridMesh, gridHelper } from "./render/grid.js";
19
19
  export { cloneMesh, transformMesh, displaceMesh, mergeMeshes, meshBounds } from "./render/meshUtils.js";
20
20
 
21
21
  // Renderers
22
- export { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./render/canvasRenderer.js";
22
+ export { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf, meshEdges } from "./render/canvasRenderer.js";
23
23
  export { WebGLRenderer, DEFAULT_FAR } from "./render/webglRenderer.js";
24
24
 
25
25
  // Lights
26
26
  export { defaultLights, sceneLights, lightAt, lightLevels, rangeFade, MAX_LIGHTS } from "./render/lights.js";
27
+ export { shadowOptions, shadowFlag, shadowFrame, findGround, flattenOnGround, DEFAULT_SHADOWS } from "./render/shadows.js";
27
28
 
28
29
  // Effects
29
30
  export {
@@ -32,7 +33,10 @@ export {
32
33
  } from "./render/effects.js";
33
34
 
34
35
  // Post effects
35
- export { vignette, bloom, grade, grain, postPasses, activePost, runCanvasPost, FRAME_GLSL } from "./render/post.js";
36
+ export {
37
+ vignette, bloom, grade, grain, outline, postPasses, activePost, runCanvasPost, wantsGeometry, maskedObjects,
38
+ FRAME_GLSL, GEOMETRY_GLSL, DEFAULT_OUTLINE_CREASE,
39
+ } from "./render/post.js";
36
40
 
37
41
  // Procedural textures
38
42
  export { makeTexture, texturePixels, colorBytes, TEXTURE_STYLES } from "./render/proceduralTextures.js";
@@ -56,7 +60,7 @@ export { screenshot, record, download } from "./export/capture.js";
56
60
  // Tools
57
61
  export {
58
62
  OrbitCamera, FlyCamera, DragObject, HoverHighlight, FpsMeter, ObjUploader,
59
- ViewGizmo, TransformGizmo,
63
+ ViewGizmo, TransformGizmo, SelectionOutline,
60
64
  ToolManager, pick, pickHit, isPickable, screenBounds, worldPerPixel,
61
65
  } from "./tools/index.js";
62
66
 
@@ -7,7 +7,8 @@ import { newell, cornerNormals, creaseOf } from "./normals.js";
7
7
  import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
8
8
  import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
9
9
  import { DEFAULT_LIGHT, DEFAULT_AMBIENT, sceneLights, lightAt as lightOf, lightLevels } from "./lights.js";
10
- import { runCanvasPost } from "./post.js";
10
+ import { runCanvasPost, activePost, maskedObjects } from "./post.js";
11
+ import { shadowFlag, findGround, flattenOnGround } from "./shadows.js";
11
12
 
12
13
  export { newell };
13
14
 
@@ -126,6 +127,14 @@ export class CanvasRenderer {
126
127
  const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
127
128
  const time = (performance.now() - this._start) / 1000;
128
129
  const background = this._rgba(ctx, scene.background ?? "transparent");
130
+ // Post effects drawn as lines along the meshes (outline()): silhouettes and creases
131
+ const contours = activePost(scene.post).filter((e) => e.contour).map((e) => ({ e, mask: maskedObjects(e) }));
132
+ const outlines = [];
133
+ // Ground shadows (see trid/render/shadows.js): the floor's faces, then the shadows, then the rest
134
+ const sun = lights.shadow && lights.shadow.dir.y > 0.01 ? lights.shadow : null;
135
+ const ground = sun ? sun.ground ?? findGround(drawn.filter((o) => materialsOf(o) && shadowFlag(o, "receiveShadow"))) : null;
136
+ const shadowing = ground !== null && (camera.y ?? 0) > ground;
137
+ const shadowPolys = [];
129
138
 
130
139
  for (const obj of drawn) {
131
140
  const mats = materialsOf(obj);
@@ -137,6 +146,18 @@ export class CanvasRenderer {
137
146
  const color = obj.color ?? scene.color;
138
147
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
139
148
 
149
+ // Its faces flattened on the floor along the light
150
+ if (shadowing && mats && shadowFlag(obj, "castShadow")) {
151
+ for (const face of obj.mesh.faces ?? []) {
152
+ if (face.length < 3) continue;
153
+ const flat = flattenOnGround(face.map((i) => world[i]), sun.dir, ground);
154
+ if (flat.length < 3) continue;
155
+ const clipped = clipPolygon(flat.map((p) => worldToCamera(p, camera)), near);
156
+ if (clipped.length >= 3) shadowPolys.push(clipped.map(toScreen));
157
+ }
158
+ }
159
+ const onGround = shadowing && mats && shadowFlag(obj, "receiveShadow");
160
+
140
161
  if (!mats) {
141
162
  for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
142
163
  const seg = clipNear(cam[i], cam[j], near);
@@ -213,11 +234,28 @@ export class CanvasRenderer {
213
234
  return (looks[k] ??= prepare(mats[k]));
214
235
  };
215
236
 
237
+ // Outline lines (outline()): placed in the drawing order once the faces are sorted
238
+ const lines = contours.length ? this._contours(obj, cam, flip, near, toScreen, contours) : null;
239
+ if (lines) outlines.push(lines);
240
+ let pendingF = -1, pendingStart = items.length, pendingGround = false;
241
+ // Marks the items of the last face with the face they come from, and whether it's floor
242
+ const attach = () => {
243
+ for (let k = pendingStart; k < items.length; k++) {
244
+ if (lines && pendingF >= 0) items[k].face = { lines, F: pendingF };
245
+ if (pendingGround) items[k].ground = true;
246
+ }
247
+ };
248
+
216
249
  const faces = split ? split.faces : sourceFaces;
217
250
  for (let f = 0; f < faces.length; f++) {
218
251
  const face = faces[f];
219
252
  if (face.length < 3) continue;
220
253
  const F = split ? split.origins[f] : f; // the mesh face this one comes from
254
+ attach();
255
+ pendingF = F;
256
+ pendingStart = items.length;
257
+ // Floor: lying on the ground level, facing up
258
+ pendingGround = onGround && face.every((i) => Math.abs(world[i].y - ground) < 1e-4) && newell(face.map((i) => world[i])).y * flip > 0;
221
259
  const L = lookOf(F);
222
260
  const { fill, shade, outline, corners } = L;
223
261
  const pts = face.map((i) => cam[i]);
@@ -333,11 +371,31 @@ export class CanvasRenderer {
333
371
  edges,
334
372
  });
335
373
  }
374
+ attach();
336
375
  }
337
376
 
338
377
  items.sort((a, b) => b.depth - a.depth);
339
378
 
340
- for (const it of items) {
379
+ // Each outline line is drawn once every face around its two ends is: those
380
+ // faces don't cover it then, but nearer ones of other objects still do
381
+ const linesAfter = new Map(); // item index → lines to draw after it
382
+ if (outlines.length) {
383
+ items.forEach((it, k) => {
384
+ if (it.face) it.face.lines.last[it.face.F] = k;
385
+ });
386
+ for (const L of outlines) {
387
+ for (const ln of L.list) {
388
+ let k = -1;
389
+ for (const F of ln.faces) k = Math.max(k, L.last[F] ?? -1);
390
+ if (k < 0) continue;
391
+ if (!linesAfter.has(k)) linesAfter.set(k, []);
392
+ linesAfter.get(k).push(ln);
393
+ }
394
+ }
395
+ }
396
+
397
+ const drawItem = (n) => {
398
+ const it = items[n];
341
399
  ctx.beginPath();
342
400
  if (it.line) {
343
401
  ctx.moveTo(it.line[0].x, it.line[0].y);
@@ -345,7 +403,7 @@ export class CanvasRenderer {
345
403
  ctx.lineWidth = it.lineWidth;
346
404
  ctx.strokeStyle = it.color;
347
405
  ctx.stroke();
348
- continue;
406
+ return;
349
407
  }
350
408
 
351
409
  if (it.texture) {
@@ -385,6 +443,31 @@ export class CanvasRenderer {
385
443
  ctx.strokeStyle = it.fill.css;
386
444
  ctx.stroke();
387
445
  }
446
+
447
+ const after = linesAfter.get(n);
448
+ if (after) {
449
+ ctx.save();
450
+ ctx.lineCap = "round";
451
+ for (const ln of after) {
452
+ ctx.beginPath();
453
+ ctx.moveTo(ln.p.x, ln.p.y);
454
+ ctx.lineTo(ln.q.x, ln.q.y);
455
+ ctx.lineWidth = ln.width;
456
+ ctx.strokeStyle = ln.color;
457
+ ctx.stroke();
458
+ }
459
+ ctx.restore();
460
+ }
461
+ };
462
+
463
+ const floor = shadowing && shadowPolys.length ? items.filter((it) => it.ground) : [];
464
+ if (floor.length) {
465
+ // Everything else stands on the floor, so it's in front of it: floor, shadows, then the rest
466
+ for (let n = 0; n < items.length; n++) if (items[n].ground) drawItem(n);
467
+ this._groundShadow(ctx, w, h, shadowPolys, floor, lights, sun, ground);
468
+ for (let n = 0; n < items.length; n++) if (!items[n].ground) drawItem(n);
469
+ } else {
470
+ for (let n = 0; n < items.length; n++) drawItem(n);
388
471
  }
389
472
 
390
473
  // Meshes marked as changed have been redrawn from their new data
@@ -394,6 +477,118 @@ export class CanvasRenderer {
394
477
  runCanvasPost(scene, scene.post, this._start);
395
478
  }
396
479
 
480
+ /**
481
+ * Darkens the floor where the shadows fall: the shadow shapes, blurred,
482
+ * kept only on the floor's faces, as dark as the light they take away.
483
+ */
484
+ _groundShadow(ctx, w, h, polys, floor, lights, sun, ground) {
485
+ const [shade, mask] = [0, 1].map((i) => {
486
+ const c = (this._scratch ??= [])[i] ??= document.createElement("canvas");
487
+ if (c.width !== w || c.height !== h) {
488
+ c.width = w;
489
+ c.height = h;
490
+ }
491
+ const g = c.getContext("2d");
492
+ g.clearRect(0, 0, w, h);
493
+ return { c, g };
494
+ });
495
+ // The shadows, all turned the same way so overlaps don't cancel out
496
+ if (typeof shade.g.filter === "string" && sun.softness > 0) shade.g.filter = `blur(${sun.softness}px)`;
497
+ shade.g.beginPath();
498
+ for (const poly of polys) {
499
+ const p = signedArea(poly) < 0 ? poly.slice().reverse() : poly;
500
+ shade.g.moveTo(p[0].x, p[0].y);
501
+ for (let k = 1; k < p.length; k++) shade.g.lineTo(p[k].x, p[k].y);
502
+ shade.g.closePath();
503
+ }
504
+ shade.g.fillStyle = "#000000";
505
+ shade.g.fill();
506
+ shade.g.filter = "none";
507
+ // Only on the floor
508
+ mask.g.beginPath();
509
+ for (const it of floor) {
510
+ mask.g.moveTo(it.poly[0].x, it.poly[0].y);
511
+ for (let k = 1; k < it.poly.length; k++) mask.g.lineTo(it.poly[k].x, it.poly[k].y);
512
+ mask.g.closePath();
513
+ }
514
+ mask.g.fillStyle = mask.g.strokeStyle = "#ffffff";
515
+ mask.g.fill();
516
+ mask.g.lineWidth = 1;
517
+ mask.g.stroke();
518
+ shade.g.globalCompositeOperation = "destination-in";
519
+ shade.g.drawImage(mask.c, 0, 0);
520
+ shade.g.globalCompositeOperation = "source-over";
521
+
522
+ // As dark as the share of the floor's light this light gives
523
+ const up = { x: 0, y: 1, z: 0 }, at = { x: 0, y: ground, z: 0 };
524
+ const all = Math.max(...lightOf(lights, up, at));
525
+ const without = Math.max(...lightOf({ ...lights, lights: lights.lights.filter((_, i) => i !== sun.index) }, up, at));
526
+ const alpha = all > 0 ? Math.min(1, Math.max(0, 1 - without / all)) : 0;
527
+ if (alpha <= 0) return;
528
+ ctx.save();
529
+ ctx.globalAlpha = alpha;
530
+ ctx.drawImage(shade.c, 0, 0);
531
+ ctx.restore();
532
+ }
533
+
534
+ /**
535
+ * Outline lines of one object, for post effects with `contour` (outline()):
536
+ * its silhouette (edges between a face seen from the front and one seen
537
+ * from behind, or with no other face) and its creases (two front faces
538
+ * meeting at more than the effect's `uCreaseAngle`).
539
+ *
540
+ * @returns {{ list: {p:Object, q:Object, faces:number[], color:string, width:number}[], last: number[] }|null}
541
+ * `faces`: the mesh faces around the line's two ends. `last` is filled in after sorting
542
+ */
543
+ _contours(obj, cam, flip, near, toScreen, contours) {
544
+ const used = contours.filter((c) => !c.mask || c.mask.has(obj));
545
+ if (!used.length) return null;
546
+ const faces = obj.mesh.faces ?? [];
547
+ const { edges, spot, facesAt } = meshTopology(obj.mesh);
548
+ // Each face's normal in camera space, and whether it faces the camera
549
+ const info = faces.map((face) => {
550
+ if (face.length < 3) return null;
551
+ const pts = face.map((i) => cam[i]);
552
+ const n = normalize(newell(pts));
553
+ const c = centroid(pts);
554
+ const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
555
+ return { n, front: !back };
556
+ });
557
+ const mats = materialsOf(obj);
558
+ const faceMats = obj.mesh.faceMaterials;
559
+ const shown = (F) => {
560
+ if (!info[F]) return false;
561
+ if (info[F].front) return true;
562
+ return !!(mats[faceMats?.[F] ?? 0] ?? mats[0])?.doubleSided;
563
+ };
564
+
565
+ const list = [];
566
+ for (const c of used) {
567
+ const u = c.e.uniforms ?? {};
568
+ const angle = u.uCreaseAngle ?? 180;
569
+ const cos = angle >= 180 ? -2 : Math.cos((angle * Math.PI) / 180);
570
+ const rgba = typeof u.uColor === "string" ? null : u.uColor;
571
+ const color = rgba ? `rgba(${Math.round(rgba[0] * 255)}, ${Math.round(rgba[1] * 255)}, ${Math.round(rgba[2] * 255)}, ${rgba[3] ?? 1})` : u.uColor ?? "#1a1a1a";
572
+ const width = u.uWidth ?? 2;
573
+ for (const { a, b, f1, f2 } of edges) {
574
+ let line;
575
+ if (f2 < 0) line = shown(f1); // open border
576
+ else {
577
+ const s1 = shown(f1), s2 = shown(f2);
578
+ if (s1 && s2) {
579
+ const p = info[f1].n, q = info[f2].n;
580
+ line = info[f1].front !== info[f2].front || p.x * q.x + p.y * q.y + p.z * q.z < cos;
581
+ } else line = s1 || s2; // the other one faces away
582
+ }
583
+ if (!line) continue;
584
+ const seg = clipNear(cam[a], cam[b], near);
585
+ if (!seg) continue;
586
+ list.push({ p: toScreen(seg[0]), q: toScreen(seg[1]), faces: [...new Set([...facesAt[spot[a]], ...facesAt[spot[b]]])], color, width });
587
+ }
588
+ }
589
+ return list.length ? { list, last: [] } : null;
590
+ }
591
+
397
592
  /**
398
593
  * Splits a mesh's big faces into triangles no longer than
399
594
  * maxFaceSize × the mesh's size. Cached until the mesh's faces change.
@@ -707,6 +902,74 @@ function withMidpoints(vertices, mids) {
707
902
  return out;
708
903
  }
709
904
 
905
+ function signedArea(pts) {
906
+ let a = 0;
907
+ for (let i = 0; i < pts.length; i++) {
908
+ const p = pts[i], q = pts[(i + 1) % pts.length];
909
+ a += p.x * q.y - q.x * p.y;
910
+ }
911
+ return a;
912
+ }
913
+
914
+ const edgeCache = new WeakMap();
915
+
916
+ /**
917
+ * Each edge of a mesh's faces once, with the (up to two) faces on its sides.
918
+ * Vertices at the same spot count as one, so seams (where a shape repeats
919
+ * vertices for its texture coordinates) aren't taken for open borders.
920
+ * Cached until the mesh's arrays change or `mesh.dirty` is set.
921
+ *
922
+ * @returns {{a:number, b:number, f1:number, f2:number}[]} `f2` is -1 on an open border
923
+ */
924
+ export function meshEdges(mesh) {
925
+ return meshTopology(mesh).edges;
926
+ }
927
+
928
+ /**
929
+ * meshEdges, plus `spot` (vertex → id of its position) and `facesAt`
930
+ * (position id → the faces touching it).
931
+ */
932
+ function meshTopology(mesh) {
933
+ const v = mesh.vertices, faces = mesh.faces ?? [];
934
+ const hit = edgeCache.get(mesh);
935
+ if (hit && !mesh.dirty && hit.v === v && hit.faces === faces && hit.counts === `${v.length}/${faces.length}`) return hit.topology;
936
+
937
+ // One id per spot
938
+ let size = 0;
939
+ for (const p of v) size = Math.max(size, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
940
+ const step = (size || 1) * 1e-6;
941
+ const ids = new Map();
942
+ const spot = v.map((p) => {
943
+ const key = `${Math.round(p.x / step)},${Math.round(p.y / step)},${Math.round(p.z / step)}`;
944
+ let id = ids.get(key);
945
+ if (id === undefined) ids.set(key, (id = ids.size));
946
+ return id;
947
+ });
948
+
949
+ const byKey = new Map();
950
+ const edges = [];
951
+ const facesAt = Array.from({ length: ids.size }, () => []);
952
+ faces.forEach((f, F) => {
953
+ if (f.length < 3) return;
954
+ for (const i of f) if (facesAt[spot[i]].at(-1) !== F) facesAt[spot[i]].push(F);
955
+ for (let k = 0; k < f.length; k++) {
956
+ const a = f[k], b = f[(k + 1) % f.length];
957
+ const sa = spot[a], sb = spot[b];
958
+ if (sa === sb) continue;
959
+ const key = sa < sb ? sa * 4294967296 + sb : sb * 4294967296 + sa;
960
+ const e = byKey.get(key);
961
+ if (!e) {
962
+ const n = { a, b, f1: F, f2: -1 };
963
+ byKey.set(key, n);
964
+ edges.push(n);
965
+ } else if (e.f2 < 0 && e.f1 !== F) e.f2 = F;
966
+ }
967
+ });
968
+ const topology = { edges, spot, facesAt };
969
+ edgeCache.set(mesh, { v, faces, counts: `${v.length}/${faces.length}`, topology });
970
+ return topology;
971
+ }
972
+
710
973
  function centroid(pts) {
711
974
  let x = 0, y = 0, z = 0;
712
975
  for (const p of pts) { x += p.x; y += p.y; z += p.z; }
@@ -719,7 +982,7 @@ function normalize(v) {
719
982
  }
720
983
 
721
984
  /** Cuts a camera-space polygon at z = near, keeping the part in front. */
722
- function clipPolygon(pts, near) {
985
+ export function clipPolygon(pts, near) {
723
986
  if (pts.every((p) => p.z >= near)) return pts;
724
987
  const out = [];
725
988
  for (let i = 0; i < pts.length; i++) {
@@ -12,6 +12,7 @@
12
12
  * - `range`: a point light fades out smoothly and reaches nothing past it
13
13
  * (default: no fading)
14
14
  * - `enabled: false` turns a light off without removing it
15
+ * - `shadows: true` (directional lights): it casts shadows, see trid/render/shadows.js
15
16
  *
16
17
  * Lights add up: a face gets the ambient lights, plus each other light
17
18
  * times how much the face turns toward it. Both renderers use at most
@@ -46,7 +47,10 @@ export function defaultLights(direction = DEFAULT_LIGHT, ambient = DEFAULT_AMBIE
46
47
  ];
47
48
  }
48
49
 
50
+ import { shadowOptions } from "./shadows.js";
51
+
49
52
  let warnedTooMany = false;
53
+ let warnedPointShadows = false;
50
54
 
51
55
  /**
52
56
  * A scene's lights, ready for shading: colors parsed and multiplied by
@@ -62,8 +66,10 @@ let warnedTooMany = false;
62
66
  * ambientLevel: number,
63
67
  * lights: { point:boolean, dir:{x,y,z}|null, pos:{x,y,z}|null, color:number[], range:number }[],
64
68
  * mainDir: {x,y,z},
69
+ * shadow: { index:number, dir:{x,y,z}, size:number, softness:number, bias:number, ground:number|null } | null,
65
70
  * }} `ambient` is the summed ambient color [r, g, b]; `ambientLevel` its brightest
66
- * channel; `mainDir` the unit direction of the first directional light (for effects)
71
+ * channel; `mainDir` the unit direction of the first directional light (for effects);
72
+ * `shadow` the light casting shadows (`index` in `lights`) and its settings
67
73
  */
68
74
  export function sceneLights(scene, rgba) {
69
75
  const list = Array.isArray(scene.lights)
@@ -73,6 +79,7 @@ export function sceneLights(scene, rgba) {
73
79
  const ambient = [0, 0, 0];
74
80
  const lights = [];
75
81
  let mainDir = null;
82
+ let shadow = null;
76
83
  for (const l of list) {
77
84
  if (!l || l.enabled === false) continue;
78
85
  const k = l.intensity ?? 1;
@@ -90,11 +97,16 @@ export function sceneLights(scene, rgba) {
90
97
  continue;
91
98
  }
92
99
  if (l.type === "point") {
100
+ if (l.shadows && !warnedPointShadows) {
101
+ warnedPointShadows = true;
102
+ console.warn("Trid: point lights don't cast shadows yet, only directional ones");
103
+ }
93
104
  const p = l.position ?? { x: 0, y: 0, z: 0 };
94
105
  lights.push({ point: true, dir: null, pos: { x: p.x, y: p.y, z: p.z }, color, range: l.range > 0 ? l.range : 0 });
95
106
  } else {
96
107
  const dir = unit(l.direction ?? DEFAULT_LIGHT);
97
108
  mainDir ??= dir;
109
+ if (l.shadows && !shadow) shadow = { index: lights.length, dir, ...shadowOptions(l.shadows) };
98
110
  lights.push({ point: false, dir, pos: null, color, range: 0 });
99
111
  }
100
112
  }
@@ -103,6 +115,7 @@ export function sceneLights(scene, rgba) {
103
115
  ambientLevel: Math.max(...ambient),
104
116
  lights,
105
117
  mainDir: mainDir ?? unit(DEFAULT_LIGHT),
118
+ shadow,
106
119
  };
107
120
  }
108
121