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 +157 -2
- package/package.json +7 -2
- package/src/camera.js +75 -0
- package/src/check.js +79 -0
- package/src/edit.js +47 -0
- package/src/gpu.js +252 -0
- package/src/index.d.ts +198 -0
- package/src/index.js +16 -3
- package/src/mesh.js +127 -0
- package/src/order.js +94 -0
- package/src/pick.js +83 -0
- package/src/place.js +23 -0
- package/src/svg.js +119 -0
- package/src/vec.js +16 -0
- package/src/version.js +8 -0
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 {
|
|
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
|
-
|
|
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
|
+

|
|
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": "
|
|
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
|
+
};
|