3draft 0.0.1 → 26.11.1013

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
@@ -9,12 +9,167 @@ Minimalist AI first 3D engine.
9
9
  npm install 3draft
10
10
  ```
11
11
 
12
+ ## Usage (v0.1: points, edges, flat faces, SVG)
13
+
12
14
  ```js
13
- import { info } from '3draft';
15
+ import { writeFileSync } from 'node:fs';
16
+ import { box, merge, renderSheet, renderViews } from '3draft';
17
+
18
+ const table = merge(
19
+ box({ size: [2, 0.15, 1.2], at: [0, 0.925, 0], color: '#c8a27a' }),
20
+ box({ size: [0.15, 0.85, 0.15], at: [-0.85, 0.425, -0.45], color: '#8a6a4a' }),
21
+ // ...more legs
22
+ );
23
+
24
+ writeFileSync('table.svg', renderSheet(table)); // 2x2 sheet
14
25
 
15
- console.log(info());
26
+ for (const { name, svg } of renderViews(table)) {
27
+ // one after another: 'front', 'side', 'top', 'persp'
28
+ writeFileSync(`table.${name}.svg`, svg);
29
+ }
16
30
  ```
17
31
 
32
+ `npm run example` writes the table above to `examples/out/`:
33
+
34
+ ![table sheet](examples/out/table.sheet.svg)
35
+
36
+ Views: `front` (eye on +z), `side` (eye on +x), `top` (eye on +y), all
37
+ orthographic with the same scale, and a perspective `persp` view. Every
38
+ `<polygon>` carries `data-face` and `data-depth`, every `<circle>` carries
39
+ `data-v`, so the output can be inspected as plain text.
40
+
41
+ ### Surfaces: what is under the cursor
42
+
43
+ The engine senses surfaces, not just pixels. `pick` casts a real ray from a
44
+ screen pixel into the geometry and returns the face, the hit point, the
45
+ surface normal and the distance. `crosshair` builds a target cross that lies
46
+ flat on that surface (perpendicular to its normal), aligned to the face, so it
47
+ turns with the object. Marks are drawn on top by `renderView`.
48
+
49
+ ```js
50
+ import { makeCamera, pick, crosshair, renderView } from '3draft';
51
+
52
+ const cam = makeCamera('persp', mesh, 400);
53
+ const hit = pick(mesh, cam, x, y); // { face, point, normal, distance, color } or null
54
+
55
+ const marks = hit ? [{ name: 'cursor', lines: crosshair(mesh, hit, 0.08) }] : [];
56
+ const svg = renderView(mesh, 'persp', { marks });
57
+ ```
58
+
59
+ Picking uses the geometry, not the painter's drawing order, so it stays exact
60
+ where the drawing has artifacts. `ray(cam, x, y)` is the inverse of
61
+ `project`. The viewer shows the cross under the pointer and reads out the face
62
+ and its object-space normal.
63
+
64
+ Any camera works: `renderView(mesh, { dir, up, ortho, name })` takes a custom
65
+ view, `rotate(mesh, { yaw, pitch })` turns the mesh, and `{ fit: bounds(mesh) }`
66
+ keeps the framing fixed while it moves.
67
+
68
+ ### Placing things
69
+
70
+ `orbit({ yaw, pitch })` is a turntable as a camera, so the scene stays in object
71
+ space and picks stay valid. `standOn(mesh, hit, { base, height, color })`
72
+ returns a prism standing on the picked surface along its normal (height
73
+ defaults to 3 x base). In the viewer, Space puts one down under the cross and
74
+ Backspace takes the last one back. Over empty space Space shoots the prism out along the cursor ray to a random distance, animated. The viewer starts empty (no table) and lays out only what you place. D deletes the prism under the cross. H toggles edges and points. T flies the camera, animated, to the object under the cross and in front of the surface you point at (the whole scene is framed again when T is pressed over nothing, or with Reset view). Prisms get a random wood brown.
75
+
76
+ ### WebGPU view
77
+
78
+ `createGpu(canvas)` returns a WebGPU renderer that draws the same scene through
79
+ the same camera as the SVG renderer, so picking, framing and the cross marks stay
80
+ valid. Faces are flat colored (the face color is the albedo) and lit by one
81
+ material (`ambient`, `diffuse`, `specular`, `shininess`) and one global light
82
+ (hemisphere ambient plus one directional light):
83
+
84
+ ```js
85
+ import { createGpu, orbit } from '3draft';
86
+ const gpu = await createGpu(canvas); // rejects when WebGPU is missing
87
+ gpu.render(mesh, orbit({ yaw, pitch }), {
88
+ size: 600, // CSS px; the canvas is made size x size x dpr
89
+ fit, // keep the framing fixed, as with SVG
90
+ light: { direction: [0.5, 0.8, 0.6] },
91
+ material: { diffuse: 0.8 },
92
+ });
93
+ ```
94
+
95
+ `viewProjection(camera)` is the matrix behind it; it is unit tested to land every
96
+ point on the same pixel as `project()`. `gpu.pixels(...)` draws the same frame
97
+ offscreen and reads it back as RGBA.
98
+
99
+ ### MCP server: let an AI build the models
100
+
101
+ `mcp/server.js` is an MCP server (stdio, no dependencies) that holds a scene of
102
+ named objects and edits it through tool calls. The AI looks at the SVG preview,
103
+ fixes what is wrong and renders again, so it never needs a screenshot.
104
+
105
+ ```sh
106
+ # Claude Code
107
+ claude mcp add 3draft -- node /absolute/path/to/3Draft/mcp/server.js
108
+ ```
109
+
110
+ ```json
111
+ { "mcpServers": { "3draft": { "command": "node", "args": ["/absolute/path/to/3Draft/mcp/server.js"] } } }
112
+ ```
113
+
114
+ (that JSON goes into `claude_desktop_config.json` for Claude Desktop.)
115
+
116
+ **Live viewer.** The same process serves the viewer and pushes every change to it
117
+ (Server-Sent Events). Ask the AI for the `viewer` tool's URL (default
118
+ `http://localhost:5173/app/`, next free port if that is taken, `3DRAFT_PORT` to
119
+ change it, `3DRAFT_LIVE=0` to turn it off), open it in a browser and watch the
120
+ model grow while the AI builds. The view re-frames to the scene; the objects are
121
+ read-only in the viewer, but you can still rotate, switch to WebGPU, and place or
122
+ delete your own prisms on top. Loopback only, other `Host` headers are refused.
123
+
124
+ | tool | what it does |
125
+ | --- | --- |
126
+ | `add_box`, `add_extrusion`, `add_mesh` | boxes, prisms from a convex footprint, raw faces |
127
+ | `transform`, `recolor`, `remove`, `clear` | scale / rotate Y / move, colors, deleting |
128
+ | `get_mesh`, `extrude_face`, `move_vertices` | shape an object: push, taper, inset or dent a face, move vertices |
129
+ | `list`, `inspect` | objects and bounds; closed shell, winding, planar and convex faces, as text notes |
130
+ | `render` | SVG: a 2x2 sheet (front, side, top, persp) or one view, also `orbit` with yaw/pitch |
131
+ | `pick` | pixel of a render -> object, face, 3D point, normal |
132
+ | `export`, `import` | scene as JSON, optionally a `.json` file under the working directory |
133
+ | `viewer` | URL of the live viewer |
134
+
135
+ Y is up, the ground is y = 0, faces are counter-clockwise seen from outside.
136
+ Everything is deterministic, so the same calls give the same SVG.
137
+
138
+ ### Known limitations
139
+
140
+ - Faces are painter-sorted with plane tests (Newell-Sancha style), which is
141
+ exact for solids that touch or stand on each other. Interpenetrating solids
142
+ and cyclic overlaps fall back to center depth and can show artifacts; a
143
+ face-splitting pass is planned.
144
+ - Edges are the polygon outlines, so hidden edges are not drawn.
145
+ - Back-face culling assumes closed meshes with CCW faces seen from outside.
146
+
147
+ ## Viewer app (repo only, not part of the npm package)
148
+
149
+ ```sh
150
+ npm run app # http://localhost:5173/app/
151
+ ```
152
+
153
+ Drag to rotate the view, double click to reset. G switches between the SVG and the WebGPU view, L turns the light (WebGPU). A
154
+ target cross follows the pointer and lies on the surface under it. It is a
155
+ single `app/index.html` plus a dependency-free static server
156
+ (`app/serve.js`, localhost only, serves `app/`, `src/` and `examples/`).
157
+
158
+ ## Versioning
159
+
160
+ Date based, written `YY.MM.DD.NNN`. npm needs valid semver (three numbers, no
161
+ leading zeros), so the published form is `YY.M.DNNN` where the last number is
162
+ day * 1000 + build:
163
+
164
+ | readable | package version |
165
+ | -------------- | --------------- |
166
+ | `26.11.01.001` | `26.11.1001` |
167
+ | `26.11.01.002` | `26.11.1002` |
168
+ | `26.11.15.012` | `26.11.15012` |
169
+
170
+ `npm run bump` sets the next version for today's date in `package.json` and
171
+ `src/index.js` (`node scripts/bump.js --dry` only prints it).
172
+
18
173
  ## Idea
19
174
 
20
175
  - **One material, one palette texture.** Every face's UV points at a single
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3draft",
3
- "version": "0.0.1",
3
+ "version": "26.11.1013",
4
4
  "description": "Minimalist AI first 3D engine: palette-texture, WASM core, WebGPU/WebGL2 renderer.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -19,10 +19,15 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test"
22
+ "test": "node --test",
23
+ "mcp": "node mcp/server.js",
24
+ "example": "node examples/table.js",
25
+ "app": "node app/serve.js",
26
+ "bump": "node scripts/bump.js"
23
27
  },
24
28
  "keywords": [
25
29
  "3d",
30
+ "svg",
26
31
  "webgpu",
27
32
  "webgl2",
28
33
  "wasm",
package/src/camera.js ADDED
@@ -0,0 +1,75 @@
1
+ // Four named views, all looking at the mesh bounding-box center.
2
+ // front / side / top are orthographic (same scale), persp is a perspective view.
3
+ // A view can also be a custom spec: { dir, up, ortho, name? } where `dir` is the
4
+ // direction from the target towards the eye.
5
+
6
+ import { add, cross, dot, norm, scale, sub } from './vec.js';
7
+ import { bounds } from './mesh.js';
8
+
9
+ export const VIEW_ORDER = ['front', 'side', 'top', 'persp'];
10
+
11
+ const FOV = (35 * Math.PI) / 180;
12
+
13
+ const VIEWS = {
14
+ front: { dir: [0, 0, 1], up: [0, 1, 0], ortho: true }, // eye on +z
15
+ side: { dir: [1, 0, 0], up: [0, 1, 0], ortho: true }, // eye on +x
16
+ top: { dir: [0, 1, 0], up: [0, 0, -1], ortho: true }, // eye on +y, -z is screen-up
17
+ persp: { dir: norm([1, 0.8, 1.2]), up: [0, 1, 0], ortho: false },
18
+ };
19
+
20
+ /**
21
+ * `fit` = { center, radius } overrides the target and framing. Use it when the
22
+ * mesh moves (e.g. rotates) so the camera keeps looking at the same spot.
23
+ */
24
+ export const makeCamera = (view, mesh, size, fit) => {
25
+ const spec = typeof view === 'string' ? VIEWS[view] : view;
26
+ if (!spec) throw new Error(`unknown view: ${view}`);
27
+ const name = typeof view === 'string' ? view : (view.name ?? 'custom');
28
+ const ortho = Boolean(spec.ortho);
29
+ const { center, radius } = fit ?? bounds(mesh);
30
+ const r0 = radius || 1;
31
+ const dist = ortho ? r0 * 4 : (r0 * 1.25) / Math.sin(FOV / 2);
32
+ const eye = add(center, scale(norm(spec.dir), dist));
33
+ const f = norm(sub(center, eye)); // forward
34
+ const r = norm(cross(f, spec.up)); // right
35
+ const u = cross(r, f); // up
36
+ const k = ortho ? ((size / 2) * 0.8) / r0 : size / 2 / Math.tan(FOV / 2);
37
+ return { name, eye, f, r, u, ortho, k, size };
38
+ };
39
+
40
+ /**
41
+ * Turntable rotation as a camera: the view you get from rotate(mesh, { yaw, pitch })
42
+ * with a fixed camera, but the mesh stays where it is. Picks and marks are then
43
+ * in object space.
44
+ */
45
+ export const orbit = ({ yaw = 0, pitch = 0, ortho = false, name = 'orbit' } = {}) => {
46
+ const cy = Math.cos(yaw);
47
+ const sy = Math.sin(yaw);
48
+ const cp = Math.cos(pitch);
49
+ const sp = Math.sin(pitch);
50
+ return { name, ortho, dir: [-sy * cp, sp, cy * cp], up: [sy * sp, cp, -cy * sp] };
51
+ };
52
+
53
+ /** Ray through panel pixel (x, y), the inverse of project(): { origin, dir } in world space, dir is a unit vector. */
54
+ export const ray = (cam, x, y) => {
55
+ const nx = (x - cam.size / 2) / cam.k;
56
+ const ny = -(y - cam.size / 2) / cam.k;
57
+ const lateral = add(scale(cam.r, nx), scale(cam.u, ny));
58
+ return cam.ortho
59
+ ? { origin: add(cam.eye, lateral), dir: cam.f }
60
+ : { origin: cam.eye, dir: norm(add(cam.f, lateral)) };
61
+ };
62
+
63
+ /** World point -> { x, y } in panel pixels (y down) and depth along the view direction. */
64
+ export const project = (cam, p) => {
65
+ const d = sub(p, cam.eye);
66
+ const cx = dot(d, cam.r);
67
+ const cy = dot(d, cam.u);
68
+ const depth = dot(d, cam.f);
69
+ const w = cam.ortho ? 1 : depth;
70
+ return {
71
+ x: cam.size / 2 + (cx / w) * cam.k,
72
+ y: cam.size / 2 - (cy / w) * cam.k,
73
+ depth,
74
+ };
75
+ };
package/src/check.js ADDED
@@ -0,0 +1,79 @@
1
+ // Model checks an AI can read as text: is the mesh closed, are the faces CCW from
2
+ // outside, planar and convex (picking and the painter assume that).
3
+
4
+ import { sub, dot, cross, len, norm } from './vec.js';
5
+ import { faceNormal, bounds } from './mesh.js';
6
+
7
+ /**
8
+ * { vertices, faces, closed, openEdges, volume, inside_out, degenerateFaces,
9
+ * nonPlanarFaces, nonConvexFaces, ok, notes }
10
+ * `volume` and `inside_out` only mean something for a closed mesh.
11
+ */
12
+ export const validate = (mesh) => {
13
+ const { positions, faces } = mesh;
14
+ const size = bounds(mesh).radius || 1;
15
+ const degenerate = [];
16
+ const nonPlanar = [];
17
+ const nonConvex = [];
18
+ const directed = new Map();
19
+ let volume = 0;
20
+
21
+ faces.forEach((face, i) => {
22
+ const pts = face.v.map((k) => positions[k]);
23
+ const m = pts.length;
24
+ const n = faceNormal(pts);
25
+ const area2 = len(n);
26
+ if (area2 < 1e-12 * size * size) {
27
+ degenerate.push(i);
28
+ return;
29
+ }
30
+ const unit = norm(n);
31
+ if (Math.max(...pts.map((p) => Math.abs(dot(sub(p, pts[0]), unit)))) > 1e-6 * size) nonPlanar.push(i);
32
+ for (let j = 0; j < m; j++) {
33
+ const a = pts[j];
34
+ const b = pts[(j + 1) % m];
35
+ const c = pts[(j + 2) % m];
36
+ if (dot(cross(sub(b, a), sub(c, b)), unit) < -1e-9 * size * size) {
37
+ nonConvex.push(i);
38
+ break;
39
+ }
40
+ }
41
+ for (let j = 0; j < m; j++) {
42
+ const key = `${face.v[j]},${face.v[(j + 1) % m]}`;
43
+ directed.set(key, (directed.get(key) ?? 0) + 1);
44
+ }
45
+ for (let j = 1; j < m - 1; j++) volume += dot(pts[0], cross(pts[j], pts[j + 1])) / 6;
46
+ });
47
+
48
+ let open = 0;
49
+ let repeated = 0;
50
+ for (const [key, count] of directed) {
51
+ const [a, b] = key.split(',');
52
+ if (count > 1) repeated += 1;
53
+ if (!directed.has(`${b},${a}`)) open += 1;
54
+ }
55
+ const closed = faces.length > 0 && open === 0 && repeated === 0;
56
+ const insideOut = closed && volume < 0;
57
+
58
+ const notes = [];
59
+ if (degenerate.length) notes.push(`degenerate faces (no area): ${degenerate.join(', ')}`);
60
+ if (nonPlanar.length) notes.push(`non-planar faces: ${nonPlanar.join(', ')}`);
61
+ if (nonConvex.length) notes.push(`non-convex faces (picking and painting assume convex): ${nonConvex.join(', ')}`);
62
+ if (repeated) notes.push(`${repeated} edge(s) are used twice in the same direction: faces are wound inconsistently`);
63
+ if (open) notes.push(`${open} open edge(s): the mesh is not closed (fine for a surface, e.g. a missing bottom)`);
64
+ if (insideOut) notes.push('the faces point inward: reverse the vertex order of every face');
65
+
66
+ return {
67
+ vertices: positions.length,
68
+ faces: faces.length,
69
+ closed,
70
+ openEdges: open,
71
+ volume: closed ? Math.round(volume * 1e9) / 1e9 : null,
72
+ inside_out: insideOut,
73
+ degenerateFaces: degenerate,
74
+ nonPlanarFaces: nonPlanar,
75
+ nonConvexFaces: nonConvex,
76
+ ok: !degenerate.length && !nonPlanar.length && !nonConvex.length && !repeated && !insideOut,
77
+ notes,
78
+ };
79
+ };
package/src/edit.js ADDED
@@ -0,0 +1,47 @@
1
+ // Shaping an existing mesh: the topology edits a modeller does by hand.
2
+ // Both functions return a NEW mesh; faces stay CCW from outside and closed meshes stay closed.
3
+
4
+ import { add, sub, scale as mul, norm } from './vec.js';
5
+ import { faceNormal, faceCenter } from './mesh.js';
6
+
7
+ /**
8
+ * Extrude a face: it is replaced (same index) by a copy moved `distance` along its
9
+ * normal and scaled by `scale` about its center, and one quad per edge joins the two.
10
+ * distance > 0, scale 1 a prism out of the face
11
+ * distance > 0, scale < 1 a taper (roof, spire, frustum)
12
+ * distance 0, scale < 1 an inset (a panel, a window frame)
13
+ * distance < 0 a dent into the solid (a niche, a door recess)
14
+ * The new quads are appended after the existing faces; `color` recolors the cap and
15
+ * the quads, otherwise they keep the face color.
16
+ * Returns { mesh, cap, sides } with the index of the cap face and the new quad indices.
17
+ */
18
+ export const extrudeFace = (mesh, index, { distance = 0, scale = 1, color } = {}) => {
19
+ const face = mesh.faces[index];
20
+ if (!face) throw new Error(`no face ${index}: this mesh has ${mesh.faces.length} faces`);
21
+ if (!(scale > 0)) throw new Error('scale must be positive');
22
+ if (distance === 0 && scale === 1) throw new Error('nothing to do: give a distance or a scale other than 1');
23
+ const pts = face.v.map((k) => mesh.positions[k]);
24
+ const n = norm(faceNormal(pts));
25
+ const c = faceCenter(pts);
26
+ const moved = pts.map((p) => add(add(c, mul(sub(p, c), scale)), mul(n, distance)));
27
+ const base = mesh.positions.length;
28
+ const m = pts.length;
29
+ const tint = color ?? face.color;
30
+ const faces = mesh.faces.map((f, i) => (i === index ? { ...f, v: moved.map((_, j) => base + j), color: tint } : f));
31
+ const sides = [];
32
+ for (let i = 0; i < m; i++) {
33
+ sides.push(faces.length);
34
+ faces.push({ v: [face.v[i], face.v[(i + 1) % m], base + ((i + 1) % m), base + i], color: tint });
35
+ }
36
+ return { mesh: { positions: [...mesh.positions, ...moved], faces }, cap: index, sides };
37
+ };
38
+
39
+ /** Move the vertices at `indices` by `delta` [dx, dy, dz]. Faces keep their vertices, so faces may stop being planar: check with validate(). */
40
+ export const moveVertices = (mesh, indices, delta) => {
41
+ const set = new Set();
42
+ for (const i of indices) {
43
+ if (!Number.isInteger(i) || i < 0 || i >= mesh.positions.length) throw new Error(`vertex ${i} does not exist: this mesh has ${mesh.positions.length} vertices`);
44
+ set.add(i);
45
+ }
46
+ return { ...mesh, positions: mesh.positions.map((p, i) => (set.has(i) ? add(p, delta) : p)) };
47
+ };
package/src/gpu.js ADDED
@@ -0,0 +1,252 @@
1
+ // WebGPU renderer: flat-colored faces, one simple lit material, one global light.
2
+ // It draws the same scene through the same camera as the SVG renderer, so picking,
3
+ // the cross marks and the framing all stay valid. The pure helpers (viewProjection,
4
+ // buildVertices) need no GPU and are unit tested; createGpu() needs a browser.
5
+
6
+ import { sub, dot, norm } from './vec.js';
7
+ import { faceNormal } from './mesh.js';
8
+ import { makeCamera } from './camera.js';
9
+
10
+ const FLOATS = 9; // position(3) normal(3) color(3)
11
+
12
+ /**
13
+ * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
14
+ * like camera.project(): the same pixels, plus a depth between near and far.
15
+ */
16
+ export const viewProjection = (cam, near = 0.1, far = 1000) => {
17
+ const kk = cam.k / (cam.size / 2);
18
+ const row = (v, s) => [v[0] * s, v[1] * s, v[2] * s, -dot(v, cam.eye) * s];
19
+ const a = cam.ortho ? 1 / (far - near) : far / (far - near);
20
+ const zr = row(cam.f, a);
21
+ zr[3] -= a * near;
22
+ const rows = [row(cam.r, kk), row(cam.u, kk), zr, cam.ortho ? [0, 0, 0, 1] : row(cam.f, 1)];
23
+ const m = new Float32Array(16);
24
+ rows.forEach((r, i) => r.forEach((v, j) => (m[j * 4 + i] = v)));
25
+ return m;
26
+ };
27
+
28
+ const rgb = (color) => {
29
+ const h = String(color ?? '').replace('#', '');
30
+ const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
31
+ if (!/^[0-9a-f]{6}$/i.test(full)) return [0.78, 0.78, 0.78];
32
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
33
+ };
34
+
35
+ /** Triangle list for the GPU: flat face normals and face colors. Float32Array, 9 floats per vertex. */
36
+ export const buildVertices = (mesh) => {
37
+ let tris = 0;
38
+ for (const f of mesh.faces) tris += Math.max(0, f.v.length - 2);
39
+ const out = new Float32Array(tris * 3 * FLOATS);
40
+ let o = 0;
41
+ for (const face of mesh.faces) {
42
+ const pts = face.v.map((k) => mesh.positions[k]);
43
+ const n = norm(faceNormal(pts));
44
+ const c = rgb(face.color);
45
+ 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], o);
48
+ o += FLOATS;
49
+ }
50
+ }
51
+ }
52
+ return out;
53
+ };
54
+
55
+ export const defaultLight = { direction: norm([0.5, 0.8, 0.6]), sky: [1, 0.97, 0.92], ground: [0.42, 0.38, 0.36] };
56
+ export const defaultMaterial = { ambient: 0.62, diffuse: 0.78, specular: 0.2, shininess: 30 };
57
+
58
+ const SHADER = /* wgsl */ `
59
+ struct U {
60
+ vp : mat4x4<f32>,
61
+ light : vec4<f32>, // xyz: direction towards the light
62
+ sky : vec4<f32>,
63
+ ground : vec4<f32>,
64
+ eye : vec4<f32>,
65
+ mat : vec4<f32>, // ambient, diffuse, specular, shininess
66
+ };
67
+ @group(0) @binding(0) var<uniform> u : U;
68
+
69
+ struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32> };
70
+ struct VOut {
71
+ @builtin(position) p : vec4<f32>,
72
+ @location(0) n : vec3<f32>,
73
+ @location(1) c : vec3<f32>,
74
+ @location(2) w : vec3<f32>,
75
+ };
76
+
77
+ @vertex fn vs(i : VIn) -> VOut {
78
+ var o : VOut;
79
+ o.p = u.vp * vec4<f32>(i.pos, 1.0);
80
+ o.n = i.nrm;
81
+ o.c = i.col;
82
+ o.w = i.pos;
83
+ return o;
84
+ }
85
+
86
+ @fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
87
+ var n = normalize(i.n);
88
+ if (!front) { n = -n; }
89
+ let l = normalize(u.light.xyz);
90
+ let v = normalize(u.eye.xyz - i.w);
91
+ let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
92
+ let diff = max(dot(n, l), 0.0) * u.mat.y;
93
+ let h = normalize(l + v);
94
+ let spec = pow(max(dot(n, h), 0.0), u.mat.w) * u.mat.z * step(0.0, dot(n, l));
95
+ let lit = i.c * (hemi + vec3<f32>(diff)) + vec3<f32>(spec);
96
+ return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
97
+ }
98
+ `;
99
+
100
+ /**
101
+ * Sets up WebGPU on a canvas. Rejects when WebGPU is not available.
102
+ * Returns { render(mesh, view, options), destroy() }.
103
+ * render options: size (CSS px, the canvas is made size x size), fit, background [r,g,b] 0..1,
104
+ * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
105
+ */
106
+ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
107
+ if (!globalThis.navigator?.gpu) throw new Error('WebGPU is not available in this browser');
108
+ const adapter = await navigator.gpu.requestAdapter();
109
+ if (!adapter) throw new Error('No WebGPU adapter found');
110
+ const device = await adapter.requestDevice();
111
+ device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
112
+ device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
113
+ const context = canvas.getContext('webgpu');
114
+ if (!context) throw new Error('Could not get a WebGPU canvas context');
115
+ const format = navigator.gpu.getPreferredCanvasFormat();
116
+ context.configure({ device, format, alphaMode: 'opaque', });
117
+
118
+ const module = device.createShaderModule({ code: SHADER });
119
+ const pipeline = device.createRenderPipeline({
120
+ layout: 'auto',
121
+ vertex: {
122
+ module,
123
+ entryPoint: 'vs',
124
+ buffers: [
125
+ {
126
+ arrayStride: FLOATS * 4,
127
+ attributes: [
128
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
129
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
130
+ { shaderLocation: 2, offset: 24, format: 'float32x3' },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ fragment: { module, entryPoint: 'fs', targets: [{ format }] },
136
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
137
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
138
+ multisample: { count: sampleCount },
139
+ });
140
+ const uniforms = device.createBuffer({ size: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
141
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
142
+
143
+ let vertexBuffer = null;
144
+ let capacity = 0;
145
+ let px = 0;
146
+ let depth = null;
147
+ let color = null;
148
+
149
+ const targets = (n) => {
150
+ if (n === px) return;
151
+ px = n;
152
+ canvas.width = n;
153
+ canvas.height = n;
154
+ depth?.destroy();
155
+ color?.destroy();
156
+ depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
157
+ color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
158
+ };
159
+
160
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, dpr = globalThis.devicePixelRatio || 1 } = {}, readback = false) => {
161
+ const L = { ...defaultLight, ...light };
162
+ const M = { ...defaultMaterial, ...material };
163
+ const cam = makeCamera(view, mesh, size, fit);
164
+ targets(Math.max(1, Math.round(size * dpr)));
165
+
166
+ const verts = buildVertices(mesh);
167
+ if (verts.byteLength > capacity) {
168
+ vertexBuffer?.destroy();
169
+ capacity = Math.max(verts.byteLength, 4096);
170
+ vertexBuffer = device.createBuffer({ size: capacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
171
+ }
172
+ if (verts.byteLength) device.queue.writeBuffer(vertexBuffer, 0, verts);
173
+
174
+ const u = new Float32Array(36);
175
+ u.set(viewProjection(cam), 0);
176
+ u.set([...norm(L.direction), 0], 16);
177
+ u.set([...L.sky, 0], 20);
178
+ u.set([...L.ground, 0], 24);
179
+ u.set([...cam.eye, 0], 28);
180
+ u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
181
+ device.queue.writeBuffer(uniforms, 0, u);
182
+
183
+ const encoder = device.createCommandEncoder();
184
+ const offscreen = readback
185
+ ? device.createTexture({ size: [px, px], format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC })
186
+ : null;
187
+ const target = (offscreen ?? context.getCurrentTexture()).createView();
188
+ const pass = encoder.beginRenderPass({
189
+ colorAttachments: [
190
+ {
191
+ view: color ? color.createView() : target,
192
+ resolveTarget: color ? target : undefined,
193
+ clearValue: { r: background[0], g: background[1], b: background[2], a: 1 },
194
+ loadOp: 'clear',
195
+ storeOp: color ? 'discard' : 'store',
196
+ },
197
+ ],
198
+ depthStencilAttachment: { view: depth.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'discard' },
199
+ });
200
+ if (verts.byteLength) {
201
+ pass.setPipeline(pipeline);
202
+ pass.setBindGroup(0, bind);
203
+ pass.setVertexBuffer(0, vertexBuffer);
204
+ pass.draw(verts.length / FLOATS);
205
+ }
206
+ pass.end();
207
+ if (!offscreen) {
208
+ device.queue.submit([encoder.finish()]);
209
+ return null;
210
+ }
211
+ const row = Math.ceil((px * 4) / 256) * 256;
212
+ const out = device.createBuffer({ size: row * px, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
213
+ encoder.copyTextureToBuffer({ texture: offscreen }, { buffer: out, bytesPerRow: row }, [px, px]);
214
+ device.queue.submit([encoder.finish()]);
215
+ return out.mapAsync(GPUMapMode.READ).then(() => {
216
+ const raw = new Uint8Array(out.getMappedRange());
217
+ const data = new Uint8ClampedArray(px * px * 4);
218
+ const swap = format.startsWith('bgra');
219
+ for (let y = 0; y < px; y++) {
220
+ for (let x = 0; x < px; x++) {
221
+ const i = y * row + x * 4;
222
+ const o = (y * px + x) * 4;
223
+ data[o] = raw[i + (swap ? 2 : 0)];
224
+ data[o + 1] = raw[i + 1];
225
+ data[o + 2] = raw[i + (swap ? 0 : 2)];
226
+ data[o + 3] = 255;
227
+ }
228
+ }
229
+ out.unmap();
230
+ out.destroy();
231
+ offscreen.destroy();
232
+ return { width: px, height: px, data };
233
+ });
234
+ };
235
+
236
+ /** Draws a frame onto the canvas. */
237
+ const render = (mesh, view, options) => {
238
+ draw(mesh, view, options);
239
+ };
240
+ /** Draws a frame into an offscreen texture and reads it back: Promise<{ width, height, data: RGBA }>. */
241
+ const pixels = (mesh, view, options) => draw(mesh, view, options, true);
242
+
243
+ const destroy = () => {
244
+ vertexBuffer?.destroy();
245
+ depth?.destroy();
246
+ color?.destroy();
247
+ uniforms.destroy();
248
+ device.destroy();
249
+ };
250
+
251
+ return { render, pixels, destroy, device, adapter };
252
+ };