3draft 26.11.1013 → 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 +71 -144
- package/dist/camera.d.ts +29 -0
- package/dist/camera.js +68 -0
- package/dist/check.d.ts +24 -0
- package/dist/check.js +83 -0
- package/dist/csg.d.ts +10 -0
- package/dist/csg.js +239 -0
- package/dist/edit.d.ts +23 -0
- package/dist/edit.js +48 -0
- package/dist/gpu.d.ts +77 -0
- package/dist/gpu.js +339 -0
- package/dist/index.d.ts +33 -0
- package/{src → dist}/index.js +14 -14
- package/dist/kernel-bytes.d.ts +1 -0
- package/dist/kernel-bytes.js +2 -0
- package/dist/kernel.d.ts +14 -0
- package/dist/kernel.js +81 -0
- package/dist/material.d.ts +31 -0
- package/dist/material.js +17 -0
- package/dist/mesh.d.ts +40 -0
- package/dist/mesh.js +130 -0
- package/dist/order.d.ts +22 -0
- package/dist/order.js +125 -0
- package/dist/pick.d.ts +18 -0
- package/dist/pick.js +82 -0
- package/dist/place.d.ts +23 -0
- package/{src → dist}/place.js +7 -13
- package/dist/svg.d.ts +37 -0
- package/dist/svg.js +239 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +2 -0
- package/dist/vec.d.ts +14 -0
- package/dist/vec.js +22 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +18 -9
- package/src/camera.js +0 -75
- package/src/check.js +0 -79
- package/src/edit.js +0 -47
- package/src/gpu.js +0 -252
- package/src/index.d.ts +0 -209
- package/src/mesh.js +0 -127
- package/src/order.js +0 -94
- package/src/pick.js +0 -83
- package/src/svg.js +0 -119
- package/src/vec.js +0 -16
- package/src/version.js +0 -8
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,193 +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
|
-
|
|
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.
|
|
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).
|
|
40
27
|
|
|
41
|
-
###
|
|
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`.
|
|
28
|
+
### Rendering
|
|
48
29
|
|
|
49
30
|
```js
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
const cam = makeCamera('persp', mesh, 400);
|
|
53
|
-
const hit = pick(mesh, cam, x, y); // { face, point, normal, distance, color } or null
|
|
31
|
+
import { box, merge, renderSheet, renderView, orbit, createGpu } from '3draft';
|
|
54
32
|
|
|
55
|
-
const
|
|
56
|
-
|
|
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 });
|
|
57
41
|
```
|
|
58
42
|
|
|
59
|
-
|
|
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.
|
|
43
|
+

|
|
63
44
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
67
49
|
|
|
68
|
-
###
|
|
50
|
+
### Materials
|
|
69
51
|
|
|
70
|
-
`
|
|
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.
|
|
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.
|
|
75
53
|
|
|
76
|
-
|
|
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 |
|
|
77
60
|
|
|
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):
|
|
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.
|
|
83
62
|
|
|
84
|
-
|
|
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
|
-
```
|
|
63
|
+
### Surfaces, picking, editing
|
|
94
64
|
|
|
95
|
-
`
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
98
69
|
|
|
99
|
-
###
|
|
70
|
+
### TypeScript and `|>`
|
|
100
71
|
|
|
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.
|
|
104
|
-
|
|
105
|
-
```sh
|
|
106
|
-
# Claude Code
|
|
107
|
-
claude mcp add 3draft -- node /absolute/path/to/3Draft/mcp/server.js
|
|
108
|
-
```
|
|
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.
|
|
109
73
|
|
|
110
|
-
```
|
|
111
|
-
|
|
74
|
+
```ts
|
|
75
|
+
const dir = spec.dir |> norm |> times(dist) |> plus(center); // the right side is a function
|
|
112
76
|
```
|
|
113
77
|
|
|
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 |
|
|
78
|
+
Put arrow functions in parentheses (`x |> ((v) => v + 1)`) and wrap a pipeline in parentheses next to `+` and `-`.
|
|
134
79
|
|
|
135
|
-
|
|
136
|
-
Everything is deterministic, so the same calls give the same SVG.
|
|
137
|
-
|
|
138
|
-
### Known limitations
|
|
80
|
+
---
|
|
139
81
|
|
|
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.
|
|
146
|
-
|
|
147
|
-
## Viewer app (repo only, not part of the npm package)
|
|
82
|
+
## 2. The viewer app
|
|
148
83
|
|
|
149
84
|
```sh
|
|
150
|
-
npm run app # http://localhost:5173/app/
|
|
85
|
+
npm run app # http://localhost:5173/app/ (repo only, not in the npm package)
|
|
151
86
|
```
|
|
152
87
|
|
|
153
|
-
|
|
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/`).
|
|
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.
|
|
157
89
|
|
|
158
|
-
|
|
90
|
+
**How to use it, all keys and the database layout: [APP.md](APP.md).**
|
|
159
91
|
|
|
160
|
-
|
|
161
|
-
leading zeros), so the published form is `YY.M.DNNN` where the last number is
|
|
162
|
-
day * 1000 + build:
|
|
92
|
+
---
|
|
163
93
|
|
|
164
|
-
|
|
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` |
|
|
94
|
+
## 3. Engine and LLM
|
|
169
95
|
|
|
170
|
-
|
|
171
|
-
`src/index.js` (`node scripts/bump.js --dry` only prints it).
|
|
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:
|
|
172
97
|
|
|
173
|
-
|
|
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.
|
|
174
103
|
|
|
175
|
-
|
|
176
|
-
texel of a small palette texture, so a whole scene is one pipeline, one bind
|
|
177
|
-
group and instanced draw calls.
|
|
178
|
-
- **Thin WASM core.** Transforms, culling and animation live in a WASM module
|
|
179
|
-
(HEXAW and/or Rust) behind a small, documented ABI. JS only uploads buffers.
|
|
180
|
-
- **WebGPU first, WebGL2 fallback** behind a minimal `Renderer` interface.
|
|
181
|
-
- **AI first.** A deterministic SVG preview and structured `inspect` reports
|
|
182
|
-
(plus an MCP server) let an AI build and verify models from text.
|
|
104
|
+
Y is up, the ground is y = 0, faces are counter-clockwise seen from outside.
|
|
183
105
|
|
|
184
|
-
|
|
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).
|
|
185
107
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Develop, version, license
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run build # src/*.ts -> dist/ (|> desugared, type checked)
|
|
114
|
+
npm test # builds, then runs every test
|
|
115
|
+
npm run build:check # fails when dist/ is out of date
|
|
116
|
+
npm run app # the viewer
|
|
117
|
+
```
|
|
191
118
|
|
|
192
|
-
|
|
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.
|
|
193
120
|
|
|
194
121
|
MIT
|
package/dist/camera.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Camera, Fit, Mesh, Vec3, ViewName, ViewSpec } from './types.js';
|
|
2
|
+
export declare const VIEW_ORDER: readonly ViewName[];
|
|
3
|
+
/**
|
|
4
|
+
* `fit` = { center, radius, dist? } overrides the target and framing (perspective views only use `dist`). Use it when the
|
|
5
|
+
* mesh moves (e.g. rotates) so the camera keeps looking at the same spot.
|
|
6
|
+
*/
|
|
7
|
+
export declare const makeCamera: (view: ViewName | ViewSpec, mesh: Mesh, size: number, fit?: Fit) => Camera;
|
|
8
|
+
/**
|
|
9
|
+
* Turntable rotation as a camera: the view you get from rotate(mesh, { yaw, pitch })
|
|
10
|
+
* with a fixed camera, but the mesh stays where it is. Picks and marks are then
|
|
11
|
+
* in object space.
|
|
12
|
+
*/
|
|
13
|
+
export declare const orbit: ({ yaw, pitch, ortho, name }?: {
|
|
14
|
+
yaw?: number;
|
|
15
|
+
pitch?: number;
|
|
16
|
+
ortho?: boolean;
|
|
17
|
+
name?: string;
|
|
18
|
+
}) => ViewSpec;
|
|
19
|
+
/** Ray through panel pixel (x, y), the inverse of project(): { origin, dir } in world space, dir is a unit vector. */
|
|
20
|
+
export declare const ray: (cam: Camera, x: number, y: number) => {
|
|
21
|
+
origin: Vec3;
|
|
22
|
+
dir: Vec3;
|
|
23
|
+
};
|
|
24
|
+
/** World point -> { x, y } in panel pixels (y down) and depth along the view direction. */
|
|
25
|
+
export declare const project: (cam: Camera, p: Vec3) => {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
depth: number;
|
|
29
|
+
};
|
package/dist/camera.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
import { add, cross, dot, norm, scale, plus, minus, times } from './vec.js';
|
|
6
|
+
import { bounds } from './mesh.js';
|
|
7
|
+
export const VIEW_ORDER = ['front', 'side', 'top', 'persp'];
|
|
8
|
+
const FOV = (35 * Math.PI) / 180;
|
|
9
|
+
const VIEWS = {
|
|
10
|
+
front: { dir: [0, 0, 1], up: [0, 1, 0], ortho: true }, // eye on +z
|
|
11
|
+
side: { dir: [1, 0, 0], up: [0, 1, 0], ortho: true }, // eye on +x
|
|
12
|
+
top: { dir: [0, 1, 0], up: [0, 0, -1], ortho: true }, // eye on +y, -z is screen-up
|
|
13
|
+
persp: { dir: norm([1, 0.8, 1.2]), up: [0, 1, 0], ortho: false },
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* `fit` = { center, radius, dist? } overrides the target and framing (perspective views only use `dist`). Use it when the
|
|
17
|
+
* mesh moves (e.g. rotates) so the camera keeps looking at the same spot.
|
|
18
|
+
*/
|
|
19
|
+
export const makeCamera = (view, mesh, size, fit) => {
|
|
20
|
+
const spec = typeof view === 'string' ? VIEWS[view] : view;
|
|
21
|
+
if (!spec)
|
|
22
|
+
throw new Error(`unknown view: ${view}`);
|
|
23
|
+
const name = typeof view === 'string' ? view : (view.name ?? 'custom');
|
|
24
|
+
const ortho = Boolean(spec.ortho);
|
|
25
|
+
const { center, radius } = fit ?? bounds(mesh);
|
|
26
|
+
const r0 = radius || 1;
|
|
27
|
+
const dist = ortho ? r0 * 4 : (fit?.dist ?? (r0 * 1.25) / Math.sin(FOV / 2)); // fit.dist: eye distance override (a first-person eye sits almost on the center)
|
|
28
|
+
const eye = plus(center)(times(dist)(norm(spec.dir)));
|
|
29
|
+
const f = norm(minus(eye)(center)); // forward
|
|
30
|
+
const r = norm(cross(f, spec.up)); // right
|
|
31
|
+
const u = cross(r, f); // up
|
|
32
|
+
const k = ortho ? ((size / 2) * 0.8) / r0 : size / 2 / Math.tan(FOV / 2);
|
|
33
|
+
return { name, eye, f, r, u, ortho, k, size };
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Turntable rotation as a camera: the view you get from rotate(mesh, { yaw, pitch })
|
|
37
|
+
* with a fixed camera, but the mesh stays where it is. Picks and marks are then
|
|
38
|
+
* in object space.
|
|
39
|
+
*/
|
|
40
|
+
export const orbit = ({ yaw = 0, pitch = 0, ortho = false, name = 'orbit' } = {}) => {
|
|
41
|
+
const cy = Math.cos(yaw);
|
|
42
|
+
const sy = Math.sin(yaw);
|
|
43
|
+
const cp = Math.cos(pitch);
|
|
44
|
+
const sp = Math.sin(pitch);
|
|
45
|
+
return { name, ortho, dir: [-sy * cp, sp, cy * cp], up: [sy * sp, cp, -cy * sp] };
|
|
46
|
+
};
|
|
47
|
+
/** Ray through panel pixel (x, y), the inverse of project(): { origin, dir } in world space, dir is a unit vector. */
|
|
48
|
+
export const ray = (cam, x, y) => {
|
|
49
|
+
const nx = (x - cam.size / 2) / cam.k;
|
|
50
|
+
const ny = -(y - cam.size / 2) / cam.k;
|
|
51
|
+
const lateral = plus(scale(cam.u, ny))(scale(cam.r, nx));
|
|
52
|
+
return cam.ortho
|
|
53
|
+
? { origin: add(cam.eye, lateral), dir: cam.f }
|
|
54
|
+
: { origin: cam.eye, dir: norm(plus(lateral)(cam.f)) };
|
|
55
|
+
};
|
|
56
|
+
/** World point -> { x, y } in panel pixels (y down) and depth along the view direction. */
|
|
57
|
+
export const project = (cam, p) => {
|
|
58
|
+
const d = minus(cam.eye)(p);
|
|
59
|
+
const cx = dot(d, cam.r);
|
|
60
|
+
const cy = dot(d, cam.u);
|
|
61
|
+
const depth = dot(d, cam.f);
|
|
62
|
+
const w = cam.ortho ? 1 : depth;
|
|
63
|
+
return {
|
|
64
|
+
x: cam.size / 2 + (cx / w) * cam.k,
|
|
65
|
+
y: cam.size / 2 - (cy / w) * cam.k,
|
|
66
|
+
depth,
|
|
67
|
+
};
|
|
68
|
+
};
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Mesh } from './types.js';
|
|
2
|
+
export interface Validation {
|
|
3
|
+
vertices: number;
|
|
4
|
+
faces: number;
|
|
5
|
+
/** Every edge is shared by two faces, wound consistently. */
|
|
6
|
+
closed: boolean;
|
|
7
|
+
openEdges: number;
|
|
8
|
+
/** Signed volume; null unless closed. */
|
|
9
|
+
volume: number | null;
|
|
10
|
+
/** Closed, but the faces point inward. */
|
|
11
|
+
inside_out: boolean;
|
|
12
|
+
degenerateFaces: number[];
|
|
13
|
+
nonPlanarFaces: number[];
|
|
14
|
+
nonConvexFaces: number[];
|
|
15
|
+
ok: boolean;
|
|
16
|
+
/** Plain-text findings. */
|
|
17
|
+
notes: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* { vertices, faces, closed, openEdges, volume, inside_out, degenerateFaces,
|
|
21
|
+
* nonPlanarFaces, nonConvexFaces, ok, notes }
|
|
22
|
+
* `volume` and `inside_out` only mean something for a closed mesh.
|
|
23
|
+
*/
|
|
24
|
+
export declare const validate: (mesh: Mesh) => Validation;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
import { sub, dot, cross, len, norm } from './vec.js';
|
|
4
|
+
import { faceNormal, bounds } from './mesh.js';
|
|
5
|
+
/**
|
|
6
|
+
* { vertices, faces, closed, openEdges, volume, inside_out, degenerateFaces,
|
|
7
|
+
* nonPlanarFaces, nonConvexFaces, ok, notes }
|
|
8
|
+
* `volume` and `inside_out` only mean something for a closed mesh.
|
|
9
|
+
*/
|
|
10
|
+
export const validate = (mesh) => {
|
|
11
|
+
const { positions, faces } = mesh;
|
|
12
|
+
const size = bounds(mesh).radius || 1;
|
|
13
|
+
const degenerate = [];
|
|
14
|
+
const nonPlanar = [];
|
|
15
|
+
const nonConvex = [];
|
|
16
|
+
const directed = new Map();
|
|
17
|
+
let volume = 0;
|
|
18
|
+
faces.forEach((face, i) => {
|
|
19
|
+
const pts = face.v.map((k) => positions[k]);
|
|
20
|
+
const m = pts.length;
|
|
21
|
+
const n = faceNormal(pts);
|
|
22
|
+
const area2 = len(n);
|
|
23
|
+
if (area2 < 1e-12 * size * size) {
|
|
24
|
+
degenerate.push(i);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const unit = norm(n);
|
|
28
|
+
if (Math.max(...pts.map((p) => Math.abs(dot(sub(p, pts[0]), unit)))) > 1e-6 * size)
|
|
29
|
+
nonPlanar.push(i);
|
|
30
|
+
for (let j = 0; j < m; j++) {
|
|
31
|
+
const a = pts[j];
|
|
32
|
+
const b = pts[(j + 1) % m];
|
|
33
|
+
const c = pts[(j + 2) % m];
|
|
34
|
+
if (dot(cross(sub(b, a), sub(c, b)), unit) < -1e-9 * size * size) {
|
|
35
|
+
nonConvex.push(i);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (let j = 0; j < m; j++) {
|
|
40
|
+
const key = `${face.v[j]},${face.v[(j + 1) % m]}`;
|
|
41
|
+
directed.set(key, (directed.get(key) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
for (let j = 1; j < m - 1; j++)
|
|
44
|
+
volume += dot(pts[0], cross(pts[j], pts[j + 1])) / 6;
|
|
45
|
+
});
|
|
46
|
+
let open = 0;
|
|
47
|
+
let repeated = 0;
|
|
48
|
+
for (const [key, count] of directed) {
|
|
49
|
+
const [a, b] = key.split(',');
|
|
50
|
+
if (count > 1)
|
|
51
|
+
repeated += 1;
|
|
52
|
+
if (!directed.has(`${b},${a}`))
|
|
53
|
+
open += 1;
|
|
54
|
+
}
|
|
55
|
+
const closed = faces.length > 0 && open === 0 && repeated === 0;
|
|
56
|
+
const insideOut = closed && volume < 0;
|
|
57
|
+
const notes = [];
|
|
58
|
+
if (degenerate.length)
|
|
59
|
+
notes.push(`degenerate faces (no area): ${degenerate.join(', ')}`);
|
|
60
|
+
if (nonPlanar.length)
|
|
61
|
+
notes.push(`non-planar faces: ${nonPlanar.join(', ')}`);
|
|
62
|
+
if (nonConvex.length)
|
|
63
|
+
notes.push(`non-convex faces (picking and painting assume convex): ${nonConvex.join(', ')}`);
|
|
64
|
+
if (repeated)
|
|
65
|
+
notes.push(`${repeated} edge(s) are used twice in the same direction: faces are wound inconsistently`);
|
|
66
|
+
if (open)
|
|
67
|
+
notes.push(`${open} open edge(s): the mesh is not closed (fine for a surface, e.g. a missing bottom)`);
|
|
68
|
+
if (insideOut)
|
|
69
|
+
notes.push('the faces point inward: reverse the vertex order of every face');
|
|
70
|
+
return {
|
|
71
|
+
vertices: positions.length,
|
|
72
|
+
faces: faces.length,
|
|
73
|
+
closed,
|
|
74
|
+
openEdges: open,
|
|
75
|
+
volume: closed ? Math.round(volume * 1e9) / 1e9 : null,
|
|
76
|
+
inside_out: insideOut,
|
|
77
|
+
degenerateFaces: degenerate,
|
|
78
|
+
nonPlanarFaces: nonPlanar,
|
|
79
|
+
nonConvexFaces: nonConvex,
|
|
80
|
+
ok: !degenerate.length && !nonPlanar.length && !nonConvex.length && !repeated && !insideOut,
|
|
81
|
+
notes,
|
|
82
|
+
};
|
|
83
|
+
};
|
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;
|