3draft 26.11.4010 → 26.11.5001

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/APP.md CHANGED
@@ -10,7 +10,7 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
10
10
 
11
11
  ## The view
12
12
 
13
- - **Starting view: WebGPU.** If WebGPU is unavailable, or the GPU device is lost, the app falls back to SVG by itself and says so in the footer (`G` or the `WebGPU` button switches by hand). SVG shows colour and transparency only; WebGPU also shows metal, roughness and emission. The **filmic look** (`C`) adds linear light, Fresnel and tone mapping, so highlights roll off softly and `lamp`-like materials glow; switch it off for the plain flat look.
13
+ - **Starting view: WebGPU.** If WebGPU is unavailable, or the GPU device is lost, the app falls back to SVG by itself and says so in the footer (`G` or the `WebGPU` button switches by hand). SVG shows colour and transparency only; WebGPU also shows metal, roughness and emission. The **filmic look** (`C`) adds linear light, Fresnel and tone mapping, so highlights roll off softly and `lamp`-like materials glow; switch it off for the plain flat look. **Smooth** (`N`) shades faces that meet at less than 40 degrees as one round surface (box corners stay sharp), and **Studio** (`K`) lights the model with a key, a fill and a rim light that follow the camera; both are on at the start (WebGPU view only). The palette's `soft` slider gives a material skin-like light: it wraps round the form, with a warm band where light turns to shade.
14
14
  - **The drawing fills the stage.** On a desktop it covers the whole area; on a phone it is a little larger than the short side.
15
15
  - **Drag** rotates, **double click** resets the view, the arrow keys rotate too.
16
16
  - **The cross** follows the pointer and lies on the surface under it. The cursor is always a cross. On a phone, with no mouse, it aims at the middle.
@@ -22,10 +22,10 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
22
22
  | --- | --- |
23
23
  | `w a s d r f` | fly: forward, left, back, right, up, down. Every press is a speed step; the opposite key slows down |
24
24
  | `Space` | stop moving (all speeds to zero) |
25
- | `T` | fly to the object under the cross. Over nothing, or when too close or inside a model: out to the nearest object |
25
+ | `T` | target the object under the cross (see Target mode); with no target, `T` over empty space targets the nearest object. `Tab` next target (the first one when none), `Esc` let go |
26
26
  | `/` | show only the object under the cross; again: the whole scene, and the camera flies back to where it was |
27
27
  | `M` | place a prism on the surface under the cross, or shoot one into empty space. **In isolated mode (`/`) it fires a bullet** instead: a prism that starts at the camera point and flies along the ray through the cursor, so its long axis is always the view direction and you always see its back; if it hits the isolated object it sinks half its length and carves that part out (A − B, the cut is made of the selected material, `U` undoes it); if it misses it flies on to 4 times the camera-to-model distance and vanishes. Only the closed parts of the model are carved; open parts are ignored (see the health check below). A carve never leaves a crack: if the result would not be a closed solid it is retried with a hair's shift, and if that fails too the model stays as it was (the note "left as it was" shows). |
28
- | `Tab` | edit mode on the object under the cross / off (see below) |
28
+ | `Q` | edit mode on the object under the cross / off (see below) |
29
29
  | `V` | Spider bar (in edit mode: draw a line): the first `V` remembers a surface point, the second joins the two with a thin prism (a quarter of the base of a placed one) made of the selected material. `V` over empty space forgets the first point |
30
30
  | `X`, `Delete` | delete the object under the cross |
31
31
  | `U`, `Backspace` | undo: placing, deleting, painting, booleans and moves, one step at a time |
@@ -37,6 +37,8 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
37
37
  | `H` | move gizmo (see below) |
38
38
  | `E` | edges and points on / off (also the `Edges` button) |
39
39
  | `C` | filmic look on / off (also the `Filmic` button; on at the start, WebGPU view only) |
40
+ | `N` | smooth shading on / off (the `Smooth` button; on at the start, WebGPU view only) |
41
+ | `K` | studio light (key, fill, rim; follows the camera) on / off (the `Studio` button; on at the start, WebGPU view only) |
40
42
  | `G`, `L` | SVG / WebGPU, turn the light |
41
43
  | `?` | show / hide these shortcuts under the view (hidden at the start; also the `Keys` button) |
42
44
 
@@ -55,9 +57,9 @@ There is one gizmo at a time, and `H` cycles through three states:
55
57
 
56
58
  Drag one of the arrows to slide the object along that axis; the arrow thickens under the pointer. While dragging only the picture changes; one write goes to the database when you let go, and one `U` takes the whole drag back.
57
59
 
58
- ## Edit mode: drawing on a model (`Tab`)
60
+ ## Edit mode: drawing on a model (`Q`)
59
61
 
60
- The idea: there is a base model, and you refine or extend it by drawing lines on it. Put the cross on an object and press `Tab`. A red bar at the top says which object is being edited. Its vertices show as small grey crosses.
62
+ The idea: there is a base model, and you refine or extend it by drawing lines on it. Put the cross on an object and press `Q`. A red bar at the top says which object is being edited. Its vertices show as small grey crosses.
61
63
 
62
64
  - **`V` draws a line.** The first `V` takes a point, the next `V` joins it to the new one, and the chain goes on from there. A point is either an existing vertex (the pointer **snaps** to vertices; a white cross shows which) or a new vertex put on the surface under the cross. On a face it splits the face into triangles, on an edge it goes into both faces, so the surface never cracks.
63
65
  - **Two points on one face cut it.** The line becomes a real edge of the mesh: the surface gets detail exactly where you drew.
@@ -71,7 +73,7 @@ The idea: there is a base model, and you refine or extend it by drawing lines on
71
73
  - `V` goes back to drawing (and drops the selection); `Esc` does the same.
72
74
  - **`B` turns the spider bars into lines.** The prisms made with `V` in object mode (they are objects named `bar`) become lines on the edited object: each end snaps to a vertex that is close, or is put on the surface if it is near it, or stays a free point. The bars are removed (one `U` brings them back). So a plan sketched as bars can be worked on as a wire.
73
75
  - **`X`** removes the lines at the selected point or line (or the last line); the points stay. `U` undoes every step, one key press at a time.
74
- - **`Esc`** stops the line you are drawing; then it leaves a move mode back to drawing; then it leaves edit mode (so does `Tab`). The lines are only shown in edit mode.
76
+ - **`Esc`** stops the line you are drawing; then it leaves a move mode back to drawing; then it leaves edit mode (so does `Q`). The lines are only shown in edit mode.
75
77
 
76
78
  The lines live next to the mesh as `wire: [[a, b], ...]` (vertex pairs) in the object's document, so the AI sees them too. The geometry is in the engine (README part 1): `insertVertex`, `cutFace`, `moveVertex`, `moveVertexSet`, `fillLoop`, `addLine`, `findLoop`.
77
79
 
@@ -99,11 +101,18 @@ Twelve starting materials: plaster, sand, wood, dark wood, concrete, slate, gras
99
101
 
100
102
  **Pick material (`I`, or the `pick` button):** the eyedropper. `I` takes the material of the face under the cross. The `pick` button arms it, and the next click on a surface takes it (`Esc` cancels). The palette opens and that material is selected, so you can read its sliders or paint with it right away. A face that has only a colour selects the material with that colour, or starts a new material from it.
101
103
 
102
- For the selected material there are four sliders (`metal`, `rough`, `alpha`, `emit`, 0 to 5) and a colour; `+ new` copies it as a new material. A painted face stores the material **name**, so editing a material changes every face made of it.
104
+ For the selected material there are five sliders (`metal`, `rough`, `alpha`, `soft`, 0 to 1, and `emit`, 0 to 5) and a colour; `+ new` copies it as a new material. A painted face stores the material **name**, so editing a material changes every face made of it.
103
105
 
104
106
  ## Isolating, travelling
105
107
 
106
- `/` hides everything but the object under the cross and frames it. The camera pose from before is remembered and restored when you leave. `T` flies the camera to the object under the cross (nothing under it: out to the nearest object, which also gets you out of a model you are inside).
108
+ `/` hides everything but the object under the cross and frames it. The camera pose from before is remembered and restored when you leave.
109
+ ### Target mode
110
+
111
+ The HUD in the top left corner shows the object under the cross (name, distance, face count) and the target.
112
+
113
+ - `T` on an object **targets** it: the camera turns to face it and from then on moves on an imaginary sphere round it, always looking at its centre. `A`/`D` swing it sideways, `R`/`F` up and down (speed levels, like everywhere), `W`/`S` change the distance (`W` stops at the object's surface), and dragging the mouse turns it over the same sphere.
114
+ - `T` on another object re-targets; `T` on the target or over empty space brings the camera back onto the target. There is no travel: `W` flies there.
115
+ - `Tab` switches to the next object (`Shift+Tab`: previous). `Space` only sets the speed to zero. `Esc` lets go, and the camera then stays exactly where it is.
107
116
 
108
117
  ## Phone
109
118
 
package/README.md CHANGED
@@ -56,18 +56,21 @@ The palette is data: `{ name: { color, metal, rough, alpha, emit } }`. A face po
56
56
  | `metal` 0..1 | paint ... metal | reflects sky and ground, tinted; strong highlight |
57
57
  | `rough` 0..1 | mirror ... matte | widens the highlight, blurs the reflection |
58
58
  | `alpha` 1..0 | solid ... invisible | blended in a second pass, far to near (SVG: `fill-opacity`, and a see-through face hides nothing) |
59
+ | `soft` 0..1 | hard ... soft | skin-like light: it wraps round the form, and a warm scattered band shows where light turns to shade (WebGPU) |
59
60
  | `emit` 0..5 | lit ... glowing | up to 1 the colour fades to unshaded; above 1 (with `tonemap`) it is a light source, that many times brighter |
60
61
 
61
62
  Pass the palette to the renderers: `renderView(mesh, view, { palette })`, `gpu.render(mesh, view, { palette })` (a new object when an entry changes). SVG shows colour and alpha only.
62
63
 
63
64
  `gpu.render(mesh, view, { tonemap: true })` switches on a filmic look: lighting in linear colour, HDR light, Fresnel on the edges, ACES tone mapping, and `emit` that really glows above white (`tonemap: { exposure, emit }` to tune; off by default, so the plain look stays the same).
64
65
 
66
+ `smooth: true` (or a crease angle in degrees, default 40) shades by corner normals: faces that meet at a shallow angle share their normals, so a faceted surface looks round, and a 90 degree box corner stays sharp (`cornerNormals(mesh, degrees)` is the pure helper). `studio: true` replaces the single sun with three lights that follow the camera: key (upper left), fill (right, softer) and a rim light from behind that edges the shape (`studio: { fill, rim }` to tune). Both are off by default.
67
+
65
68
  ### Surfaces, picking, editing
66
69
 
67
70
  - `pick(mesh, camera, x, y)` casts a real ray: face, point, normal, distance. `ray` is the inverse of `project`; `crosshair` builds a cross lying on the surface; `standOn` returns a prism standing on a picked surface.
68
71
  - `validate(mesh)` returns text notes: closed shell, winding, planar and convex faces. `extrudeFace`, `moveVertices`, `extrude`, `rotate`, `merge`, `bounds` shape meshes.
69
72
  - `boolean(a, b, op)` (also `union`, `subtract`, `intersect`) is solid modelling on BSP trees: faces are split by planes, so the result stays convex and planar. Both operands must be closed with outward faces; an open shell (a wall without thickness) is refused with the reason. With `{ strict: false }` the parts that are not closed solids are left out of the calculation instead (a union keeps them as they are, a subtract keeps those of `a`); `health(mesh)` is the check behind it: the connected parts, which are closed, and how many faces a boolean can work with. Face colours and materials are kept; cut faces take the colour of the cutting solid. The result is welded and T-junctions are repaired. A solid stays a solid: a result that is not a closed, consistently wound solid (numerical trouble with coincident faces) is thrown away and the operation is repeated with the second operand nudged by a tiny amount; if no try is sound it throws and the caller keeps the original.
70
- - **Drawing on a mesh** (`wire.ts`): `insertVertex(mesh, face, point)` puts a vertex on a surface (inside a face it makes a triangle fan, on an edge it joins both faces), `cutFace(mesh, a, b)` cuts a face along a line between two of its vertices, `moveVertex(mesh, i, to)` moves one (polygons that use it become triangles, so faces stay planar), `fillLoop(mesh, loop)` closes a loop of vertices into a face, `addLine` and `findLoop` keep the wire (a list of vertex pairs). These are what the viewer's edit mode (`Tab`, see [APP.md](APP.md)) uses.
73
+ - **Drawing on a mesh** (`wire.ts`): `insertVertex(mesh, face, point)` puts a vertex on a surface (inside a face it makes a triangle fan, on an edge it joins both faces), `cutFace(mesh, a, b)` cuts a face along a line between two of its vertices, `moveVertex(mesh, i, to)` moves one (polygons that use it become triangles, so faces stay planar), `fillLoop(mesh, loop)` closes a loop of vertices into a face, `addLine` and `findLoop` keep the wire (a list of vertex pairs). These are what the viewer's edit mode (`Q`, see [APP.md](APP.md)) uses.
71
74
  - **HEXAW kernel.** The picking hot loop is also written in [HEXAW](https://github.com/Pengeszikra/hexaw) (`kernel/pick.hexaw`, 569 bytes of WebAssembly). `initKernel()` starts it; `kernel.pick` returns exactly what `pick` returns, 0.14 ms instead of 1.7 ms on 7200 faces. The module is embedded as base64 (`src/kernel-bytes.ts`), so the engine needs neither HEXAW nor a fetch at runtime. `npm run kernel` re-assembles it.
72
75
 
73
76
  ### TypeScript and `|>`
@@ -92,6 +95,60 @@ A single `app/index.html` plus a dependency-free static server. Person and AI wo
92
95
 
93
96
  **How to use it, all keys and the database layout: [APP.md](APP.md).**
94
97
 
98
+ ### Keys
99
+
100
+ **Moving and looking**
101
+
102
+ | Key | What it does |
103
+ |---|---|
104
+ | drag | turns the view (in target mode: moves the camera over the sphere round the target) |
105
+ | arrow keys | turn the view in steps |
106
+ | `W` / `S` | forward / back: speed levels, every press is one step more or less |
107
+ | `A` / `D` | left / right (speed levels) |
108
+ | `R` / `F` | up / down (speed levels) |
109
+ | `Space` | speed to zero (the target stays) |
110
+ | `/` | show only the model under the cross; again: the whole scene |
111
+
112
+ **Target mode**
113
+
114
+ | Key | What it does |
115
+ |---|---|
116
+ | `T` | target the object under the cross; with a target: another object re-targets, the target itself or empty space brings the camera back onto it; with no target over empty space: the nearest object |
117
+ | `Tab` / `Shift+Tab` | next / previous target (the first one when there is none) |
118
+ | `Esc` | let go; the camera stays where it is |
119
+ | `A D R F`, `W S`, drag | swing round the target, closer or farther (`W` stops at the surface) |
120
+
121
+ **Editing and objects**
122
+
123
+ | Key | What it does |
124
+ |---|---|
125
+ | `Q` | edit mode on / off |
126
+ | `V` | spider bar between two points; in edit mode: draw a line |
127
+ | `Enter` | edit mode: close a loop into a face |
128
+ | `B` | edit mode: turn the old `V` bars into lines |
129
+ | `1` / `2` / `3` | edit mode: move points / lines / faces |
130
+ | `M` | put a prism down; in isolated mode: shoot it (it carves) |
131
+ | `O` | boolean: mark two objects, then pick the operation |
132
+ | `X` | delete the object under the cross |
133
+ | `H` | move gizmo: 1st world axes, 2nd own axes, 3rd off |
134
+ | `U` / `Backspace` | undo |
135
+
136
+ **Material and look**
137
+
138
+ | Key | What it does |
139
+ |---|---|
140
+ | `P` | material palette (Shift+click paints a face, Shift+double click the whole object) |
141
+ | `I` | pick the material under the cross |
142
+ | `G` | SVG / WebGPU view |
143
+ | `C` | filmic look |
144
+ | `N` | smooth shading |
145
+ | `K` | studio light |
146
+ | `L` | turn the light |
147
+ | `E` | edges and points on / off |
148
+ | `?` | show / hide the key hints |
149
+
150
+ In edit mode `Esc` first stops the line being drawn, then leaves a move mode, then leaves edit mode (and keeps the target).
151
+
95
152
  ---
96
153
 
97
154
  ## 3. Engine and LLM
@@ -106,7 +163,7 @@ The engine is small enough to read whole (about 2000 lines) and everything it wo
106
163
 
107
164
  Y is up, the ground is y = 0, faces are counter-clockwise seen from outside.
108
165
 
109
- Examples: `examples/table.js`, `examples/bridge.js` (50 named objects), `examples/cessna.js` (one mesh, 9 colours), and `examples/house.js` (a house converted from Blender with `tools/blend/`, 45 objects, approximate colours).
166
+ Examples: `examples/table.js`, `examples/bridge.js` (50 named objects), `examples/cessna.js` (one mesh, 9 colours), and `examples/head.js` (a stylized head: 7 objects with their own palette, soft skin; try N and K), and `examples/house.js` (a house converted from Blender with `tools/blend/`, 45 objects, approximate colours).
110
167
 
111
168
  ---
112
169
 
package/dist/gpu.d.ts CHANGED
@@ -39,6 +39,19 @@ export interface GpuRenderOptions {
39
39
  exposure?: number;
40
40
  emit?: number;
41
41
  };
42
+ /**
43
+ * Smooth shading: faces that meet at an angle smaller than this (degrees; `true` = 40) share their normals, so a curved
44
+ * surface made of flat faces looks round and sharper corners stay crisp. Off by default (every face flat).
45
+ */
46
+ smooth?: boolean | number;
47
+ /**
48
+ * Studio light: three lights that follow the camera instead of one fixed sun: a key light (upper left), a fill light
49
+ * (right, softer) and a rim light from behind that edges the shape. Off by default.
50
+ */
51
+ studio?: boolean | {
52
+ fill?: number;
53
+ rim?: number;
54
+ };
42
55
  }
43
56
  /** One copy of a prototype: its position, orthonormal frame and color. */
44
57
  export interface Instance {
@@ -64,8 +77,19 @@ export interface Gpu {
64
77
  * like camera.project(): the same pixels, plus a depth between near and far.
65
78
  */
66
79
  export declare const viewProjection: (cam: Camera, near?: number, far?: number) => Float32Array;
67
- /** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
68
- export declare const buildVertices: (mesh: Mesh, palette?: Palette) => Float32Array;
80
+ /**
81
+ * Smooth shading: a normal for every corner of every face. The faces that meet at a point (welded by position, so a
82
+ * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
83
+ * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
84
+ */
85
+ export declare const cornerNormals: (mesh: Mesh, crease: number) => Vec3[][];
86
+ /**
87
+ * Triangle list for the GPU: face normals (flat, or smoothed with `smooth` degrees), face colors and materials.
88
+ * Float32Array, 14 floats per vertex.
89
+ */
90
+ export declare const buildVertices: (mesh: Mesh, palette?: Palette, { smooth }?: {
91
+ smooth?: number;
92
+ }) => Float32Array;
69
93
  /**
70
94
  * Per-instance data for drawInstances: one entry { at, axes: [ex, ey, ez], color } per copy of a
71
95
  * prototype mesh (modelled around the origin). Float32Array, 15 floats per instance.
@@ -80,7 +104,8 @@ export declare const defaultMaterial: Required<GpuMaterial>;
80
104
  * prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
81
105
  * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
82
106
  */
83
- export declare const createGpu: (canvas: HTMLCanvasElement, { sampleCount }?: {
107
+ export declare const createGpu: (canvas: HTMLCanvasElement, { sampleCount, onError }?: {
84
108
  sampleCount?: number;
109
+ onError?: (message: string) => void;
85
110
  }) => Promise<Gpu>;
86
111
  export {};
package/dist/gpu.js CHANGED
@@ -3,10 +3,10 @@
3
3
  // the cross marks and the framing all stay valid. The pure helpers (viewProjection,
4
4
  // buildVertices) need no GPU and are unit tested; createGpu() needs a browser.
5
5
  import { dot, norm } from './vec.js';
6
- import { faceNormal } from './mesh.js';
6
+ import { bounds, faceNormal } from './mesh.js';
7
7
  import { makeCamera } from './camera.js';
8
8
  import { surfaceOf } from './material.js';
9
- const FLOATS = 13; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit)
9
+ const FLOATS = 14; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit) soft(1)
10
10
  /**
11
11
  * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
12
12
  * like camera.project(): the same pixels, plus a depth between near and far.
@@ -29,26 +29,60 @@ const rgb = (color) => {
29
29
  return [0.78, 0.78, 0.78];
30
30
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
31
31
  };
32
- /** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
33
- export const buildVertices = (mesh, palette) => {
32
+ /**
33
+ * Smooth shading: a normal for every corner of every face. The faces that meet at a point (welded by position, so a
34
+ * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
35
+ * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
36
+ */
37
+ export const cornerNormals = (mesh, crease) => {
38
+ const cosC = Math.cos((crease * Math.PI) / 180);
39
+ const area = mesh.faces.map((f) => faceNormal(f.v.map((k) => mesh.positions[k]))); // length = area
40
+ const unit = area.map((n) => (Math.hypot(...n) > 0 ? norm(n) : [0, 1, 0]));
41
+ const q = (bounds(mesh).radius || 1) * 1e-6;
42
+ const key = (p) => `${Math.round(p[0] / q)},${Math.round(p[1] / q)},${Math.round(p[2] / q)}`;
43
+ const at = new Map();
44
+ mesh.faces.forEach((f, i) => f.v.forEach((k) => {
45
+ const id = key(mesh.positions[k]);
46
+ const list = at.get(id) ?? [];
47
+ if (list[list.length - 1] !== i)
48
+ list.push(i);
49
+ at.set(id, list);
50
+ }));
51
+ return mesh.faces.map((f, i) => f.v.map((k) => {
52
+ const sum = [0, 0, 0];
53
+ for (const j of at.get(key(mesh.positions[k])) ?? []) {
54
+ if (dot(unit[i], unit[j]) < cosC)
55
+ continue;
56
+ for (let c = 0; c < 3; c++)
57
+ sum[c] += area[j][c];
58
+ }
59
+ return Math.hypot(...sum) > 0 ? norm(sum) : unit[i];
60
+ }));
61
+ };
62
+ /**
63
+ * Triangle list for the GPU: face normals (flat, or smoothed with `smooth` degrees), face colors and materials.
64
+ * Float32Array, 14 floats per vertex.
65
+ */
66
+ export const buildVertices = (mesh, palette, { smooth = 0 } = {}) => {
34
67
  let tris = 0;
35
68
  for (const f of mesh.faces)
36
69
  tris += Math.max(0, f.v.length - 2);
37
70
  const out = new Float32Array(tris * 3 * FLOATS);
71
+ const corners = smooth > 0 ? cornerNormals(mesh, smooth) : null;
38
72
  let o = 0;
39
- for (const face of mesh.faces) {
73
+ mesh.faces.forEach((face, fi) => {
40
74
  const pts = face.v.map((k) => mesh.positions[k]);
41
- const n = norm(faceNormal(pts));
75
+ const flat = norm(faceNormal(pts));
42
76
  const s = surfaceOf(face, palette);
43
77
  const c = rgb(s.color);
44
78
  const mat = [s.metal, s.rough, s.alpha, s.emit];
45
79
  for (let j = 1; j < pts.length - 1; j++) {
46
- for (const p of [pts[0], pts[j], pts[j + 1]]) {
47
- out.set([...p, ...n, ...c, ...mat], o);
80
+ for (const k of [0, j, j + 1]) {
81
+ out.set([...pts[k], ...(corners ? corners[fi][k] : flat), ...c, ...mat, s.soft], o);
48
82
  o += FLOATS;
49
83
  }
50
84
  }
51
- }
85
+ });
52
86
  return out;
53
87
  };
54
88
  const INSTANCE_FLOATS = 15; // at(3) ex(3) ey(3) ez(3) color(3)
@@ -71,17 +105,20 @@ struct U {
71
105
  ground : vec4<f32>,
72
106
  eye : vec4<f32>,
73
107
  mat : vec4<f32>, // ambient, diffuse, specular, shininess
74
- fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain
108
+ fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain, studio (0 or 1)
109
+ fill : vec4<f32>, // studio: xyz direction towards the fill light, w intensity
110
+ rim : vec4<f32>, // studio: xyz direction towards the rim light, w intensity
75
111
  };
76
112
  @group(0) @binding(0) var<uniform> u : U;
77
113
 
78
- struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32> };
114
+ struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32>, @location(9) soft : f32 };
79
115
  struct VOut {
80
116
  @builtin(position) p : vec4<f32>,
81
117
  @location(0) n : vec3<f32>,
82
118
  @location(1) c : vec3<f32>,
83
119
  @location(2) w : vec3<f32>,
84
120
  @location(3) m : vec4<f32>, // metal, rough, alpha, emit
121
+ @location(4) s : f32, // soft
85
122
  };
86
123
 
87
124
  @vertex fn vs(i : VIn) -> VOut {
@@ -91,6 +128,7 @@ struct VOut {
91
128
  o.c = i.col;
92
129
  o.w = i.pos;
93
130
  o.m = i.mat;
131
+ o.s = i.soft;
94
132
  return o;
95
133
  }
96
134
 
@@ -110,6 +148,7 @@ struct IIn {
110
148
  o.c = i.col;
111
149
  o.w = w;
112
150
  o.m = vec4<f32>(0.0, 0.7, 1.0, 0.0);
151
+ o.s = 0.0;
113
152
  return o;
114
153
  }
115
154
 
@@ -124,16 +163,39 @@ fn aces(x : vec3<f32>) -> vec3<f32> {
124
163
  return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3<f32>(0.0), vec3<f32>(1.0));
125
164
  }
126
165
 
166
+ // Direct light, shared by both looks: x diffuse, y highlight, z scattered light (soft materials).
167
+ // Studio: the key light is u.light (placed by the camera), plus a softer fill and a rim light that only edges the shape.
168
+ fn lights(n : vec3<f32>, v : vec3<f32>, shin : f32, soft : f32) -> vec3<f32> {
169
+ let key = normalize(u.light.xyz);
170
+ let ndl = dot(n, key);
171
+ // soft: the light wraps round the form, and a warm band of scattered light sits where light turns to shade
172
+ var diff = max((ndl + soft) / (1.0 + soft), 0.0);
173
+ let h = normalize(key + v);
174
+ var spec = pow(max(dot(n, h), 0.0), shin) * step(0.0, ndl);
175
+ let scat = soft * exp(-ndl * ndl * 5.0);
176
+ if (u.fx.w > 0.5) {
177
+ diff = diff * 0.85; // three lights add up: the key takes a little less
178
+ let fl = normalize(u.fill.xyz);
179
+ diff = diff + max((dot(n, fl) + soft) / (1.0 + soft), 0.0) * u.fill.w;
180
+ let rl = normalize(u.rim.xyz);
181
+ let edge = pow(1.0 - max(dot(n, v), 0.0), 2.5);
182
+ diff = diff + max(dot(n, rl), 0.0) * edge * u.rim.w * 2.0;
183
+ let rh = normalize(rl + v);
184
+ spec = spec + pow(max(dot(n, rh), 0.0), shin) * step(0.0, dot(n, rl)) * u.rim.w * 0.6 * edge;
185
+ }
186
+ return vec3<f32>(diff, spec, scat);
187
+ }
188
+
127
189
  // The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
128
190
  fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
129
191
  let metal = i.m.x;
130
192
  let rough = i.m.y;
131
193
  let albedo = toLinear(i.c);
132
- let hemi = mix(toLinear(u.ground.rgb), toLinear(u.sky.rgb), n.y * 0.5 + 0.5) * u.mat.x;
133
- let diff = max(dot(n, l), 0.0) * u.mat.y * 1.6;
134
- let h = normalize(l + v);
194
+ let hemi = mix(toLinear(u.ground.rgb), toLinear(u.sky.rgb), n.y * 0.5 + 0.5) * u.mat.x * select(1.0, 0.7, u.fx.w > 0.5);
135
195
  let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
136
- let spec = pow(max(dot(n, h), 0.0), shin) * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * step(0.0, dot(n, l)) * 2.5;
196
+ let lt = lights(n, v, shin, i.s);
197
+ let diff = lt.x * u.mat.y * 1.6;
198
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * 2.5;
137
199
  let r = reflect(-v, n);
138
200
  // Schlick: edges mirror more, metal keeps its tint
139
201
  let f0 = mix(vec3<f32>(0.04), albedo, metal);
@@ -144,6 +206,7 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
144
206
  + env * fres * (1.0 - rough) * 0.5
145
207
  + vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
146
208
  // emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
209
+ lit = lit + albedo * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.5 * (1.0 - metal); // soft: the warm scattered band
147
210
  lit = lit + albedo * i.m.w * u.fx.z;
148
211
  return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
149
212
  }
@@ -156,18 +219,19 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
156
219
  if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
157
220
  let metal = i.m.x;
158
221
  let rough = i.m.y;
159
- let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
160
- let diff = max(dot(n, l), 0.0) * u.mat.y;
161
- let h = normalize(l + v);
222
+ let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x * select(1.0, 0.7, u.fx.w > 0.5);
162
223
  // roughness widens the highlight; metal makes it strong and tinted by the color
163
224
  let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
164
- let spec = pow(max(dot(n, h), 0.0), shin) * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * step(0.0, dot(n, l));
225
+ let lt = lights(n, v, shin, i.s);
226
+ let diff = lt.x * u.mat.y;
227
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough);
165
228
  // metal mirrors the sky above and the ground below, blurred by roughness
166
229
  let r = reflect(-v, n);
167
230
  let env = mix(u.ground.rgb * 1.2, u.sky.rgb * 1.5, smoothstep(-0.25 - rough * 0.5, 0.25 + rough * 0.5, r.y));
168
231
  var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
169
232
  + i.c * env * metal * (1.0 - 0.6 * rough)
170
- + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
233
+ + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal)
234
+ + i.c * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.25 * (1.0 - metal);
171
235
  lit = mix(lit, i.c * 1.15, min(i.m.w, 1.0)); // the plain look: emit 0..1 fades to flat colour, more does not add
172
236
  return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
173
237
  }
@@ -179,14 +243,18 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
179
243
  * prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
180
244
  * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
181
245
  */
182
- export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
246
+ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
183
247
  if (!globalThis.navigator?.gpu)
184
248
  throw new Error('WebGPU is not available in this browser');
185
249
  const adapter = await navigator.gpu.requestAdapter();
186
250
  if (!adapter)
187
251
  throw new Error('No WebGPU adapter found');
188
252
  const device = await adapter.requestDevice();
189
- device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
253
+ device.addEventListener('uncapturederror', (e) => {
254
+ const message = String(e.error?.message ?? e);
255
+ console.error('WebGPU:', message);
256
+ onError?.(message);
257
+ });
190
258
  device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
191
259
  const context = canvas.getContext('webgpu');
192
260
  if (!context)
@@ -194,6 +262,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
194
262
  const format = navigator.gpu.getPreferredCanvasFormat();
195
263
  context.configure({ device, format, alphaMode: 'opaque', });
196
264
  const module = device.createShaderModule({ code: SHADER });
265
+ // shader compile errors are reported to onError too (the browser only logs them to the console)
266
+ module.getCompilationInfo?.().then((info) => {
267
+ for (const m of info.messages ?? [])
268
+ if (m.type === 'error')
269
+ onError?.(`shader line ${m.lineNum}: ${m.message}`);
270
+ });
197
271
  const meshLayout = {
198
272
  arrayStride: FLOATS * 4,
199
273
  attributes: [
@@ -201,6 +275,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
201
275
  { shaderLocation: 1, offset: 12, format: 'float32x3' },
202
276
  { shaderLocation: 2, offset: 24, format: 'float32x3' },
203
277
  { shaderLocation: 8, offset: 36, format: 'float32x4' },
278
+ { shaderLocation: 9, offset: 52, format: 'float32' },
204
279
  ],
205
280
  };
206
281
  const instanceLayout = {
@@ -208,8 +283,14 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
208
283
  stepMode: 'instance',
209
284
  attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
210
285
  };
286
+ // ONE explicit layout for every pipeline: with 'auto' each pipeline gets its own, and a bind group made for one is refused by the others
287
+ const bindLayout = device.createBindGroupLayout({
288
+ // visibility 1 | 2 = GPUShaderStage.VERTEX | FRAGMENT
289
+ entries: [{ binding: 0, visibility: 1 | 2, buffer: { type: 'uniform' } }],
290
+ });
291
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });
211
292
  const make = (entry, buffers, glass = false) => device.createRenderPipeline({
212
- layout: 'auto',
293
+ layout: pipelineLayout,
213
294
  vertex: { module, entryPoint: entry, buffers },
214
295
  fragment: {
215
296
  module,
@@ -235,12 +316,13 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
235
316
  const glassPipeline = make('vs', [meshLayout], true);
236
317
  // the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
237
318
  const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
238
- const uniforms = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
239
- const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
240
- const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
319
+ const uniforms = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
320
+ const bind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
321
+ const instBind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
241
322
  // Geometry that did not change since the last frame is not rebuilt or uploaded again.
242
323
  let uploaded = null; // the mesh now in vertexBuffer
243
324
  let uploadedPalette; // and the palette it was built with
325
+ let uploadedSmooth = 0; // and the smoothing
244
326
  let vertexCount = 0;
245
327
  let protoMesh = null;
246
328
  let protoBuffer = null;
@@ -271,14 +353,16 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
271
353
  depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
272
354
  color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
273
355
  };
274
- const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false } = {}, readback = false) => {
356
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false, smooth = false, studio = false } = {}, readback = false) => {
275
357
  const L = { ...defaultLight, ...light };
276
358
  const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
277
359
  const M = { ...defaultMaterial, ...material };
278
360
  const cam = makeCamera(view, mesh, size, fit);
279
361
  targets(Math.max(1, Math.round(size * dpr)));
280
- if (mesh !== uploaded || palette !== uploadedPalette) {
281
- const all = buildVertices(mesh, palette);
362
+ const crease = smooth === true ? 40 : smooth || 0;
363
+ if (mesh !== uploaded || palette !== uploadedPalette || crease !== uploadedSmooth) {
364
+ uploadedSmooth = crease;
365
+ const all = buildVertices(mesh, palette, { smooth: crease });
282
366
  // split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
283
367
  const tri = 3 * FLOATS;
284
368
  const solid = [];
@@ -346,14 +430,19 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
346
430
  }
347
431
  if (!glassTris.length)
348
432
  glassCount = 0;
349
- const u = new Float32Array(40);
433
+ // studio rig, relative to the camera: key upper left in front, fill from the right, rim from behind and above
434
+ const rig = (f, r, up) => norm([0, 1, 2].map((k) => -cam.f[k] * f + cam.r[k] * r + cam.u[k] * up));
435
+ const S = studio ? { fill: 0.4, rim: 1, ...(studio === true ? {} : studio) } : null;
436
+ const u = new Float32Array(48);
350
437
  u.set(viewProjection(cam), 0);
351
- u.set([...(norm(L.direction)), 0], 16);
438
+ u.set([...(S ? rig(0.7, -0.5, 0.6) : norm(L.direction)), 0], 16);
352
439
  u.set([...L.sky, 0], 20);
353
440
  u.set([...L.ground, 0], 24);
354
441
  u.set([...cam.eye, 0], 28);
355
442
  u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
356
- u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, 0], 36);
443
+ u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, S ? 1 : 0], 36);
444
+ u.set([...rig(0.6, 0.8, 0.1), S ? S.fill : 0], 40);
445
+ u.set([...rig(-0.8, 0.3, 0.5), S ? S.rim : 0], 44);
357
446
  device.queue.writeBuffer(uniforms, 0, u);
358
447
  const encoder = device.createCommandEncoder();
359
448
  const offscreen = readback
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export declare const name: "3draft";
2
- export declare const version = "26.11.4010";
2
+ export declare const version = "26.11.5001";
3
3
  export type { Vec3, Face, Mesh, Axes, ViewName, ViewSpec, Fit, Camera, Hit, Mark } from './types.js';
4
4
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
5
5
  export type { BoxOptions } from './mesh.js';
@@ -14,7 +14,7 @@ export { standOn } from './place.js';
14
14
  export type { StandOptions, Frame } from './place.js';
15
15
  export { paintOrder } from './order.js';
16
16
  export type { PaintItem } from './order.js';
17
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
17
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
18
18
  export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
19
19
  export { renderView, renderViews, renderSheet } from './svg.js';
20
20
  export type { RenderOptions } from './svg.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // a target cross that lies on the surface under the cursor. No textures, no
5
5
  // shading yet.
6
6
  export const name = '3draft';
7
- export const version = '26.11.4010';
7
+ export const version = '26.11.5001';
8
8
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
9
9
  export { validate } from './check.js';
10
10
  export { boolean, union, subtract, intersect, health } from './csg.js';
@@ -13,7 +13,7 @@ export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
13
13
  export { pick, crosshair } from './pick.js';
14
14
  export { standOn } from './place.js';
15
15
  export { paintOrder } from './order.js';
16
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
16
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
17
17
  export { renderView, renderViews, renderSheet } from './svg.js';
18
18
  export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
19
19
  export { displayVersion } from './version.js';
@@ -10,6 +10,8 @@ export interface Material {
10
10
  alpha?: number;
11
11
  /** 0..5: how much the color glows on its own, unlit. Up to 1 it fades from lit to flat colour; above 1 (WebGPU `tonemap`) it is a light source: that many times brighter. */
12
12
  emit?: number;
13
+ /** 0..1: soft, skin-like light. The light wraps round the form and a warm, scattered band shows where light turns to shade. */
14
+ soft?: number;
13
15
  }
14
16
  export type Palette = Record<string, Material>;
15
17
  /** What a face is made of, all numbers filled in. */
@@ -19,6 +21,7 @@ export interface Surface {
19
21
  rough: number;
20
22
  alpha: number;
21
23
  emit: number;
24
+ soft: number;
22
25
  }
23
26
  export declare const EMIT_MAX = 5;
24
27
  export declare const FALLBACK_COLOR = "#c8c8c8";
@@ -27,6 +30,7 @@ export declare const DEFAULTS: {
27
30
  readonly rough: 0.7;
28
31
  readonly alpha: 1;
29
32
  readonly emit: 0;
33
+ readonly soft: 0;
30
34
  };
31
35
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
32
36
  export declare const surfaceOf: (face: Pick<Face, "color" | "mat">, palette?: Palette) => Surface;
package/dist/material.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const clamp = (n, fallback, max = 1) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback);
5
5
  export const EMIT_MAX = 5;
6
6
  export const FALLBACK_COLOR = '#c8c8c8';
7
- export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
7
+ export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0, soft: 0 };
8
8
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
9
9
  export const surfaceOf = (face, palette) => {
10
10
  const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
@@ -14,5 +14,6 @@ export const surfaceOf = (face, palette) => {
14
14
  rough: clamp(m?.rough, DEFAULTS.rough),
15
15
  alpha: clamp(m?.alpha, DEFAULTS.alpha),
16
16
  emit: clamp(m?.emit, DEFAULTS.emit, EMIT_MAX),
17
+ soft: clamp(m?.soft, DEFAULTS.soft),
17
18
  };
18
19
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3draft",
3
- "version": "26.11.4010",
3
+ "version": "26.11.5001",
4
4
  "description": "Minimalist AI first 3D engine: palette-texture, WASM core, WebGPU/WebGL2 renderer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",