3draft 26.11.2005 → 26.11.3001
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 +89 -0
- package/README.md +67 -201
- package/dist/csg.d.ts +10 -0
- package/dist/csg.js +239 -0
- package/dist/gpu.d.ts +5 -2
- package/dist/gpu.js +33 -13
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/material.d.ts +31 -0
- package/dist/material.js +17 -0
- package/dist/svg.d.ts +9 -0
- package/dist/svg.js +117 -4
- package/dist/types.d.ts +2 -0
- package/package.json +2 -1
package/APP.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# 3Draft viewer: how to use it
|
|
2
|
+
|
|
3
|
+
The viewer is the human side of 3Draft: a person and an AI look at, and edit, the same scene. The engine is described in the [README](README.md); this page is only about the app.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm run app # http://localhost:5173/app/ (repo only, not in the npm package)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It is one file, `app/index.html`, plus a dependency-free static server (`app/serve.js`, localhost only). The same page is published as a Claude artifact, where the scene lives in a shared database. When the MCP server serves it, everything the AI builds appears live.
|
|
10
|
+
|
|
11
|
+
## The view
|
|
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.
|
|
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
|
+
- **Drag** rotates, **double click** resets the view, the arrow keys rotate too.
|
|
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.
|
|
17
|
+
- **Footer:** yaw, pitch, fly speed, which code answers picking (`pick hexaw` = the WebAssembly kernel), object count, live link, and the face under the cross.
|
|
18
|
+
|
|
19
|
+
## Keys
|
|
20
|
+
|
|
21
|
+
| key | does |
|
|
22
|
+
| --- | --- |
|
|
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
|
+
| `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 |
|
|
26
|
+
| `/` | show only the object under the cross; again: the whole scene, and the camera flies back to where it was |
|
|
27
|
+
| `M` | place a prism on the surface under the cross, or shoot one into empty space |
|
|
28
|
+
| `V` | Spider bar: the first `V` remembers a surface point, the second joins the two with a thin prism. `V` over empty space forgets the first point |
|
|
29
|
+
| `X`, `Delete` | delete the object under the cross |
|
|
30
|
+
| `U`, `Backspace` | undo: placing, deleting, painting, booleans and moves, one step at a time |
|
|
31
|
+
| `P` | show / hide the material palette |
|
|
32
|
+
| `Shift` + press | paint the face under the cross with the selected material (palette must be on) |
|
|
33
|
+
| `Shift` + double click | paint the whole object |
|
|
34
|
+
| `O` | boolean (see below) |
|
|
35
|
+
| `H` | move gizmo (see below) |
|
|
36
|
+
| `E` | edges and points on / off (also the `Edges` button) |
|
|
37
|
+
| `G`, `L` | SVG / WebGPU, turn the light |
|
|
38
|
+
| `?` | show / hide these shortcuts under the view (hidden at the start; also the `Keys` button) |
|
|
39
|
+
|
|
40
|
+
## Moving an object: the gizmo (`H`)
|
|
41
|
+
|
|
42
|
+
There is one gizmo at a time, and `H` cycles through three states:
|
|
43
|
+
|
|
44
|
+
1. **First `H`**: the gizmo appears on the object under the cross, on the world axes (x red, y green, z blue).
|
|
45
|
+
2. **Second `H`**: it turns to the object's own axes. Objects carry no transform, so these come from the shape: y stays up, x and z follow the turn around y that fits the footprint best (an object that already lines up with the world axes looks the same in both states).
|
|
46
|
+
3. **Third `H`**: the gizmo goes away.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Boolean operations (`O`)
|
|
51
|
+
|
|
52
|
+
Press `O` over an object: it gets an orange box. Press `O` over another one (green box): a small question asks which operation to run.
|
|
53
|
+
|
|
54
|
+
| key | operation |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `1` | union |
|
|
57
|
+
| `2` | A − B (A is the orange one) |
|
|
58
|
+
| `3` | B − A |
|
|
59
|
+
| `4` | intersect |
|
|
60
|
+
| `5`, `Esc` | cancel |
|
|
61
|
+
|
|
62
|
+
The result is a new object named like `A-B` and it replaces both operands; `U` brings the two originals back and removes the result. `O` over empty space forgets a marked object.
|
|
63
|
+
|
|
64
|
+
The math is the engine's `boolean(a, b, op)` (README, part 1). Both objects must be **closed solids with outward faces**. An open shell, such as a wall without thickness, is refused with the reason in the dialog. With messy data (coincident faces) the result can come out open; the engine's `validate` says so.
|
|
65
|
+
|
|
66
|
+
## Materials (`P`)
|
|
67
|
+
|
|
68
|
+
Twelve starting materials: plaster, sand, wood, dark wood, concrete, slate, grass, brick, steel, gold, glass, lamp. Pick one, then `Shift`+press to paint a face or `Shift`+double click for the whole object.
|
|
69
|
+
|
|
70
|
+
For the selected material there are four sliders (`metal`, `rough`, `alpha`, `emit`) 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.
|
|
71
|
+
|
|
72
|
+
## Isolating, travelling
|
|
73
|
+
|
|
74
|
+
`/` 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).
|
|
75
|
+
|
|
76
|
+
## Phone
|
|
77
|
+
|
|
78
|
+
Four round buttons cover the main actions: travel, place, delete, and the view mode. Drag rotates; the cross aims at the middle of the screen.
|
|
79
|
+
|
|
80
|
+
## One object model and the database
|
|
81
|
+
|
|
82
|
+
Objects that come from outside (the database, the MCP server) and objects made in the app are the same thing: `{ id, name, mesh }`. Painting, deleting, isolating, booleans, moving and undo work on all of them, whoever made them. With the artifact database every change is written back, so the AI sees what the person did.
|
|
83
|
+
|
|
84
|
+
Collections:
|
|
85
|
+
|
|
86
|
+
- `objects`: one document per object, `{ name, mesh }`, at most 256 KiB each.
|
|
87
|
+
- `palette`: one document per material, `{ color, metal, rough, alpha, emit, order }`.
|
|
88
|
+
|
|
89
|
+
Undo is a per-session stack in the page; it does not reach back past a reload.
|
package/README.md
CHANGED
|
@@ -2,254 +2,120 @@
|
|
|
2
2
|
|
|
3
3
|
Minimalist AI first 3D engine.
|
|
4
4
|
|
|
5
|
-
> Status: **pre-alpha**. The
|
|
6
|
-
> pin down the direction; the engine itself is built step by step in this repo.
|
|
5
|
+
> Status: **pre-alpha**. The engine is built step by step in this repo.
|
|
7
6
|
|
|
8
7
|
```sh
|
|
9
8
|
npm install 3draft
|
|
10
9
|
```
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
The README has three parts: [1. the engine](#1-the-engine), [2. the viewer app](#2-the-viewer-app) and [3. how the engine and an LLM work together](#3-engine-and-llm).
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
import { writeFileSync } from 'node:fs';
|
|
16
|
-
import { box, merge, renderSheet, renderViews } from '3draft';
|
|
13
|
+
---
|
|
17
14
|
|
|
18
|
-
|
|
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
|
-
);
|
|
15
|
+
## 1. The engine
|
|
23
16
|
|
|
24
|
-
|
|
17
|
+
A model is plain data, so a human, a program and an LLM can all read and write it:
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
19
|
+
```js
|
|
20
|
+
const mesh = {
|
|
21
|
+
positions: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], // Vec3[], y is up
|
|
22
|
+
faces: [{ v: [0, 1, 2, 3], color: '#c8a27a', mat: 'wood' }], // CCW seen from outside, planar, convex
|
|
23
|
+
};
|
|
30
24
|
```
|
|
31
25
|
|
|
32
|
-
|
|
26
|
+
A scene is a list of objects `{ id, name, mesh }`. One mesh can hold many differently coloured faces (`examples/cessna.js` is one mesh with 9 colours).
|
|
33
27
|
|
|
34
|
-
|
|
28
|
+
### Rendering
|
|
35
29
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
`<polygon>` carries `data-face` and `data-depth`, every `<circle>` carries
|
|
39
|
-
`data-v`, so the output can be inspected as plain text.
|
|
30
|
+
```js
|
|
31
|
+
import { box, merge, renderSheet, renderView, orbit, createGpu } from '3draft';
|
|
40
32
|
|
|
41
|
-
|
|
33
|
+
const table = merge(
|
|
34
|
+
box({ size: [2, 0.15, 1.2], at: [0, 0.925, 0], color: '#c8a27a' }),
|
|
35
|
+
box({ size: [0.15, 0.85, 0.15], at: [-0.85, 0.425, -0.45], color: '#8a6a4a' }),
|
|
36
|
+
);
|
|
37
|
+
renderSheet(table); // SVG: front, side, top, persp on one sheet
|
|
38
|
+
renderView(table, 'persp', { occlude: true }); // one view, only what is seen
|
|
39
|
+
const gpu = await createGpu(canvas); // WebGPU, same camera, same pixels
|
|
40
|
+
gpu.render(table, orbit({ yaw: -0.6, pitch: 0.45 }), { size: 600 });
|
|
41
|
+
```
|
|
42
42
|
|
|
43
|
-
|
|
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`.
|
|
43
|
+

|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
- **SVG** is the deterministic preview (the same calls give the same text). Every `<polygon>` has `data-face` and `data-depth`, every `<circle>` has `data-v`. `occlude: true` paints into a small depth buffer first and sends only the faces and points that are seen (on the 5000-face house: 2793 -> about 680 polygons, 802 KB -> 175 KB, same picture).
|
|
46
|
+
- **WebGPU** draws the same scene through the same camera (`viewProjection` is tested to hit the same pixels as `project`), with one shader, one light, alpha-to-coverage transparency and instancing.
|
|
47
|
+
- Cameras: `front`, `side`, `top` (orthographic) and `persp`; `orbit({ yaw, pitch })` is a turntable; any `{ dir, up, ortho }` works.
|
|
48
|
+
- Faces are painter-sorted with plane tests; interpenetrating solids can show artifacts, hidden edges are not drawn, back-face culling assumes closed CCW meshes.
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
const hit = pick(mesh, cam, x, y); // { face, point, normal, distance, color } or null
|
|
50
|
+
### Materials
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
const svg = renderView(mesh, 'persp', { marks });
|
|
57
|
-
```
|
|
52
|
+
The palette is data: `{ name: { color, metal, rough, alpha, emit } }`. A face points at an entry with `face.mat`; its `color` stays as the fallback. Editing an entry changes every face made of it.
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
`
|
|
62
|
-
|
|
54
|
+
| field | meaning | drawn as |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `metal` 0..1 | paint ... metal | reflects sky and ground, tinted; strong highlight |
|
|
57
|
+
| `rough` 0..1 | mirror ... matte | widens the highlight, blurs the reflection |
|
|
58
|
+
| `alpha` 1..0 | solid ... invisible | alpha-to-coverage, one pass, no sorting (SVG: `fill-opacity`, and a see-through face hides nothing) |
|
|
59
|
+
| `emit` 0..1 | lit ... glowing | the colour shows unshaded |
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
view, `rotate(mesh, { yaw, pitch })` turns the mesh, and `{ fit: bounds(mesh) }`
|
|
66
|
-
keeps the framing fixed while it moves.
|
|
61
|
+
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.
|
|
67
62
|
|
|
68
|
-
###
|
|
63
|
+
### Surfaces, picking, editing
|
|
69
64
|
|
|
70
|
-
`
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Backspace takes the last one back. Over empty space Space shoots the prism out under the cursor to a random distance, animated, flying exactly along the camera axis (it lies along that axis like a bullet). The viewer starts empty (no table) and lays out only what you place. X deletes the prism under the cross. V is the Spider bar: the first V remembers the surface point under the cross, the second joins the two points with a thin prism (half the base of a shot prism, a darker brown); V over empty space forgets a pending first point. T flies the camera straight to the front of the object under the cross without turning it; T over nothing sets the speed to zero. W A S D R F fly: every press is a speed step along that axis (the speed grows faster than the level: 1, 2.7, 5.6, 9.9 ...) (w w w = speed 3 forward, s s s brings it back to a stop), R and F go up and down. While Shift is held the view turns towards the pointer, faster the farther the pointer is from the middle. 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. On a phone or tablet the stage fills the screen with no text: drag orbits, and four round translucent buttons do T, Space, D and the SVG/WebGPU switch (the cross starts in the middle and stays where you last touched).
|
|
65
|
+
- `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.
|
|
66
|
+
- `validate(mesh)` returns text notes: closed shell, winding, planar and convex faces. `extrudeFace`, `moveVertices`, `extrude`, `rotate`, `merge`, `bounds` shape meshes.
|
|
67
|
+
- `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. Face colours and materials are kept; cut faces take the colour of the cutting solid. The result is welded and T-junctions are repaired; on messy data (coincident faces) it can still come out open, so check `validate`.
|
|
68
|
+
- **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.
|
|
75
69
|
|
|
76
|
-
###
|
|
70
|
+
### TypeScript and `|>`
|
|
77
71
|
|
|
78
|
-
`
|
|
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):
|
|
72
|
+
The source is TypeScript (`src/*.ts`) with the F# style pipeline operator, `value |> fn` ([why](https://dev.to/pengeszikra/pipeline-operator-great-again-1cbn)). `npm run build` rewrites every `|>` into plain calls (`scripts/pipe.mjs`: `a |> f |> g(1)` becomes `g(1)(f(a))`) and lets `tsc` check and emit `dist/`, which is committed, so a clone runs without a build.
|
|
83
73
|
|
|
84
|
-
```
|
|
85
|
-
|
|
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
|
-
});
|
|
74
|
+
```ts
|
|
75
|
+
const dir = spec.dir |> norm |> times(dist) |> plus(center); // the right side is a function
|
|
93
76
|
```
|
|
94
77
|
|
|
95
|
-
`
|
|
96
|
-
point on the same pixel as `project()`. `gpu.pixels(...)` draws the same frame
|
|
97
|
-
offscreen and reads it back as RGBA.
|
|
78
|
+
Put arrow functions in parentheses (`x |> ((v) => v + 1)`) and wrap a pipeline in parentheses next to `+` and `-`.
|
|
98
79
|
|
|
99
|
-
|
|
80
|
+
---
|
|
100
81
|
|
|
101
|
-
|
|
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.
|
|
82
|
+
## 2. The viewer app
|
|
104
83
|
|
|
105
84
|
```sh
|
|
106
|
-
#
|
|
107
|
-
claude mcp add 3draft -- node /absolute/path/to/3Draft/mcp/server.js
|
|
85
|
+
npm run app # http://localhost:5173/app/ (repo only, not in the npm package)
|
|
108
86
|
```
|
|
109
87
|
|
|
110
|
-
|
|
111
|
-
{ "mcpServers": { "3draft": { "command": "node", "args": ["/absolute/path/to/3Draft/mcp/server.js"] } } }
|
|
112
|
-
```
|
|
88
|
+
A single `app/index.html` plus a dependency-free static server. Person and AI work on the same scene: objects loaded from outside and objects made in the viewer are one model, `{ id, name, mesh }`. WebGPU view with an SVG fallback, a cross cursor on the surface, flying, materials, booleans, a move gizmo, isolate, travel, undo. It is also published as a Claude artifact whose shared database is the scene.
|
|
113
89
|
|
|
114
|
-
|
|
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 |
|
|
90
|
+
**How to use it, all keys and the database layout: [APP.md](APP.md).**
|
|
134
91
|
|
|
135
|
-
|
|
136
|
-
Everything is deterministic, so the same calls give the same SVG.
|
|
92
|
+
---
|
|
137
93
|
|
|
138
|
-
|
|
94
|
+
## 3. Engine and LLM
|
|
139
95
|
|
|
140
|
-
|
|
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.
|
|
96
|
+
The engine is small enough to read whole (about 2000 lines) and everything it works with is text: meshes, palettes and scenes are JSON, previews are SVG, reports are sentences. That is the interface for an LLM:
|
|
146
97
|
|
|
147
|
-
|
|
98
|
+
- **Look.** The LLM renders a model to SVG and reads it (or the numbers in it), without a screenshot. `occlude` keeps the text small.
|
|
99
|
+
- **Check.** `validate` / `inspect` say in words what is wrong (open shell, inside-out faces, a non-planar face, which one), so the next edit is targeted.
|
|
100
|
+
- **Edit.** The same data goes back in: write documents, call tools, render again. Output is deterministic, so a change in the picture is a change in the model.
|
|
101
|
+
- **MCP server.** `mcp/server.js` (stdio, no dependencies) holds a scene of named objects and exposes it as tools: `add_box`, `add_extrusion`, `add_mesh`, `transform`, `boolean`, `recolor`, `remove`, `clear`, `get_mesh`, `extrude_face`, `move_vertices`, `list`, `inspect`, `render`, `pick`, `export`, `import`, `viewer`. It also serves the viewer and pushes every change to it, so a person watches the model grow. `claude mcp add 3draft -- node /absolute/path/to/3Draft/mcp/server.js`
|
|
102
|
+
- **Shared database.** In the artifact, the LLM and the person edit the same `objects` and `palette` collections, so either side can start from what the other made.
|
|
148
103
|
|
|
149
|
-
|
|
150
|
-
(see [Pipeline operator great again](https://dev.to/pengeszikra/pipeline-operator-great-again-1cbn)).
|
|
151
|
-
Neither `tsc` nor Node understands `|>` yet, so `npm run build` (`scripts/build.mjs`) first rewrites
|
|
152
|
-
every `|>` into plain calls (`scripts/pipe.mjs`, `a |> f |> g(1)` becomes `g(1)(f(a))`) and then lets the
|
|
153
|
-
TypeScript compiler type check and emit `dist/*.js` and `dist/*.d.ts`.
|
|
104
|
+
Y is up, the ground is y = 0, faces are counter-clockwise seen from outside.
|
|
154
105
|
|
|
155
|
-
|
|
156
|
-
const t1 = mesh.positions[b] |> minus(mesh.positions[a]) |> norm; // the right side is a function
|
|
157
|
-
const dir = spec.dir |> norm |> times(dist) |> plus(center); // curried helpers: plus, minus, times
|
|
158
|
-
```
|
|
106
|
+
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).
|
|
159
107
|
|
|
160
|
-
|
|
161
|
-
- `|>` binds like `>>>` while it is parsed, so wrap a pipeline in parentheses next to `+`, `-` and friends.
|
|
162
|
-
- `dist/` is committed: the viewer, the examples, the MCP server and the npm package use the built JavaScript, so a
|
|
163
|
-
clone runs without a build. `npm run build:check` fails when `dist/` is out of date, `npm test` builds first.
|
|
164
|
-
- The compiler is a dev dependency (`typescript`); `TYPESCRIPT=/path/to/typescript.js` points the build at another copy.
|
|
108
|
+
---
|
|
165
109
|
|
|
166
|
-
## Develop
|
|
110
|
+
## Develop, version, license
|
|
167
111
|
|
|
168
112
|
```bash
|
|
169
113
|
npm run build # src/*.ts -> dist/ (|> desugared, type checked)
|
|
170
|
-
npm test # builds, then runs every test
|
|
114
|
+
npm test # builds, then runs every test
|
|
171
115
|
npm run build:check # fails when dist/ is out of date
|
|
172
|
-
npm run
|
|
173
|
-
npm run app # the viewer, http://localhost:5173/app/
|
|
116
|
+
npm run app # the viewer
|
|
174
117
|
```
|
|
175
118
|
|
|
176
|
-
Edit `src/*.ts`, never `dist
|
|
177
|
-
|
|
178
|
-
## Viewer app (repo only, not part of the npm package)
|
|
179
|
-
|
|
180
|
-
```sh
|
|
181
|
-
npm run app # http://localhost:5173/app/
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
Drag to rotate the view, double click to reset. A target cross follows the pointer and lies on the surface under it.
|
|
185
|
-
|
|
186
|
-
| key | does |
|
|
187
|
-
| --- | --- |
|
|
188
|
-
| `w a s d r f` | fly: forward, left, back, right, up, down; every press is a speed step (an accelerating curve), the opposite key slows down |
|
|
189
|
-
| `Shift` | steer towards the pointer while flying |
|
|
190
|
-
| `Space` | place a prism on the surface (or shoot one into empty space) |
|
|
191
|
-
| `V` | Spider bar: two surface points joined by a thin brown prism; `V` over empty space forgets the first point |
|
|
192
|
-
| `T` | travel to the target (a straight flight); `T` over nothing stops and zeroes the speed |
|
|
193
|
-
| `X`, `Backspace` | delete the prism under the cross, undo |
|
|
194
|
-
| `H`, `G`, `L` | edges, SVG / WebGPU view, light |
|
|
195
|
-
|
|
196
|
-
On a phone the four round buttons do the same. The viewer picks with the HEXAW kernel (the footer shows `pick hexaw`). It is a
|
|
197
|
-
single `app/index.html` plus a dependency-free static server
|
|
198
|
-
(`app/serve.js`, localhost only, serves `app/`, `dist/` and `examples/`).
|
|
199
|
-
|
|
200
|
-
## Versioning
|
|
201
|
-
|
|
202
|
-
Date based, written `YY.MM.DD.NNN`. npm needs valid semver (three numbers, no
|
|
203
|
-
leading zeros), so the published form is `YY.M.DNNN` where the last number is
|
|
204
|
-
day * 1000 + build:
|
|
205
|
-
|
|
206
|
-
| readable | package version |
|
|
207
|
-
| -------------- | --------------- |
|
|
208
|
-
| `26.11.01.001` | `26.11.1001` |
|
|
209
|
-
| `26.11.01.002` | `26.11.1002` |
|
|
210
|
-
| `26.11.15.012` | `26.11.15012` |
|
|
211
|
-
|
|
212
|
-
`npm run bump` sets the next version for today's date in `package.json` and
|
|
213
|
-
`src/index.ts` (`node scripts/bump.js --dry` only prints it).
|
|
214
|
-
|
|
215
|
-
## Idea
|
|
216
|
-
|
|
217
|
-
- **One material, one palette texture.** Every face's UV points at a single
|
|
218
|
-
texel of a small palette texture, so a whole scene is one pipeline, one bind
|
|
219
|
-
group and instanced draw calls.
|
|
220
|
-
- **Thin WASM core.** Transforms, culling and animation live in a WASM module
|
|
221
|
-
(HEXAW and/or Rust) behind a small, documented ABI. JS only uploads buffers.
|
|
222
|
-
- **WebGPU first, WebGL2 fallback** behind a minimal `Renderer` interface.
|
|
223
|
-
- **AI first.** A deterministic SVG preview and structured `inspect` reports
|
|
224
|
-
(plus an MCP server) let an AI build and verify models from text.
|
|
225
|
-
|
|
226
|
-
## Roadmap
|
|
227
|
-
|
|
228
|
-
1. ABI description + reference JS kernel + spinning instanced cube
|
|
229
|
-
2. SVG/PNG preview renderer and MCP `render` / `inspect`
|
|
230
|
-
3. Text modelling language + palette/UV validator
|
|
231
|
-
4. HEXAW kernel next to the JS reference, with differential tests
|
|
232
|
-
5. Rust core only if the logic outgrows HEXAW
|
|
233
|
-
|
|
234
|
-
## License
|
|
119
|
+
Edit `src/*.ts`, never `dist/`. Versions are date based, `YY.MM.DD.NNN`, published as semver `YY.M.DNNN` (`26.11.01.002` is `26.11.1002`); `npm run bump` sets the next one.
|
|
235
120
|
|
|
236
121
|
MIT
|
|
237
|
-
|
|
238
|
-
## Examples
|
|
239
|
-
|
|
240
|
-
- `examples/table.js`: the table demo (`npm run example`).
|
|
241
|
-
- `examples/bridge.js`: a stone arch bridge of 50 named objects (`npm run bridge`). It writes `examples/bridge.scene.json`, a scene file you can load with the MCP `import` tool (`path: "examples/bridge.scene.json"`).
|
|
242
|
-
- `examples/cessna.js`: a small Cessna as ONE mesh with 9 face colours (single flat material, colour per face): lofted closed parts (fuselage, high wing, tail, struts, gear, spinner, propeller) merged into one mesh. `node examples/cessna.js` checks it and writes `examples/cessna.scene.json` (one object, loadable with the MCP `import` tool) and `examples/out/cessna.svg`.
|
|
243
|
-
|
|
244
|
-
## HEXAW kernel
|
|
245
|
-
|
|
246
|
-
The picking hot loop (ray against every triangle, Moller-Trumbore, the same arithmetic as `src/pick.ts`) is also written in
|
|
247
|
-
[HEXAW](https://github.com/Pengeszikra/hexaw), the byte-visible WebAssembly assembly dialect:
|
|
248
|
-
|
|
249
|
-
- `kernel/pick.hexaw` is the source (569 bytes of WebAssembly once assembled).
|
|
250
|
-
- `npm run kernel` assembles it with `hexaw-core.js` (found through `HEXAW_CORE`, a checkout next to this repo, or the `hexaw` package) and writes `src/kernel-bytes.ts`, the module as base64 (then `npm run build` carries it into `dist/`). The engine needs neither HEXAW nor a file fetch at runtime. `node scripts/build-kernel.mjs --check` fails when the generated file is out of date.
|
|
251
|
-
- `initKernel()` starts it; `kernel.pick(mesh, camera, x, y)` returns exactly what `pick()` returns. The mesh is copied into the kernel's linear memory once for as long as it is the same object.
|
|
252
|
-
- The viewer uses the kernel as soon as it has started (the footer shows `pick hexaw`), and plain `pick()` before that.
|
|
253
|
-
- Measured on 1200 prisms (7200 faces): 1.7 ms per pick in JavaScript, 0.14 ms with the kernel.
|
|
254
|
-
|
|
255
|
-
The kernel exports its linear memory (`EXPM "memory"`, needs hexaw with JS-compatible memory), so the mesh is written straight into it with `Float64Array` / `Int32Array` views and the distance is read back from address 0; the former `setf`/`seti`/`getf`/`grow`/`size` helper functions are gone.
|
package/dist/csg.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Mesh } from './types.js';
|
|
2
|
+
export type BooleanOp = 'union' | 'subtract' | 'intersect';
|
|
3
|
+
/**
|
|
4
|
+
* Boolean of two closed solids (outward CCW faces): 'union', 'subtract' (a minus b) or 'intersect'.
|
|
5
|
+
* Throws with a reason when an input is open or inside out.
|
|
6
|
+
*/
|
|
7
|
+
export declare const boolean: (a: Mesh, b: Mesh, op: BooleanOp) => Mesh;
|
|
8
|
+
export declare const union: (a: Mesh, b: Mesh) => Mesh;
|
|
9
|
+
export declare const subtract: (a: Mesh, b: Mesh) => Mesh;
|
|
10
|
+
export declare const intersect: (a: Mesh, b: Mesh) => Mesh;
|
package/dist/csg.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Boolean operations on closed solids: union, subtract (a - b) and intersect.
|
|
2
|
+
// A BSP tree per solid (after Evan Wallace's csg.js): polygons are cut by the planes of the other solid
|
|
3
|
+
// and the pieces inside or outside are kept, so every result face stays planar and convex, which is what
|
|
4
|
+
// the rest of the engine assumes. The result is welded and its T-junctions are closed, so it is a closed
|
|
5
|
+
// solid again and can go into the next boolean. Faces keep the color and material of the face they came from;
|
|
6
|
+
// a face cut into a solid by the other one takes the color of the cutting face.
|
|
7
|
+
import { sub, dot, cross, len } from './vec.js';
|
|
8
|
+
import { faceNormal, bounds } from './mesh.js';
|
|
9
|
+
import { validate } from './check.js';
|
|
10
|
+
let CSG_EPS = 1e-6; // set per operation, relative to the size of the two solids
|
|
11
|
+
const unit = (v) => {
|
|
12
|
+
const l = len(v) || 1;
|
|
13
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
14
|
+
};
|
|
15
|
+
const polysOf = (mesh) => mesh.faces.flatMap((f) => {
|
|
16
|
+
const pts = f.v.map((k) => mesh.positions[k]);
|
|
17
|
+
const n = unit(faceNormal(pts));
|
|
18
|
+
return len(faceNormal(pts)) < 1e-12 ? [] : [{ pts, n, w: dot(n, pts[0]), color: f.color, mat: f.mat }];
|
|
19
|
+
});
|
|
20
|
+
const flip = (p) => ({ ...p, pts: [...p.pts].reverse(), n: [-p.n[0], -p.n[1], -p.n[2]], w: -p.w });
|
|
21
|
+
/** Cuts `p` by the plane into the four buckets. */
|
|
22
|
+
const splitPoly = (plane, p, cf, cb, front, back) => {
|
|
23
|
+
const COPLANAR = 0;
|
|
24
|
+
const FRONT = 1;
|
|
25
|
+
const BACK = 2;
|
|
26
|
+
const SPANNING = 3;
|
|
27
|
+
let type = 0;
|
|
28
|
+
const types = p.pts.map((q) => {
|
|
29
|
+
const t = dot(plane.n, q) - plane.w;
|
|
30
|
+
const k = t < -CSG_EPS ? BACK : t > CSG_EPS ? FRONT : COPLANAR;
|
|
31
|
+
type |= k;
|
|
32
|
+
return k;
|
|
33
|
+
});
|
|
34
|
+
if (type === COPLANAR)
|
|
35
|
+
(dot(plane.n, p.n) > 0 ? cf : cb).push(p);
|
|
36
|
+
else if (type === FRONT)
|
|
37
|
+
front.push(p);
|
|
38
|
+
else if (type === BACK)
|
|
39
|
+
back.push(p);
|
|
40
|
+
else if (type === SPANNING) {
|
|
41
|
+
const f = [];
|
|
42
|
+
const b = [];
|
|
43
|
+
for (let i = 0; i < p.pts.length; i++) {
|
|
44
|
+
const j = (i + 1) % p.pts.length;
|
|
45
|
+
const ti = types[i];
|
|
46
|
+
const tj = types[j];
|
|
47
|
+
const vi = p.pts[i];
|
|
48
|
+
const vj = p.pts[j];
|
|
49
|
+
if (ti !== BACK)
|
|
50
|
+
f.push(vi);
|
|
51
|
+
if (ti !== FRONT)
|
|
52
|
+
b.push(vi);
|
|
53
|
+
if ((ti | tj) === SPANNING) {
|
|
54
|
+
const t = (plane.w - dot(plane.n, vi)) / dot(plane.n, sub(vj, vi));
|
|
55
|
+
const v = [vi[0] + (vj[0] - vi[0]) * t, vi[1] + (vj[1] - vi[1]) * t, vi[2] + (vj[2] - vi[2]) * t];
|
|
56
|
+
f.push(v);
|
|
57
|
+
b.push(v);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (f.length >= 3)
|
|
61
|
+
front.push({ ...p, pts: f });
|
|
62
|
+
if (b.length >= 3)
|
|
63
|
+
back.push({ ...p, pts: b });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const build = (node, polys) => {
|
|
67
|
+
if (!polys.length)
|
|
68
|
+
return;
|
|
69
|
+
if (!node.plane)
|
|
70
|
+
node.plane = { n: polys[0].n, w: polys[0].w };
|
|
71
|
+
const front = [];
|
|
72
|
+
const back = [];
|
|
73
|
+
for (const p of polys)
|
|
74
|
+
splitPoly(node.plane, p, node.polys, node.polys, front, back);
|
|
75
|
+
if (front.length)
|
|
76
|
+
build((node.front ??= { plane: null, front: null, back: null, polys: [] }), front);
|
|
77
|
+
if (back.length)
|
|
78
|
+
build((node.back ??= { plane: null, front: null, back: null, polys: [] }), back);
|
|
79
|
+
};
|
|
80
|
+
const tree = (polys) => {
|
|
81
|
+
const node = { plane: null, front: null, back: null, polys: [] };
|
|
82
|
+
build(node, polys);
|
|
83
|
+
return node;
|
|
84
|
+
};
|
|
85
|
+
const invert = (node) => {
|
|
86
|
+
node.polys = node.polys.map(flip);
|
|
87
|
+
if (node.plane)
|
|
88
|
+
node.plane = { n: [-node.plane.n[0], -node.plane.n[1], -node.plane.n[2]], w: -node.plane.w };
|
|
89
|
+
invert0(node);
|
|
90
|
+
};
|
|
91
|
+
const invert0 = (node) => {
|
|
92
|
+
if (node.front)
|
|
93
|
+
invert(node.front);
|
|
94
|
+
if (node.back)
|
|
95
|
+
invert(node.back);
|
|
96
|
+
[node.front, node.back] = [node.back, node.front];
|
|
97
|
+
};
|
|
98
|
+
/** The parts of `polys` that are outside the solid of `node`. */
|
|
99
|
+
const clipPolys = (node, polys) => {
|
|
100
|
+
if (!node.plane)
|
|
101
|
+
return [...polys];
|
|
102
|
+
let front = [];
|
|
103
|
+
let back = [];
|
|
104
|
+
for (const p of polys)
|
|
105
|
+
splitPoly(node.plane, p, front, back, front, back);
|
|
106
|
+
front = node.front ? clipPolys(node.front, front) : front;
|
|
107
|
+
back = node.back ? clipPolys(node.back, back) : [];
|
|
108
|
+
return [...front, ...back];
|
|
109
|
+
};
|
|
110
|
+
const clipTo = (node, other) => {
|
|
111
|
+
node.polys = clipPolys(other, node.polys);
|
|
112
|
+
if (node.front)
|
|
113
|
+
clipTo(node.front, other);
|
|
114
|
+
if (node.back)
|
|
115
|
+
clipTo(node.back, other);
|
|
116
|
+
};
|
|
117
|
+
const all = (node) => [...node.polys, ...(node.front ? all(node.front) : []), ...(node.back ? all(node.back) : [])];
|
|
118
|
+
/** Polygons to a welded mesh: shared vertices, no sliver faces, T-junctions closed. */
|
|
119
|
+
const toMesh = (polys, scale) => {
|
|
120
|
+
const cell = scale * 1e-5;
|
|
121
|
+
const ids = new Map();
|
|
122
|
+
const positions = [];
|
|
123
|
+
const weld = (p) => {
|
|
124
|
+
// look in the neighbouring cells too, so a point on a cell border still finds its twin
|
|
125
|
+
const base = p.map((c) => Math.round(c / cell));
|
|
126
|
+
for (let dx = -1; dx <= 1; dx++)
|
|
127
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
128
|
+
for (let dz = -1; dz <= 1; dz++) {
|
|
129
|
+
const hit = ids.get(`${base[0] + dx},${base[1] + dy},${base[2] + dz}`);
|
|
130
|
+
if (hit !== undefined && len(sub(positions[hit], p)) <= cell * 1.5)
|
|
131
|
+
return hit;
|
|
132
|
+
}
|
|
133
|
+
ids.set(base.join(','), positions.length);
|
|
134
|
+
positions.push(p);
|
|
135
|
+
return positions.length - 1;
|
|
136
|
+
};
|
|
137
|
+
let faces = polys.flatMap((p) => {
|
|
138
|
+
const v = [];
|
|
139
|
+
for (const q of p.pts) {
|
|
140
|
+
const k = weld(q);
|
|
141
|
+
if (v[v.length - 1] !== k)
|
|
142
|
+
v.push(k);
|
|
143
|
+
}
|
|
144
|
+
if (v.length > 1 && v[0] === v[v.length - 1])
|
|
145
|
+
v.pop();
|
|
146
|
+
if (v.length < 3)
|
|
147
|
+
return [];
|
|
148
|
+
const n = faceNormal(v.map((k) => positions[k]));
|
|
149
|
+
return len(n) < 1e-9 * scale * scale ? [] : [{ v, color: p.color, mat: p.mat }];
|
|
150
|
+
});
|
|
151
|
+
// T-junctions: a vertex lying on the edge of a neighbouring face gets inserted into that edge
|
|
152
|
+
const used = new Set(faces.flatMap((f) => f.v));
|
|
153
|
+
const verts = [...used];
|
|
154
|
+
if (verts.length * faces.length < 6e7) {
|
|
155
|
+
const tol = scale * 1e-5 * 3;
|
|
156
|
+
faces = faces.map((f) => {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let i = 0; i < f.v.length; i++) {
|
|
159
|
+
const a = f.v[i];
|
|
160
|
+
const b = f.v[(i + 1) % f.v.length];
|
|
161
|
+
out.push(a);
|
|
162
|
+
const pa = positions[a];
|
|
163
|
+
const pb = positions[b];
|
|
164
|
+
const d = sub(pb, pa);
|
|
165
|
+
const dl = len(d);
|
|
166
|
+
if (dl < tol * 4)
|
|
167
|
+
continue;
|
|
168
|
+
const on = [];
|
|
169
|
+
for (const k of verts) {
|
|
170
|
+
if (k === a || k === b)
|
|
171
|
+
continue;
|
|
172
|
+
const pk = positions[k];
|
|
173
|
+
const t = dot(sub(pk, pa), d) / (dl * dl);
|
|
174
|
+
if (t <= 0 || t >= 1)
|
|
175
|
+
continue;
|
|
176
|
+
if (len(cross(sub(pk, pa), d)) / dl <= tol)
|
|
177
|
+
on.push({ t, k });
|
|
178
|
+
}
|
|
179
|
+
on.sort((x, y) => x.t - y.t);
|
|
180
|
+
for (const { k } of on)
|
|
181
|
+
out.push(k);
|
|
182
|
+
}
|
|
183
|
+
return { ...f, v: out };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { positions, faces };
|
|
187
|
+
};
|
|
188
|
+
const closedSolid = (name, m) => {
|
|
189
|
+
const v = validate(m);
|
|
190
|
+
if (!v.closed)
|
|
191
|
+
throw new Error(`boolean needs closed solids: ${name} has ${v.openEdges} open edge(s)`);
|
|
192
|
+
if (v.inside_out)
|
|
193
|
+
throw new Error(`boolean needs outward faces: ${name} is inside out`);
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Boolean of two closed solids (outward CCW faces): 'union', 'subtract' (a minus b) or 'intersect'.
|
|
197
|
+
* Throws with a reason when an input is open or inside out.
|
|
198
|
+
*/
|
|
199
|
+
export const boolean = (a, b, op) => {
|
|
200
|
+
closedSolid('a', a);
|
|
201
|
+
closedSolid('b', b);
|
|
202
|
+
const sa = bounds(a);
|
|
203
|
+
const sb = bounds(b);
|
|
204
|
+
const scale = Math.max(sa.radius, sb.radius, 1e-9) * 2 + len(sub(sa.center, sb.center));
|
|
205
|
+
CSG_EPS = scale * 1e-9 * 1000;
|
|
206
|
+
const A = tree(polysOf(a));
|
|
207
|
+
const B = tree(polysOf(b));
|
|
208
|
+
if (op === 'union') {
|
|
209
|
+
clipTo(A, B);
|
|
210
|
+
clipTo(B, A);
|
|
211
|
+
invert(B);
|
|
212
|
+
clipTo(B, A);
|
|
213
|
+
invert(B);
|
|
214
|
+
build(A, all(B));
|
|
215
|
+
}
|
|
216
|
+
else if (op === 'subtract') {
|
|
217
|
+
invert(A);
|
|
218
|
+
clipTo(A, B);
|
|
219
|
+
clipTo(B, A);
|
|
220
|
+
invert(B);
|
|
221
|
+
clipTo(B, A);
|
|
222
|
+
invert(B);
|
|
223
|
+
build(A, all(B));
|
|
224
|
+
invert(A);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
invert(A);
|
|
228
|
+
clipTo(B, A);
|
|
229
|
+
invert(B);
|
|
230
|
+
clipTo(A, B);
|
|
231
|
+
clipTo(B, A);
|
|
232
|
+
build(A, all(B));
|
|
233
|
+
invert(A);
|
|
234
|
+
}
|
|
235
|
+
return toMesh(all(A), scale);
|
|
236
|
+
};
|
|
237
|
+
export const union = (a, b) => boolean(a, b, 'union');
|
|
238
|
+
export const subtract = (a, b) => boolean(a, b, 'subtract');
|
|
239
|
+
export const intersect = (a, b) => boolean(a, b, 'intersect');
|
package/dist/gpu.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from './material.js';
|
|
1
2
|
import type { Axes, Camera, Fit, Mesh, Vec3, ViewName, ViewSpec } from './types.js';
|
|
2
3
|
type Handle = any;
|
|
3
4
|
export interface GpuLight {
|
|
@@ -21,6 +22,8 @@ export interface GpuRenderOptions {
|
|
|
21
22
|
background?: Vec3;
|
|
22
23
|
light?: GpuLight;
|
|
23
24
|
material?: GpuMaterial;
|
|
25
|
+
/** Named materials the faces point at (`face.mat`); pass a new object when an entry changes. */
|
|
26
|
+
palette?: Palette;
|
|
24
27
|
dpr?: number;
|
|
25
28
|
/** Many copies of one prototype mesh (modelled around the origin), drawn after `mesh`. */
|
|
26
29
|
instances?: {
|
|
@@ -52,8 +55,8 @@ export interface Gpu {
|
|
|
52
55
|
* like camera.project(): the same pixels, plus a depth between near and far.
|
|
53
56
|
*/
|
|
54
57
|
export declare const viewProjection: (cam: Camera, near?: number, far?: number) => Float32Array;
|
|
55
|
-
/** Triangle list for the GPU: flat face normals
|
|
56
|
-
export declare const buildVertices: (mesh: Mesh) => Float32Array;
|
|
58
|
+
/** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
|
|
59
|
+
export declare const buildVertices: (mesh: Mesh, palette?: Palette) => Float32Array;
|
|
57
60
|
/**
|
|
58
61
|
* Per-instance data for drawInstances: one entry { at, axes: [ex, ey, ez], color } per copy of a
|
|
59
62
|
* prototype mesh (modelled around the origin). Float32Array, 15 floats per instance.
|
package/dist/gpu.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { dot, norm } from './vec.js';
|
|
6
6
|
import { 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
10
|
/**
|
|
10
11
|
* Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
|
|
11
12
|
* like camera.project(): the same pixels, plus a depth between near and far.
|
|
@@ -28,8 +29,8 @@ const rgb = (color) => {
|
|
|
28
29
|
return [0.78, 0.78, 0.78];
|
|
29
30
|
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
|
|
30
31
|
};
|
|
31
|
-
/** Triangle list for the GPU: flat face normals
|
|
32
|
-
export const buildVertices = (mesh) => {
|
|
32
|
+
/** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
|
|
33
|
+
export const buildVertices = (mesh, palette) => {
|
|
33
34
|
let tris = 0;
|
|
34
35
|
for (const f of mesh.faces)
|
|
35
36
|
tris += Math.max(0, f.v.length - 2);
|
|
@@ -38,10 +39,12 @@ export const buildVertices = (mesh) => {
|
|
|
38
39
|
for (const face of mesh.faces) {
|
|
39
40
|
const pts = face.v.map((k) => mesh.positions[k]);
|
|
40
41
|
const n = norm(faceNormal(pts));
|
|
41
|
-
const
|
|
42
|
+
const s = surfaceOf(face, palette);
|
|
43
|
+
const c = rgb(s.color);
|
|
44
|
+
const mat = [s.metal, s.rough, s.alpha, s.emit];
|
|
42
45
|
for (let j = 1; j < pts.length - 1; j++) {
|
|
43
46
|
for (const p of [pts[0], pts[j], pts[j + 1]]) {
|
|
44
|
-
out.set([...p, ...n, ...c], o);
|
|
47
|
+
out.set([...p, ...n, ...c, ...mat], o);
|
|
45
48
|
o += FLOATS;
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -71,12 +74,13 @@ struct U {
|
|
|
71
74
|
};
|
|
72
75
|
@group(0) @binding(0) var<uniform> u : U;
|
|
73
76
|
|
|
74
|
-
struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32> };
|
|
77
|
+
struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32> };
|
|
75
78
|
struct VOut {
|
|
76
79
|
@builtin(position) p : vec4<f32>,
|
|
77
80
|
@location(0) n : vec3<f32>,
|
|
78
81
|
@location(1) c : vec3<f32>,
|
|
79
82
|
@location(2) w : vec3<f32>,
|
|
83
|
+
@location(3) m : vec4<f32>, // metal, rough, alpha, emit
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
@vertex fn vs(i : VIn) -> VOut {
|
|
@@ -85,6 +89,7 @@ struct VOut {
|
|
|
85
89
|
o.n = i.nrm;
|
|
86
90
|
o.c = i.col;
|
|
87
91
|
o.w = i.pos;
|
|
92
|
+
o.m = i.mat;
|
|
88
93
|
return o;
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -103,6 +108,7 @@ struct IIn {
|
|
|
103
108
|
o.n = normalize(i.ex) * i.nrm.x + normalize(i.ey) * i.nrm.y + normalize(i.ez) * i.nrm.z;
|
|
104
109
|
o.c = i.col;
|
|
105
110
|
o.w = w;
|
|
111
|
+
o.m = vec4<f32>(0.0, 0.7, 1.0, 0.0);
|
|
106
112
|
return o;
|
|
107
113
|
}
|
|
108
114
|
|
|
@@ -111,12 +117,22 @@ struct IIn {
|
|
|
111
117
|
if (!front) { n = -n; }
|
|
112
118
|
let l = normalize(u.light.xyz);
|
|
113
119
|
let v = normalize(u.eye.xyz - i.w);
|
|
120
|
+
let metal = i.m.x;
|
|
121
|
+
let rough = i.m.y;
|
|
114
122
|
let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
|
|
115
123
|
let diff = max(dot(n, l), 0.0) * u.mat.y;
|
|
116
124
|
let h = normalize(l + v);
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
|
|
125
|
+
// roughness widens the highlight; metal makes it strong and tinted by the color
|
|
126
|
+
let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
|
|
127
|
+
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));
|
|
128
|
+
// metal mirrors the sky above and the ground below, blurred by roughness
|
|
129
|
+
let r = reflect(-v, n);
|
|
130
|
+
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));
|
|
131
|
+
var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
|
|
132
|
+
+ i.c * env * metal * (1.0 - 0.6 * rough)
|
|
133
|
+
+ vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
|
|
134
|
+
lit = mix(lit, i.c * 1.15, i.m.w);
|
|
135
|
+
return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
|
|
120
136
|
}
|
|
121
137
|
`;
|
|
122
138
|
/**
|
|
@@ -147,6 +163,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
147
163
|
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
|
148
164
|
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
|
149
165
|
{ shaderLocation: 2, offset: 24, format: 'float32x3' },
|
|
166
|
+
{ shaderLocation: 8, offset: 36, format: 'float32x4' },
|
|
150
167
|
],
|
|
151
168
|
};
|
|
152
169
|
const instanceLayout = {
|
|
@@ -160,7 +177,8 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
160
177
|
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
|
|
161
178
|
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
162
179
|
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
|
|
163
|
-
|
|
180
|
+
// the fragment alpha becomes coverage: a half transparent face covers half of the samples (one pass, no sorting)
|
|
181
|
+
multisample: sampleCount > 1 ? { count: sampleCount, alphaToCoverageEnabled: true } : { count: sampleCount },
|
|
164
182
|
});
|
|
165
183
|
const pipeline = make('vs', [meshLayout]);
|
|
166
184
|
// the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
|
|
@@ -170,6 +188,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
170
188
|
const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
171
189
|
// Geometry that did not change since the last frame is not rebuilt or uploaded again.
|
|
172
190
|
let uploaded = null; // the mesh now in vertexBuffer
|
|
191
|
+
let uploadedPalette; // and the palette it was built with
|
|
173
192
|
let vertexCount = 0;
|
|
174
193
|
let protoMesh = null;
|
|
175
194
|
let protoBuffer = null;
|
|
@@ -194,13 +213,13 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
194
213
|
depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
|
|
195
214
|
color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
|
|
196
215
|
};
|
|
197
|
-
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, dpr = globalThis.devicePixelRatio || 1, instances } = {}, readback = false) => {
|
|
216
|
+
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances } = {}, readback = false) => {
|
|
198
217
|
const L = { ...defaultLight, ...light };
|
|
199
218
|
const M = { ...defaultMaterial, ...material };
|
|
200
219
|
const cam = makeCamera(view, mesh, size, fit);
|
|
201
220
|
targets(Math.max(1, Math.round(size * dpr)));
|
|
202
|
-
if (mesh !== uploaded) {
|
|
203
|
-
const verts = buildVertices(mesh);
|
|
221
|
+
if (mesh !== uploaded || palette !== uploadedPalette) {
|
|
222
|
+
const verts = buildVertices(mesh, palette);
|
|
204
223
|
if (verts.byteLength > capacity) {
|
|
205
224
|
vertexBuffer?.destroy();
|
|
206
225
|
capacity = Math.max(verts.byteLength, 4096);
|
|
@@ -210,6 +229,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
210
229
|
device.queue.writeBuffer(vertexBuffer, 0, verts);
|
|
211
230
|
vertexCount = verts.length / FLOATS;
|
|
212
231
|
uploaded = mesh;
|
|
232
|
+
uploadedPalette = palette;
|
|
213
233
|
}
|
|
214
234
|
if (instances?.mesh && instances.mesh !== protoMesh) {
|
|
215
235
|
const v = buildVertices(instances.mesh);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export declare const name: "3draft";
|
|
2
|
-
export declare const version = "26.11.
|
|
2
|
+
export declare const version = "26.11.3001";
|
|
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';
|
|
6
6
|
export { validate } from './check.js';
|
|
7
|
+
export { boolean, union, subtract, intersect } from './csg.js';
|
|
8
|
+
export type { BooleanOp } from './csg.js';
|
|
7
9
|
export type { Validation } from './check.js';
|
|
8
10
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
9
11
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
@@ -16,6 +18,8 @@ export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight,
|
|
|
16
18
|
export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
|
|
17
19
|
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
18
20
|
export type { RenderOptions } from './svg.js';
|
|
21
|
+
export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
|
|
22
|
+
export type { Material, Palette, Surface } from './material.js';
|
|
19
23
|
export { displayVersion } from './version.js';
|
|
20
24
|
export interface Info {
|
|
21
25
|
name: string;
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
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.
|
|
7
|
+
export const version = '26.11.3001';
|
|
8
8
|
export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
|
|
9
9
|
export { validate } from './check.js';
|
|
10
|
+
export { boolean, union, subtract, intersect } from './csg.js';
|
|
10
11
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
11
12
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
12
13
|
export { pick, crosshair } from './pick.js';
|
|
@@ -14,6 +15,7 @@ export { standOn } from './place.js';
|
|
|
14
15
|
export { paintOrder } from './order.js';
|
|
15
16
|
export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
|
|
16
17
|
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
18
|
+
export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
|
|
17
19
|
export { displayVersion } from './version.js';
|
|
18
20
|
/** Short, machine-readable description of the current state of the package. */
|
|
19
21
|
export const info = () => ({
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Face } from './types.js';
|
|
2
|
+
export interface Material {
|
|
3
|
+
/** #rrggbb. */
|
|
4
|
+
color: string;
|
|
5
|
+
/** 0 = paint, 1 = metal (reflects the sky and the ground, tinted by the color). */
|
|
6
|
+
metal?: number;
|
|
7
|
+
/** 0 = mirror-like, 1 = matte; widens the highlight. */
|
|
8
|
+
rough?: number;
|
|
9
|
+
/** 1 = solid, 0 = invisible. Drawn with alpha-to-coverage: one pass, no sorting. */
|
|
10
|
+
alpha?: number;
|
|
11
|
+
/** 0..1: how much the color glows on its own, unlit. */
|
|
12
|
+
emit?: number;
|
|
13
|
+
}
|
|
14
|
+
export type Palette = Record<string, Material>;
|
|
15
|
+
/** What a face is made of, all numbers filled in. */
|
|
16
|
+
export interface Surface {
|
|
17
|
+
color: string;
|
|
18
|
+
metal: number;
|
|
19
|
+
rough: number;
|
|
20
|
+
alpha: number;
|
|
21
|
+
emit: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const FALLBACK_COLOR = "#c8c8c8";
|
|
24
|
+
export declare const DEFAULTS: {
|
|
25
|
+
readonly metal: 0;
|
|
26
|
+
readonly rough: 0.7;
|
|
27
|
+
readonly alpha: 1;
|
|
28
|
+
readonly emit: 0;
|
|
29
|
+
};
|
|
30
|
+
/** The surface of a face: its palette entry when it names one, else just its color with default properties. */
|
|
31
|
+
export declare const surfaceOf: (face: Pick<Face, "color" | "mat">, palette?: Palette) => Surface;
|
package/dist/material.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Materials are data. A palette is a plain object { name: Material }; a face points at an entry by name
|
|
2
|
+
// (`face.mat`) and keeps its own `color` as a fallback, so a mesh still looks right without the palette.
|
|
3
|
+
// One shader draws all of them; the numbers below are the only knobs.
|
|
4
|
+
const clamp01 = (n, fallback) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : fallback);
|
|
5
|
+
export const FALLBACK_COLOR = '#c8c8c8';
|
|
6
|
+
export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
|
|
7
|
+
/** The surface of a face: its palette entry when it names one, else just its color with default properties. */
|
|
8
|
+
export const surfaceOf = (face, palette) => {
|
|
9
|
+
const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
|
|
10
|
+
return {
|
|
11
|
+
color: m?.color ?? face.color ?? FALLBACK_COLOR,
|
|
12
|
+
metal: clamp01(m?.metal, DEFAULTS.metal),
|
|
13
|
+
rough: clamp01(m?.rough, DEFAULTS.rough),
|
|
14
|
+
alpha: clamp01(m?.alpha, DEFAULTS.alpha),
|
|
15
|
+
emit: clamp01(m?.emit, DEFAULTS.emit),
|
|
16
|
+
};
|
|
17
|
+
};
|
package/dist/svg.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from './material.js';
|
|
1
2
|
import type { Fit, Mark, Mesh, ViewName, ViewSpec } from './types.js';
|
|
2
3
|
export interface RenderOptions {
|
|
3
4
|
/** Panel size in px (default 400). */
|
|
@@ -6,6 +7,14 @@ export interface RenderOptions {
|
|
|
6
7
|
cull?: boolean;
|
|
7
8
|
/** Draw the vertices as circles (default true). */
|
|
8
9
|
points?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Send only what can be seen (default false). Faces are first painted into a small id buffer, far to
|
|
12
|
+
* near; a face none of whose pixels survives is covered by nearer ones and is left out, and so is a
|
|
13
|
+
* point no visible face reaches. The picture stays the same, the SVG gets much smaller.
|
|
14
|
+
*/
|
|
15
|
+
occlude?: boolean;
|
|
16
|
+
/** Named materials (`face.mat`). The SVG shows their color and alpha; metal, rough and emit are for the GPU view. */
|
|
17
|
+
palette?: Palette;
|
|
9
18
|
stroke?: string;
|
|
10
19
|
pointColor?: string;
|
|
11
20
|
background?: string;
|
package/dist/svg.js
CHANGED
|
@@ -5,6 +5,74 @@ import { dot, norm, minus } from './vec.js';
|
|
|
5
5
|
import { faceNormal } from './mesh.js';
|
|
6
6
|
import { paintOrder } from './order.js';
|
|
7
7
|
import { VIEW_ORDER, makeCamera, project } from './camera.js';
|
|
8
|
+
import { surfaceOf } from './material.js';
|
|
9
|
+
/**
|
|
10
|
+
* Depth-tested scanline fill of one polygon into an id buffer. `w` grows towards the eye (1/depth in a
|
|
11
|
+
* perspective view, -depth in an orthographic one) and is linear across a planar face on the screen.
|
|
12
|
+
* A pixel that ties with what is there (coplanar faces) keeps both ids alive for the painter's order.
|
|
13
|
+
*/
|
|
14
|
+
const fill = (ids, zb, size, pts, w, id, alive, solid = true) => {
|
|
15
|
+
const test = (x, y, v) => {
|
|
16
|
+
if (x < 0 || y < 0 || x >= size || y >= size)
|
|
17
|
+
return;
|
|
18
|
+
const at = y * size + x;
|
|
19
|
+
const old = zb[at];
|
|
20
|
+
const tie = Math.abs(v - old) <= 1e-5 * Math.abs(old);
|
|
21
|
+
if (!solid) {
|
|
22
|
+
// a see-through face is visible where nothing nearer hides it, and hides nothing itself
|
|
23
|
+
if (v > old || tie)
|
|
24
|
+
alive.add(id);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (v > old || tie) {
|
|
28
|
+
if (tie && ids[at] >= 0)
|
|
29
|
+
alive.add(ids[at]);
|
|
30
|
+
zb[at] = Math.max(v, old);
|
|
31
|
+
ids[at] = id;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
// the outline too: a face thinner than a pixel still shows as a line
|
|
35
|
+
for (let a = 0; a < pts.length; a++) {
|
|
36
|
+
const b = (a + 1) % pts.length;
|
|
37
|
+
const n = Math.ceil(Math.max(Math.abs(pts[b].x - pts[a].x), Math.abs(pts[b].y - pts[a].y)));
|
|
38
|
+
for (let i = 0; i <= n; i++) {
|
|
39
|
+
const t = n ? i / n : 0;
|
|
40
|
+
test(Math.floor(pts[a].x + t * (pts[b].x - pts[a].x)), Math.floor(pts[a].y + t * (pts[b].y - pts[a].y)), w[a] + t * (w[b] - w[a]));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let lo = Infinity;
|
|
44
|
+
let hi = -Infinity;
|
|
45
|
+
for (const p of pts)
|
|
46
|
+
(lo = Math.min(lo, p.y), (hi = Math.max(hi, p.y)));
|
|
47
|
+
const y0 = Math.max(0, Math.ceil(lo - 0.5));
|
|
48
|
+
const y1 = Math.min(size - 1, Math.floor(hi - 0.5));
|
|
49
|
+
const cross = [];
|
|
50
|
+
for (let y = y0; y <= y1; y++) {
|
|
51
|
+
const yc = y + 0.5;
|
|
52
|
+
cross.length = 0;
|
|
53
|
+
for (let a = 0; a < pts.length; a++) {
|
|
54
|
+
const b = (a + 1) % pts.length;
|
|
55
|
+
const p = pts[a];
|
|
56
|
+
const q = pts[b];
|
|
57
|
+
if ((p.y <= yc && q.y > yc) || (q.y <= yc && p.y > yc)) {
|
|
58
|
+
const t = (yc - p.y) / (q.y - p.y);
|
|
59
|
+
cross.push({ x: p.x + t * (q.x - p.x), w: w[a] + t * (w[b] - w[a]) });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
cross.sort((a, b) => a.x - b.x);
|
|
63
|
+
for (let k = 0; k + 1 < cross.length; k += 2) {
|
|
64
|
+
const l = cross[k];
|
|
65
|
+
const r = cross[k + 1];
|
|
66
|
+
const x0 = Math.max(0, Math.ceil(l.x - 0.5));
|
|
67
|
+
const x1 = Math.min(size - 1, Math.floor(r.x - 0.5));
|
|
68
|
+
const span = r.x - l.x || 1;
|
|
69
|
+
for (let x = x0; x <= x1; x++) {
|
|
70
|
+
const v = l.w + ((x + 0.5 - l.x) / span) * (r.w - l.w);
|
|
71
|
+
test(x, y, v);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
8
76
|
const defaults = {
|
|
9
77
|
size: 400,
|
|
10
78
|
cull: true,
|
|
@@ -58,6 +126,7 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
|
|
|
58
126
|
}
|
|
59
127
|
if (!seen(screen))
|
|
60
128
|
return; // outside the panel
|
|
129
|
+
const surface = surfaceOf(face, o.palette);
|
|
61
130
|
items.push({
|
|
62
131
|
i,
|
|
63
132
|
vs: face.v,
|
|
@@ -66,22 +135,66 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
|
|
|
66
135
|
n: norm(normal),
|
|
67
136
|
p: world[0],
|
|
68
137
|
eye: facing ? 1 : -1,
|
|
69
|
-
color:
|
|
138
|
+
color: surface.color,
|
|
139
|
+
alpha: surface.alpha,
|
|
70
140
|
depth: screen.reduce((s, p) => s + p.depth, 0) / screen.length,
|
|
71
141
|
pts: screen.map((p) => `${num(p.x)},${num(p.y)}`).join(' '),
|
|
72
142
|
});
|
|
73
143
|
});
|
|
74
|
-
|
|
144
|
+
// Occlusion: a depth buffer finds the faces that own a pixel; only those go on to the painter's order.
|
|
145
|
+
let idBuf = null;
|
|
146
|
+
let visible = items;
|
|
147
|
+
if (o.occlude) {
|
|
148
|
+
const buf = new Int32Array(o.size * o.size).fill(-1);
|
|
149
|
+
const zb = new Float32Array(o.size * o.size).fill(-Infinity);
|
|
150
|
+
const alive = new Set();
|
|
151
|
+
items.forEach((it, idx) => {
|
|
152
|
+
const w = it.screen.map((p) => (cam.ortho ? -p.depth : 1 / p.depth));
|
|
153
|
+
fill(buf, zb, o.size, it.screen, w, idx, alive, it.alpha >= 1);
|
|
154
|
+
});
|
|
155
|
+
for (const id of buf)
|
|
156
|
+
if (id >= 0)
|
|
157
|
+
alive.add(id);
|
|
158
|
+
const remap = new Map(); // item index -> index among the visible faces
|
|
159
|
+
visible = items.filter((_, idx) => alive.has(idx) && (remap.set(idx, remap.size), true));
|
|
160
|
+
idBuf = buf.map((id) => (id < 0 ? -1 : (remap.get(id) ?? -1)));
|
|
161
|
+
}
|
|
162
|
+
let ordered = paintOrder(visible); // far to near
|
|
163
|
+
if (idBuf) {
|
|
164
|
+
// ids point into `visible`; the painter's order permutes it
|
|
165
|
+
const at = new Map();
|
|
166
|
+
visible.forEach((it, n) => at.set(it, n));
|
|
167
|
+
const perm = new Int32Array(visible.length);
|
|
168
|
+
ordered.forEach((it, n) => (perm[at.get(it)] = n));
|
|
169
|
+
idBuf = idBuf.map((id) => (id < 0 ? -1 : perm[id]));
|
|
170
|
+
}
|
|
75
171
|
// A point is drawn right after the nearest painted face that holds it, so
|
|
76
172
|
// faces painted later (closer to the camera) still cover it.
|
|
77
173
|
const last = new Map();
|
|
78
174
|
ordered.forEach((it, idx) => it.vs.forEach((k) => last.set(k, idx)));
|
|
175
|
+
// a point is visible when a visible face that holds it owns a pixel at (or right next to) it
|
|
176
|
+
const reached = (k) => {
|
|
177
|
+
const px = Math.round(proj[k].x);
|
|
178
|
+
const py = Math.round(proj[k].y);
|
|
179
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
180
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
181
|
+
const x = px + dx;
|
|
182
|
+
const y = py + dy;
|
|
183
|
+
if (x < 0 || y < 0 || x >= o.size || y >= o.size)
|
|
184
|
+
continue;
|
|
185
|
+
const id = idBuf[y * o.size + x];
|
|
186
|
+
if (id >= 0 && ordered[id].vs.includes(k))
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
};
|
|
79
192
|
const body = ordered.flatMap((it, idx) => {
|
|
80
193
|
const polygon = `<polygon data-face="${it.i}" data-depth="${num(it.depth)}" points="${it.pts}" ` +
|
|
81
|
-
`fill="${it.color}" stroke="${o.stroke}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
|
194
|
+
`fill="${it.color}"${it.alpha < 1 ? ` fill-opacity="${num(it.alpha)}"` : ''} stroke="${o.stroke}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
|
82
195
|
const dots = o.points
|
|
83
196
|
? it.vs
|
|
84
|
-
.filter((k) => last.get(k) === idx && (cam.ortho || proj[k].depth >= NEAR))
|
|
197
|
+
.filter((k) => last.get(k) === idx && (cam.ortho || proj[k].depth >= NEAR) && (!idBuf || reached(k)))
|
|
85
198
|
.sort((a, b) => a - b)
|
|
86
199
|
.map((k) => `<circle data-v="${k}" cx="${num(proj[k].x)}" cy="${num(proj[k].y)}" r="3" fill="${o.pointColor}"/>`)
|
|
87
200
|
: [];
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "3draft",
|
|
3
|
-
"version": "26.11.
|
|
3
|
+
"version": "26.11.3001",
|
|
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",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dist",
|
|
17
17
|
"!dist/**/*.test.*",
|
|
18
18
|
"README.md",
|
|
19
|
+
"APP.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|