3draft 26.11.3001 → 26.11.4010
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 +37 -5
- package/README.md +7 -4
- package/dist/csg.d.ts +27 -2
- package/dist/csg.js +99 -8
- package/dist/gpu.d.ts +9 -0
- package/dist/gpu.js +112 -10
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/material.d.ts +3 -2
- package/dist/material.js +6 -5
- package/dist/wire.d.ts +57 -0
- package/dist/wire.js +244 -0
- package/package.json +1 -1
package/APP.md
CHANGED
|
@@ -10,7 +10,7 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
|
|
|
10
10
|
|
|
11
11
|
## The view
|
|
12
12
|
|
|
13
|
-
- **Starting view: WebGPU.** If WebGPU is unavailable, or the GPU device is lost, the app falls back to SVG by itself and says so in the footer (`G` or the `WebGPU` button switches by hand). SVG shows colour and transparency only; WebGPU also shows metal, roughness and emission.
|
|
13
|
+
- **Starting view: WebGPU.** If WebGPU is unavailable, or the GPU device is lost, the app falls back to SVG by itself and says so in the footer (`G` or the `WebGPU` button switches by hand). SVG shows colour and transparency only; WebGPU also shows metal, roughness and emission. The **filmic look** (`C`) adds linear light, Fresnel and tone mapping, so highlights roll off softly and `lamp`-like materials glow; switch it off for the plain flat look.
|
|
14
14
|
- **The drawing fills the stage.** On a desktop it covers the whole area; on a phone it is a little larger than the short side.
|
|
15
15
|
- **Drag** rotates, **double click** resets the view, the arrow keys rotate too.
|
|
16
16
|
- **The cross** follows the pointer and lies on the surface under it. The cursor is always a cross. On a phone, with no mouse, it aims at the middle.
|
|
@@ -24,19 +24,27 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
|
|
|
24
24
|
| `Space` | stop moving (all speeds to zero) |
|
|
25
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
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
|
-
| `
|
|
27
|
+
| `M` | place a prism on the surface under the cross, or shoot one into empty space. **In isolated mode (`/`) it fires a bullet** instead: a prism that starts at the camera point and flies along the ray through the cursor, so its long axis is always the view direction and you always see its back; if it hits the isolated object it sinks half its length and carves that part out (A − B, the cut is made of the selected material, `U` undoes it); if it misses it flies on to 4 times the camera-to-model distance and vanishes. Only the closed parts of the model are carved; open parts are ignored (see the health check below). A carve never leaves a crack: if the result would not be a closed solid it is retried with a hair's shift, and if that fails too the model stays as it was (the note "left as it was" shows). |
|
|
28
|
+
| `Tab` | edit mode on the object under the cross / off (see below) |
|
|
29
|
+
| `V` | Spider bar (in edit mode: draw a line): the first `V` remembers a surface point, the second joins the two with a thin prism (a quarter of the base of a placed one) made of the selected material. `V` over empty space forgets the first point |
|
|
29
30
|
| `X`, `Delete` | delete the object under the cross |
|
|
30
31
|
| `U`, `Backspace` | undo: placing, deleting, painting, booleans and moves, one step at a time |
|
|
31
32
|
| `P` | show / hide the material palette |
|
|
33
|
+
| `I` | pick material: the material of the face under the cross becomes the selected one |
|
|
32
34
|
| `Shift` + press | paint the face under the cross with the selected material (palette must be on) |
|
|
33
35
|
| `Shift` + double click | paint the whole object |
|
|
34
36
|
| `O` | boolean (see below) |
|
|
35
37
|
| `H` | move gizmo (see below) |
|
|
36
38
|
| `E` | edges and points on / off (also the `Edges` button) |
|
|
39
|
+
| `C` | filmic look on / off (also the `Filmic` button; on at the start, WebGPU view only) |
|
|
37
40
|
| `G`, `L` | SVG / WebGPU, turn the light |
|
|
38
41
|
| `?` | show / hide these shortcuts under the view (hidden at the start; also the `Keys` button) |
|
|
39
42
|
|
|
43
|
+
## Clear and example models
|
|
44
|
+
|
|
45
|
+
- **`Clear`** (footer) removes every object in the scene. It is one undo step: `U` brings them all back.
|
|
46
|
+
- **`+ Example…`** (the drop-down in the footer) adds a ready-made model to the scene: the house (the Blender import, 45 objects), the Cessna, the stone bridge or the table. It is placed beside what is already there, on its +x side, and the view frames everything. One `U` removes the whole example again. Examples are normal objects after that: paint, move, boolean and delete them like any other.
|
|
47
|
+
|
|
40
48
|
## Moving an object: the gizmo (`H`)
|
|
41
49
|
|
|
42
50
|
There is one gizmo at a time, and `H` cycles through three states:
|
|
@@ -47,6 +55,28 @@ There is one gizmo at a time, and `H` cycles through three states:
|
|
|
47
55
|
|
|
48
56
|
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
57
|
|
|
58
|
+
## Edit mode: drawing on a model (`Tab`)
|
|
59
|
+
|
|
60
|
+
The idea: there is a base model, and you refine or extend it by drawing lines on it. Put the cross on an object and press `Tab`. A red bar at the top says which object is being edited. Its vertices show as small grey crosses.
|
|
61
|
+
|
|
62
|
+
- **`V` draws a line.** The first `V` takes a point, the next `V` joins it to the new one, and the chain goes on from there. A point is either an existing vertex (the pointer **snaps** to vertices; a white cross shows which) or a new vertex put on the surface under the cross. On a face it splits the face into triangles, on an edge it goes into both faces, so the surface never cracks.
|
|
63
|
+
- **Two points on one face cut it.** The line becomes a real edge of the mesh: the surface gets detail exactly where you drew.
|
|
64
|
+
- **Points in the air extend the model.** With a line in progress, `V` over empty space puts the next point on the plane that faces the camera through the last point. So you can draw a new part out from the model, and lines that end on existing vertices are attached to it.
|
|
65
|
+
- **`Enter` closes a loop into a face.** The shortest closed loop (up to 8 lines) through the last line becomes a face with the selected material, wound away from the middle of the object. A loop of 4 or more becomes triangles, so faces stay flat.
|
|
66
|
+
- **Nothing moves by default** (and never while you draw with `V`). Moving is Blender-style, with a key per mode, and every drag is one `U` step:
|
|
67
|
+
- `1` **move points:** hover a vertex (white cross), drag it.
|
|
68
|
+
- `2` **move lines:** hover an edge or a line (white), drag it; both ends move.
|
|
69
|
+
- `3` **move faces:** hover a face (white outline), drag it; all its vertices move together and the face stays flat.
|
|
70
|
+
- The drag slides in the plane that faces the camera. A selected point, line or face (orange) also gets a gizmo: drag an arrow to move along one axis. Everything that uses the moved vertices follows: lines, faces and the base surface, so this is how you reshape the original model. A polygon that gets only some of its vertices moved turns into triangles.
|
|
71
|
+
- `V` goes back to drawing (and drops the selection); `Esc` does the same.
|
|
72
|
+
- **`B` turns the spider bars into lines.** The prisms made with `V` in object mode (they are objects named `bar`) become lines on the edited object: each end snaps to a vertex that is close, or is put on the surface if it is near it, or stays a free point. The bars are removed (one `U` brings them back). So a plan sketched as bars can be worked on as a wire.
|
|
73
|
+
- **`X`** removes the lines at the selected point or line (or the last line); the points stay. `U` undoes every step, one key press at a time.
|
|
74
|
+
- **`Esc`** stops the line you are drawing; then it leaves a move mode back to drawing; then it leaves edit mode (so does `Tab`). The lines are only shown in edit mode.
|
|
75
|
+
|
|
76
|
+
The lines live next to the mesh as `wire: [[a, b], ...]` (vertex pairs) in the object's document, so the AI sees them too. The geometry is in the engine (README part 1): `insertVertex`, `cutFace`, `moveVertex`, `moveVertexSet`, `fillLoop`, `addLine`, `findLoop`.
|
|
77
|
+
|
|
78
|
+
Limits for now: a face made from a non-flat or non-convex loop is only as good as its triangle fan, there is no mirror yet, and the lines are not deformation weights (a surface does not bend around a moved vertex, it just follows the vertices that are in the mesh).
|
|
79
|
+
|
|
50
80
|
## Boolean operations (`O`)
|
|
51
81
|
|
|
52
82
|
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.
|
|
@@ -61,13 +91,15 @@ Press `O` over an object: it gets an orange box. Press `O` over another one (gre
|
|
|
61
91
|
|
|
62
92
|
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
93
|
|
|
64
|
-
The math is the engine's `boolean(a, b, op)` (README, part 1).
|
|
94
|
+
The math is the engine's `boolean(a, b, op)` (README, part 1). Only closed parts are calculated: a part that is not a closed solid (a surface, a box without a bottom) is left out, and a union keeps it as it is. The status line under the picture is the **health check** of the object under the cross: `solid`, `N of M parts closed · K faces open`, or `no closed part`. Each object needs at least one **closed solid with outward faces**; with none, the reason shows in the dialog. With messy data (coincident faces) the result can come out open; the engine's `validate` says so.
|
|
65
95
|
|
|
66
96
|
## Materials (`P`)
|
|
67
97
|
|
|
68
98
|
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
99
|
|
|
70
|
-
|
|
100
|
+
**Pick material (`I`, or the `pick` button):** the eyedropper. `I` takes the material of the face under the cross. The `pick` button arms it, and the next click on a surface takes it (`Esc` cancels). The palette opens and that material is selected, so you can read its sliders or paint with it right away. A face that has only a colour selects the material with that colour, or starts a new material from it.
|
|
101
|
+
|
|
102
|
+
For the selected material there are four sliders (`metal`, `rough`, `alpha`, `emit`, 0 to 5) and a colour; `+ new` copies it as a new material. A painted face stores the material **name**, so editing a material changes every face made of it.
|
|
71
103
|
|
|
72
104
|
## Isolating, travelling
|
|
73
105
|
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ gpu.render(table, orbit({ yaw: -0.6, pitch: 0.45 }), { size: 600 });
|
|
|
43
43
|

|
|
44
44
|
|
|
45
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
|
|
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, real alpha blending (see-through faces in a second pass, sorted far to near) and instancing.
|
|
47
47
|
- Cameras: `front`, `side`, `top` (orthographic) and `persp`; `orbit({ yaw, pitch })` is a turntable; any `{ dir, up, ortho }` works.
|
|
48
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.
|
|
49
49
|
|
|
@@ -55,16 +55,19 @@ The palette is data: `{ name: { color, metal, rough, alpha, emit } }`. A face po
|
|
|
55
55
|
|---|---|---|
|
|
56
56
|
| `metal` 0..1 | paint ... metal | reflects sky and ground, tinted; strong highlight |
|
|
57
57
|
| `rough` 0..1 | mirror ... matte | widens the highlight, blurs the reflection |
|
|
58
|
-
| `alpha` 1..0 | solid ... invisible |
|
|
59
|
-
| `emit` 0..
|
|
58
|
+
| `alpha` 1..0 | solid ... invisible | blended in a second pass, far to near (SVG: `fill-opacity`, and a see-through face hides nothing) |
|
|
59
|
+
| `emit` 0..5 | lit ... glowing | up to 1 the colour fades to unshaded; above 1 (with `tonemap`) it is a light source, that many times brighter |
|
|
60
60
|
|
|
61
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.
|
|
62
62
|
|
|
63
|
+
`gpu.render(mesh, view, { tonemap: true })` switches on a filmic look: lighting in linear colour, HDR light, Fresnel on the edges, ACES tone mapping, and `emit` that really glows above white (`tonemap: { exposure, emit }` to tune; off by default, so the plain look stays the same).
|
|
64
|
+
|
|
63
65
|
### Surfaces, picking, editing
|
|
64
66
|
|
|
65
67
|
- `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
68
|
- `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
|
|
69
|
+
- `boolean(a, b, op)` (also `union`, `subtract`, `intersect`) is solid modelling on BSP trees: faces are split by planes, so the result stays convex and planar. Both operands must be closed with outward faces; an open shell (a wall without thickness) is refused with the reason. With `{ strict: false }` the parts that are not closed solids are left out of the calculation instead (a union keeps them as they are, a subtract keeps those of `a`); `health(mesh)` is the check behind it: the connected parts, which are closed, and how many faces a boolean can work with. Face colours and materials are kept; cut faces take the colour of the cutting solid. The result is welded and T-junctions are repaired. A solid stays a solid: a result that is not a closed, consistently wound solid (numerical trouble with coincident faces) is thrown away and the operation is repeated with the second operand nudged by a tiny amount; if no try is sound it throws and the caller keeps the original.
|
|
70
|
+
- **Drawing on a mesh** (`wire.ts`): `insertVertex(mesh, face, point)` puts a vertex on a surface (inside a face it makes a triangle fan, on an edge it joins both faces), `cutFace(mesh, a, b)` cuts a face along a line between two of its vertices, `moveVertex(mesh, i, to)` moves one (polygons that use it become triangles, so faces stay planar), `fillLoop(mesh, loop)` closes a loop of vertices into a face, `addLine` and `findLoop` keep the wire (a list of vertex pairs). These are what the viewer's edit mode (`Tab`, see [APP.md](APP.md)) uses.
|
|
68
71
|
- **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.
|
|
69
72
|
|
|
70
73
|
### TypeScript and `|>`
|
package/dist/csg.d.ts
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import type { Mesh } from './types.js';
|
|
2
2
|
export type BooleanOp = 'union' | 'subtract' | 'intersect';
|
|
3
|
+
/** One connected piece of a mesh (faces joined by shared edges). */
|
|
4
|
+
export interface Part {
|
|
5
|
+
/** Indices into mesh.faces. */
|
|
6
|
+
faces: number[];
|
|
7
|
+
closed: boolean;
|
|
8
|
+
insideOut: boolean;
|
|
9
|
+
openEdges: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Health {
|
|
12
|
+
parts: Part[];
|
|
13
|
+
/** Faces in closed, outward parts: the ones a boolean can work with. */
|
|
14
|
+
usableFaces: number;
|
|
15
|
+
/** Faces in open (or inside-out) parts: a boolean ignores them. */
|
|
16
|
+
skippedFaces: number;
|
|
17
|
+
/** true when at least one part is a closed solid. */
|
|
18
|
+
hasSolid: boolean;
|
|
19
|
+
/** true when every part is a closed solid. */
|
|
20
|
+
allSolid: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Splits a mesh into connected parts and says which of them are closed solids (the health check). */
|
|
23
|
+
export declare const health: (mesh: Mesh) => Health;
|
|
3
24
|
/**
|
|
4
25
|
* 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.
|
|
26
|
+
* Throws with a reason when an input is open or inside out. With `strict: false` the parts that are not closed solids are
|
|
27
|
+
* left out of the calculation instead (see `health`): a union keeps them as they are, a subtract keeps those of `a`,
|
|
28
|
+
* an intersect drops them; it throws only when `a` has no closed part at all.
|
|
6
29
|
*/
|
|
7
|
-
export declare const boolean: (a: Mesh, b: Mesh, op: BooleanOp
|
|
30
|
+
export declare const boolean: (a: Mesh, b: Mesh, op: BooleanOp, { strict }?: {
|
|
31
|
+
strict?: boolean;
|
|
32
|
+
}) => Mesh;
|
|
8
33
|
export declare const union: (a: Mesh, b: Mesh) => Mesh;
|
|
9
34
|
export declare const subtract: (a: Mesh, b: Mesh) => Mesh;
|
|
10
35
|
export declare const intersect: (a: Mesh, b: Mesh) => Mesh;
|
package/dist/csg.js
CHANGED
|
@@ -145,8 +145,8 @@ const toMesh = (polys, scale) => {
|
|
|
145
145
|
v.pop();
|
|
146
146
|
if (v.length < 3)
|
|
147
147
|
return [];
|
|
148
|
-
|
|
149
|
-
return
|
|
148
|
+
// slivers stay for now: their vertices still give the neighbours' edges something to join to (see below)
|
|
149
|
+
return [{ v, color: p.color, mat: p.mat }];
|
|
150
150
|
});
|
|
151
151
|
// T-junctions: a vertex lying on the edge of a neighbouring face gets inserted into that edge
|
|
152
152
|
const used = new Set(faces.flatMap((f) => f.v));
|
|
@@ -167,8 +167,8 @@ const toMesh = (polys, scale) => {
|
|
|
167
167
|
continue;
|
|
168
168
|
const on = [];
|
|
169
169
|
for (const k of verts) {
|
|
170
|
-
if (k === a || k === b)
|
|
171
|
-
continue;
|
|
170
|
+
if (k === a || k === b || f.v.includes(k))
|
|
171
|
+
continue; // never twice in one face
|
|
172
172
|
const pk = positions[k];
|
|
173
173
|
const t = dot(sub(pk, pa), d) / (dl * dl);
|
|
174
174
|
if (t <= 0 || t >= 1)
|
|
@@ -183,6 +183,10 @@ const toMesh = (polys, scale) => {
|
|
|
183
183
|
return { ...f, v: out };
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
|
+
// Now the slivers can go: a face with no area (its corners are in a line, the neighbours already have the extra vertices)
|
|
187
|
+
// closes up around it. Dropping one before the T-junctions are done would leave a hole (an open edge) behind.
|
|
188
|
+
const flat = 1e-9 * scale * scale;
|
|
189
|
+
faces = faces.filter((f) => len(faceNormal(f.v.map((k) => positions[k]))) >= flat);
|
|
186
190
|
return { positions, faces };
|
|
187
191
|
};
|
|
188
192
|
const closedSolid = (name, m) => {
|
|
@@ -192,13 +196,100 @@ const closedSolid = (name, m) => {
|
|
|
192
196
|
if (v.inside_out)
|
|
193
197
|
throw new Error(`boolean needs outward faces: ${name} is inside out`);
|
|
194
198
|
};
|
|
199
|
+
const subMesh = (mesh, idx) => {
|
|
200
|
+
const map = new Map();
|
|
201
|
+
const positions = [];
|
|
202
|
+
const faces = idx.map((i) => {
|
|
203
|
+
const f = mesh.faces[i];
|
|
204
|
+
return { ...f, v: f.v.map((k) => (map.has(k) ? map.get(k) : (map.set(k, positions.length), positions.push(mesh.positions[k]), positions.length - 1))) };
|
|
205
|
+
});
|
|
206
|
+
return { ...mesh, positions, faces };
|
|
207
|
+
};
|
|
208
|
+
/** Splits a mesh into connected parts and says which of them are closed solids (the health check). */
|
|
209
|
+
export const health = (mesh) => {
|
|
210
|
+
const n = mesh.faces.length;
|
|
211
|
+
const root = Array.from({ length: n }, (_, i) => i);
|
|
212
|
+
const find = (i) => (root[i] === i ? i : (root[i] = find(root[i])));
|
|
213
|
+
const seen = new Map();
|
|
214
|
+
mesh.faces.forEach((f, i) => f.v.forEach((v, k) => {
|
|
215
|
+
const w = f.v[(k + 1) % f.v.length];
|
|
216
|
+
const key = v < w ? `${v},${w}` : `${w},${v}`;
|
|
217
|
+
const j = seen.get(key);
|
|
218
|
+
if (j === undefined)
|
|
219
|
+
seen.set(key, i);
|
|
220
|
+
else
|
|
221
|
+
root[find(i)] = find(j);
|
|
222
|
+
}));
|
|
223
|
+
const groups = new Map();
|
|
224
|
+
for (let i = 0; i < n; i++)
|
|
225
|
+
groups.set(find(i), [...(groups.get(find(i)) ?? []), i]);
|
|
226
|
+
const parts = [...groups.values()].map((faces) => {
|
|
227
|
+
const v = validate(subMesh(mesh, faces));
|
|
228
|
+
return { faces, closed: v.closed, insideOut: v.inside_out, openEdges: v.openEdges };
|
|
229
|
+
});
|
|
230
|
+
const usable = parts.filter((p) => p.closed && !p.insideOut);
|
|
231
|
+
const usableFaces = usable.reduce((t, p) => t + p.faces.length, 0);
|
|
232
|
+
return { parts, usableFaces, skippedFaces: n - usableFaces, hasSolid: usable.length > 0, allSolid: usable.length === parts.length && parts.length > 0 };
|
|
233
|
+
};
|
|
234
|
+
const append = (base, extra) => {
|
|
235
|
+
const o = base.positions.length;
|
|
236
|
+
return { ...base, positions: [...base.positions, ...extra.positions], faces: [...base.faces, ...extra.faces.map((f) => ({ ...f, v: f.v.map((k) => k + o) }))] };
|
|
237
|
+
};
|
|
195
238
|
/**
|
|
196
239
|
* 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.
|
|
240
|
+
* Throws with a reason when an input is open or inside out. With `strict: false` the parts that are not closed solids are
|
|
241
|
+
* left out of the calculation instead (see `health`): a union keeps them as they are, a subtract keeps those of `a`,
|
|
242
|
+
* an intersect drops them; it throws only when `a` has no closed part at all.
|
|
198
243
|
*/
|
|
199
|
-
export const boolean = (a, b, op) => {
|
|
200
|
-
|
|
201
|
-
|
|
244
|
+
export const boolean = (a, b, op, { strict = true } = {}) => {
|
|
245
|
+
if (strict) {
|
|
246
|
+
closedSolid('a', a);
|
|
247
|
+
closedSolid('b', b);
|
|
248
|
+
return run(a, b, op);
|
|
249
|
+
}
|
|
250
|
+
const ha = health(a);
|
|
251
|
+
const hb = health(b);
|
|
252
|
+
if (!ha.hasSolid)
|
|
253
|
+
throw new Error('boolean: the model has no closed part (nothing to calculate with)');
|
|
254
|
+
if (!hb.hasSolid)
|
|
255
|
+
throw new Error('boolean: the second model has no closed part');
|
|
256
|
+
const pick = (m, h, closed) => subMesh(m, h.parts.filter((p) => (p.closed && !p.insideOut) === closed).flatMap((p) => p.faces).sort((x, y) => x - y));
|
|
257
|
+
let out = run(pick(a, ha, true), pick(b, hb, true), op);
|
|
258
|
+
if (ha.skippedFaces)
|
|
259
|
+
out = append(out, pick(a, ha, false));
|
|
260
|
+
if (op === 'union' && hb.skippedFaces)
|
|
261
|
+
out = append(out, pick(b, hb, false));
|
|
262
|
+
return out;
|
|
263
|
+
};
|
|
264
|
+
// A result is sound when it is a closed, outward solid with consistent winding (and the inputs were).
|
|
265
|
+
const sound = (m) => {
|
|
266
|
+
if (!m.faces.length)
|
|
267
|
+
return true; // nothing is a fine result (disjoint intersect)
|
|
268
|
+
const v = validate(m);
|
|
269
|
+
return v.closed && !v.inside_out && !v.notes.some((n) => /wound inconsistently/.test(n));
|
|
270
|
+
};
|
|
271
|
+
const shiftBy = (m, d) => ({ ...m, positions: m.positions.map((p) => [p[0] + d[0], p[1] + d[1], p[2] + d[2]]) });
|
|
272
|
+
/**
|
|
273
|
+
* BSP booleans hit numerical trouble when faces are (nearly) coincident or a cut passes within a hair of a vertex; the
|
|
274
|
+
* result then comes out with a crack or a flipped face. A solid must stay a solid, so a result that is not sound is
|
|
275
|
+
* thrown away and the operation is repeated with the second operand moved by a tiny amount (a few tries, growing).
|
|
276
|
+
* When no try gives a sound solid it throws, so the caller keeps the original.
|
|
277
|
+
*/
|
|
278
|
+
const run = (a, b, op) => {
|
|
279
|
+
const first = run0(a, b, op);
|
|
280
|
+
if (sound(first) || !sound(a) || !sound(b))
|
|
281
|
+
return first;
|
|
282
|
+
const size = Math.max(bounds(a).radius, bounds(b).radius, 1e-9);
|
|
283
|
+
for (let k = 1; k <= 12; k++) {
|
|
284
|
+
const mag = size * 1e-4 * k;
|
|
285
|
+
const d = [Math.sin(k * 12.9898) * mag, Math.sin(k * 78.233) * mag, Math.sin(k * 37.719) * mag];
|
|
286
|
+
const r = run0(a, shiftBy(b, d), op);
|
|
287
|
+
if (sound(r))
|
|
288
|
+
return r;
|
|
289
|
+
}
|
|
290
|
+
throw new Error('boolean: the result would not be a closed solid (numerical trouble); the operands were left alone');
|
|
291
|
+
};
|
|
292
|
+
const run0 = (a, b, op) => {
|
|
202
293
|
const sa = bounds(a);
|
|
203
294
|
const sb = bounds(b);
|
|
204
295
|
const scale = Math.max(sa.radius, sb.radius, 1e-9) * 2 + len(sub(sa.center, sb.center));
|
package/dist/gpu.d.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface GpuRenderOptions {
|
|
|
30
30
|
mesh: Mesh;
|
|
31
31
|
data: Float32Array;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Filmic look: lighting in linear colour, HDR light and emission, Fresnel on the edges, ACES tone mapping.
|
|
35
|
+
* `true` for the defaults or { exposure = 1, emit = 2 } (emit: brightness of one unit of material `emit`, which goes up to 5).
|
|
36
|
+
* Off by default (the plain flat look).
|
|
37
|
+
*/
|
|
38
|
+
tonemap?: boolean | {
|
|
39
|
+
exposure?: number;
|
|
40
|
+
emit?: number;
|
|
41
|
+
};
|
|
33
42
|
}
|
|
34
43
|
/** One copy of a prototype: its position, orthonormal frame and color. */
|
|
35
44
|
export interface Instance {
|
package/dist/gpu.js
CHANGED
|
@@ -71,6 +71,7 @@ struct U {
|
|
|
71
71
|
ground : vec4<f32>,
|
|
72
72
|
eye : vec4<f32>,
|
|
73
73
|
mat : vec4<f32>, // ambient, diffuse, specular, shininess
|
|
74
|
+
fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain
|
|
74
75
|
};
|
|
75
76
|
@group(0) @binding(0) var<uniform> u : U;
|
|
76
77
|
|
|
@@ -112,11 +113,47 @@ struct IIn {
|
|
|
112
113
|
return o;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
fn toLinear(c : vec3<f32>) -> vec3<f32> {
|
|
117
|
+
return select(pow((c + 0.055) / 1.055, vec3<f32>(2.4)), c / 12.92, c <= vec3<f32>(0.04045));
|
|
118
|
+
}
|
|
119
|
+
fn toSrgb(c : vec3<f32>) -> vec3<f32> {
|
|
120
|
+
return select(1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3<f32>(0.0031308));
|
|
121
|
+
}
|
|
122
|
+
// ACES filmic curve (Narkowicz fit): highlights roll off softly instead of clipping
|
|
123
|
+
fn aces(x : vec3<f32>) -> vec3<f32> {
|
|
124
|
+
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3<f32>(0.0), vec3<f32>(1.0));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
|
|
128
|
+
fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
|
|
129
|
+
let metal = i.m.x;
|
|
130
|
+
let rough = i.m.y;
|
|
131
|
+
let albedo = toLinear(i.c);
|
|
132
|
+
let hemi = mix(toLinear(u.ground.rgb), toLinear(u.sky.rgb), n.y * 0.5 + 0.5) * u.mat.x;
|
|
133
|
+
let diff = max(dot(n, l), 0.0) * u.mat.y * 1.6;
|
|
134
|
+
let h = normalize(l + v);
|
|
135
|
+
let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
|
|
136
|
+
let spec = pow(max(dot(n, h), 0.0), shin) * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * step(0.0, dot(n, l)) * 2.5;
|
|
137
|
+
let r = reflect(-v, n);
|
|
138
|
+
// Schlick: edges mirror more, metal keeps its tint
|
|
139
|
+
let f0 = mix(vec3<f32>(0.04), albedo, metal);
|
|
140
|
+
let fres = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - max(dot(n, v), 0.0), 5.0) * (1.0 - rough);
|
|
141
|
+
let env = mix(toLinear(u.ground.rgb) * 1.2, toLinear(u.sky.rgb) * 1.5, smoothstep(-0.25 - rough * 0.5, 0.25 + rough * 0.5, r.y));
|
|
142
|
+
var lit = albedo * (hemi + vec3<f32>(diff)) * (1.0 - metal)
|
|
143
|
+
+ albedo * env * metal * (1.0 - 0.6 * rough)
|
|
144
|
+
+ env * fres * (1.0 - rough) * 0.5
|
|
145
|
+
+ vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
|
|
146
|
+
// emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
|
|
147
|
+
lit = lit + albedo * i.m.w * u.fx.z;
|
|
148
|
+
return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
|
|
149
|
+
}
|
|
150
|
+
|
|
115
151
|
@fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
|
|
116
152
|
var n = normalize(i.n);
|
|
117
153
|
if (!front) { n = -n; }
|
|
118
154
|
let l = normalize(u.light.xyz);
|
|
119
155
|
let v = normalize(u.eye.xyz - i.w);
|
|
156
|
+
if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
|
|
120
157
|
let metal = i.m.x;
|
|
121
158
|
let rough = i.m.y;
|
|
122
159
|
let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
|
|
@@ -131,7 +168,7 @@ struct IIn {
|
|
|
131
168
|
var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
|
|
132
169
|
+ i.c * env * metal * (1.0 - 0.6 * rough)
|
|
133
170
|
+ vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
|
|
134
|
-
lit = mix(lit, i.c * 1.15, i.m.w);
|
|
171
|
+
lit = mix(lit, i.c * 1.15, min(i.m.w, 1.0)); // the plain look: emit 0..1 fades to flat colour, more does not add
|
|
135
172
|
return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
|
|
136
173
|
}
|
|
137
174
|
`;
|
|
@@ -171,19 +208,34 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
171
208
|
stepMode: 'instance',
|
|
172
209
|
attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
|
|
173
210
|
};
|
|
174
|
-
const make = (entry, buffers) => device.createRenderPipeline({
|
|
211
|
+
const make = (entry, buffers, glass = false) => device.createRenderPipeline({
|
|
175
212
|
layout: 'auto',
|
|
176
213
|
vertex: { module, entryPoint: entry, buffers },
|
|
177
|
-
fragment: {
|
|
214
|
+
fragment: {
|
|
215
|
+
module,
|
|
216
|
+
entryPoint: 'fs',
|
|
217
|
+
targets: [
|
|
218
|
+
glass
|
|
219
|
+
? {
|
|
220
|
+
format,
|
|
221
|
+
blend: {
|
|
222
|
+
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
223
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
: { format },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
178
229
|
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
multisample:
|
|
230
|
+
// see-through faces (alpha < 1) are blended over what is behind them: they test depth but do not write it
|
|
231
|
+
depthStencil: { format: 'depth24plus', depthWriteEnabled: !glass, depthCompare: 'less' },
|
|
232
|
+
multisample: { count: sampleCount },
|
|
182
233
|
});
|
|
183
234
|
const pipeline = make('vs', [meshLayout]);
|
|
235
|
+
const glassPipeline = make('vs', [meshLayout], true);
|
|
184
236
|
// the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
|
|
185
237
|
const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
|
|
186
|
-
const uniforms = device.createBuffer({ size:
|
|
238
|
+
const uniforms = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
187
239
|
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
188
240
|
const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
189
241
|
// Geometry that did not change since the last frame is not rebuilt or uploaded again.
|
|
@@ -199,6 +251,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
199
251
|
let instCount = 0;
|
|
200
252
|
let vertexBuffer = null;
|
|
201
253
|
let capacity = 0;
|
|
254
|
+
let glassBuffer = null; // the see-through triangles, sorted far to near for the eye
|
|
255
|
+
let glassCapacity = 0;
|
|
256
|
+
let glassTris = []; // centroid and offset (in floats) in glassSource
|
|
257
|
+
let glassSource = new Float32Array(0);
|
|
258
|
+
let glassCount = 0;
|
|
259
|
+
let glassEye = '';
|
|
202
260
|
let px = 0;
|
|
203
261
|
let depth = null;
|
|
204
262
|
let color = null;
|
|
@@ -213,13 +271,30 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
213
271
|
depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
|
|
214
272
|
color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
|
|
215
273
|
};
|
|
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) => {
|
|
274
|
+
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false } = {}, readback = false) => {
|
|
217
275
|
const L = { ...defaultLight, ...light };
|
|
276
|
+
const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
|
|
218
277
|
const M = { ...defaultMaterial, ...material };
|
|
219
278
|
const cam = makeCamera(view, mesh, size, fit);
|
|
220
279
|
targets(Math.max(1, Math.round(size * dpr)));
|
|
221
280
|
if (mesh !== uploaded || palette !== uploadedPalette) {
|
|
222
|
-
const
|
|
281
|
+
const all = buildVertices(mesh, palette);
|
|
282
|
+
// split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
|
|
283
|
+
const tri = 3 * FLOATS;
|
|
284
|
+
const solid = [];
|
|
285
|
+
glassTris = [];
|
|
286
|
+
for (let o = 0; o < all.length; o += tri) {
|
|
287
|
+
if (all[o + 11] < 1) {
|
|
288
|
+
const c = [0, 1, 2].map((k) => (all[o + k] + all[o + FLOATS + k] + all[o + 2 * FLOATS + k]) / 3);
|
|
289
|
+
glassTris.push({ c, at: o });
|
|
290
|
+
}
|
|
291
|
+
else
|
|
292
|
+
for (let k = 0; k < tri; k++)
|
|
293
|
+
solid.push(all[o + k]);
|
|
294
|
+
}
|
|
295
|
+
glassSource = all;
|
|
296
|
+
glassEye = '';
|
|
297
|
+
const verts = Float32Array.from(solid);
|
|
223
298
|
if (verts.byteLength > capacity) {
|
|
224
299
|
vertexBuffer?.destroy();
|
|
225
300
|
capacity = Math.max(verts.byteLength, 4096);
|
|
@@ -253,13 +328,32 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
253
328
|
}
|
|
254
329
|
if (!instances)
|
|
255
330
|
instCount = 0;
|
|
256
|
-
const
|
|
331
|
+
const eyeKey = cam.eye.join(',');
|
|
332
|
+
if (glassTris.length && eyeKey !== glassEye) {
|
|
333
|
+
glassEye = eyeKey;
|
|
334
|
+
const [ex, ey, ez] = cam.eye;
|
|
335
|
+
const far = (t) => (t.c[0] - ex) ** 2 + (t.c[1] - ey) ** 2 + (t.c[2] - ez) ** 2;
|
|
336
|
+
const sorted = [...glassTris].sort((a, b) => far(b) - far(a)); // far first
|
|
337
|
+
const out = new Float32Array(sorted.length * 3 * FLOATS);
|
|
338
|
+
sorted.forEach((t, k) => out.set(glassSource.subarray(t.at, t.at + 3 * FLOATS), k * 3 * FLOATS));
|
|
339
|
+
if (out.byteLength > glassCapacity) {
|
|
340
|
+
glassBuffer?.destroy();
|
|
341
|
+
glassCapacity = Math.max(out.byteLength, 4096);
|
|
342
|
+
glassBuffer = device.createBuffer({ size: glassCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
343
|
+
}
|
|
344
|
+
device.queue.writeBuffer(glassBuffer, 0, out);
|
|
345
|
+
glassCount = sorted.length * 3;
|
|
346
|
+
}
|
|
347
|
+
if (!glassTris.length)
|
|
348
|
+
glassCount = 0;
|
|
349
|
+
const u = new Float32Array(40);
|
|
257
350
|
u.set(viewProjection(cam), 0);
|
|
258
351
|
u.set([...(norm(L.direction)), 0], 16);
|
|
259
352
|
u.set([...L.sky, 0], 20);
|
|
260
353
|
u.set([...L.ground, 0], 24);
|
|
261
354
|
u.set([...cam.eye, 0], 28);
|
|
262
355
|
u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
|
|
356
|
+
u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, 0], 36);
|
|
263
357
|
device.queue.writeBuffer(uniforms, 0, u);
|
|
264
358
|
const encoder = device.createCommandEncoder();
|
|
265
359
|
const offscreen = readback
|
|
@@ -291,6 +385,13 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
291
385
|
pass.setVertexBuffer(1, instBuffer);
|
|
292
386
|
pass.draw(protoCount, instCount);
|
|
293
387
|
}
|
|
388
|
+
if (glassCount) {
|
|
389
|
+
// after everything solid (and the instances): see-through faces, far to near
|
|
390
|
+
pass.setPipeline(glassPipeline);
|
|
391
|
+
pass.setBindGroup(0, bind);
|
|
392
|
+
pass.setVertexBuffer(0, glassBuffer);
|
|
393
|
+
pass.draw(glassCount);
|
|
394
|
+
}
|
|
294
395
|
pass.end();
|
|
295
396
|
if (!offscreen) {
|
|
296
397
|
device.queue.submit([encoder.finish()]);
|
|
@@ -328,6 +429,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
328
429
|
const pixels = (mesh, view, options) => draw(mesh, view, options, true);
|
|
329
430
|
const destroy = () => {
|
|
330
431
|
vertexBuffer?.destroy();
|
|
432
|
+
glassBuffer?.destroy();
|
|
331
433
|
protoBuffer?.destroy();
|
|
332
434
|
instBuffer?.destroy();
|
|
333
435
|
depth?.destroy();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export declare const name: "3draft";
|
|
2
|
-
export declare const version = "26.11.
|
|
2
|
+
export declare const version = "26.11.4010";
|
|
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
|
+
export { boolean, union, subtract, intersect, health } from './csg.js';
|
|
8
|
+
export type { BooleanOp, Health, Part } from './csg.js';
|
|
9
9
|
export type { Validation } from './check.js';
|
|
10
10
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
11
11
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
@@ -31,3 +31,5 @@ export interface Info {
|
|
|
31
31
|
export declare const info: () => Info;
|
|
32
32
|
export { initKernel } from './kernel.js';
|
|
33
33
|
export type { Kernel } from './kernel.js';
|
|
34
|
+
export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
|
|
35
|
+
export type { Wire } from './wire.js';
|
package/dist/index.js
CHANGED
|
@@ -4,10 +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.4010';
|
|
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
|
+
export { boolean, union, subtract, intersect, health } from './csg.js';
|
|
11
11
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
12
12
|
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
13
13
|
export { pick, crosshair } from './pick.js';
|
|
@@ -30,3 +30,4 @@ export const info = () => ({
|
|
|
30
30
|
],
|
|
31
31
|
});
|
|
32
32
|
export { initKernel } from './kernel.js';
|
|
33
|
+
export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
|
package/dist/material.d.ts
CHANGED
|
@@ -6,9 +6,9 @@ export interface Material {
|
|
|
6
6
|
metal?: number;
|
|
7
7
|
/** 0 = mirror-like, 1 = matte; widens the highlight. */
|
|
8
8
|
rough?: number;
|
|
9
|
-
/** 1 = solid, 0 = invisible.
|
|
9
|
+
/** 1 = solid, 0 = invisible. Blended over what is behind it (WebGPU: a second pass, far to near). */
|
|
10
10
|
alpha?: number;
|
|
11
|
-
/** 0..
|
|
11
|
+
/** 0..5: how much the color glows on its own, unlit. Up to 1 it fades from lit to flat colour; above 1 (WebGPU `tonemap`) it is a light source: that many times brighter. */
|
|
12
12
|
emit?: number;
|
|
13
13
|
}
|
|
14
14
|
export type Palette = Record<string, Material>;
|
|
@@ -20,6 +20,7 @@ export interface Surface {
|
|
|
20
20
|
alpha: number;
|
|
21
21
|
emit: number;
|
|
22
22
|
}
|
|
23
|
+
export declare const EMIT_MAX = 5;
|
|
23
24
|
export declare const FALLBACK_COLOR = "#c8c8c8";
|
|
24
25
|
export declare const DEFAULTS: {
|
|
25
26
|
readonly metal: 0;
|
package/dist/material.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Materials are data. A palette is a plain object { name: Material }; a face points at an entry by name
|
|
2
2
|
// (`face.mat`) and keeps its own `color` as a fallback, so a mesh still looks right without the palette.
|
|
3
3
|
// One shader draws all of them; the numbers below are the only knobs.
|
|
4
|
-
const
|
|
4
|
+
const clamp = (n, fallback, max = 1) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback);
|
|
5
|
+
export const EMIT_MAX = 5;
|
|
5
6
|
export const FALLBACK_COLOR = '#c8c8c8';
|
|
6
7
|
export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
|
|
7
8
|
/** The surface of a face: its palette entry when it names one, else just its color with default properties. */
|
|
@@ -9,9 +10,9 @@ export const surfaceOf = (face, palette) => {
|
|
|
9
10
|
const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
|
|
10
11
|
return {
|
|
11
12
|
color: m?.color ?? face.color ?? FALLBACK_COLOR,
|
|
12
|
-
metal:
|
|
13
|
-
rough:
|
|
14
|
-
alpha:
|
|
15
|
-
emit:
|
|
13
|
+
metal: clamp(m?.metal, DEFAULTS.metal),
|
|
14
|
+
rough: clamp(m?.rough, DEFAULTS.rough),
|
|
15
|
+
alpha: clamp(m?.alpha, DEFAULTS.alpha),
|
|
16
|
+
emit: clamp(m?.emit, DEFAULTS.emit, EMIT_MAX),
|
|
16
17
|
};
|
|
17
18
|
};
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Mesh, Vec3, Face } from './types.js';
|
|
2
|
+
/** Lines drawn on / off a mesh: pairs of vertex indices. */
|
|
3
|
+
export type Wire = [number, number][];
|
|
4
|
+
/**
|
|
5
|
+
* Puts a vertex at `point`, which lies on face `face`. On an existing vertex it returns that one; on an edge it is
|
|
6
|
+
* added to both faces that share the edge (no crack); inside the face it splits the face into triangles around it.
|
|
7
|
+
* { mesh, index }
|
|
8
|
+
*/
|
|
9
|
+
export declare const insertVertex: (mesh: Mesh, face: number, point: Vec3) => {
|
|
10
|
+
mesh: Mesh;
|
|
11
|
+
index: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Cuts the face that holds vertices `a` and `b` (not next to each other) along the line a-b: two convex faces.
|
|
15
|
+
* `cut` is false when no face holds both, or they are already neighbours on every face.
|
|
16
|
+
*/
|
|
17
|
+
export declare const cutFace: (mesh: Mesh, a: number, b: number) => {
|
|
18
|
+
mesh: Mesh;
|
|
19
|
+
cut: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Moves vertex `i` to `to`. Polygons that use it become triangles, so every face stays planar. */
|
|
22
|
+
export declare const moveVertex: (mesh: Mesh, i: number, to: Vec3) => Mesh;
|
|
23
|
+
/**
|
|
24
|
+
* A face from a closed loop of vertices. It is wound to point away from `awayFrom` (default: the middle of the
|
|
25
|
+
* mesh), which is what a patch closing a gap in a surface wants; 4 or more vertices become a fan of triangles.
|
|
26
|
+
*/
|
|
27
|
+
export declare const fillLoop: (mesh: Mesh, loop: number[], face?: Partial<Face>, awayFrom?: Vec3) => Mesh;
|
|
28
|
+
/** Adds the line a-b (not twice, not a point). */
|
|
29
|
+
export declare const addLine: (wire: Wire, a: number, b: number) => Wire;
|
|
30
|
+
/**
|
|
31
|
+
* The shortest closed loop of lines through line [a, b], at most `max` vertices: [a, ..., b], or null.
|
|
32
|
+
* Close it with fillLoop.
|
|
33
|
+
*/
|
|
34
|
+
export declare const findLoop: (wire: Wire, [a, b]: [number, number], max?: number) => number[] | null;
|
|
35
|
+
/**
|
|
36
|
+
* The surface point of the mesh nearest to `p`, if it is within `tolerance`: { face, point } (point lies on the face),
|
|
37
|
+
* otherwise null. For placing a point that was drawn near a surface on the surface.
|
|
38
|
+
*/
|
|
39
|
+
export declare const nearestSurface: (mesh: Mesh, p: Vec3, tolerance: number) => {
|
|
40
|
+
face: number;
|
|
41
|
+
point: Vec3;
|
|
42
|
+
} | null;
|
|
43
|
+
/**
|
|
44
|
+
* Turns line segments (pairs of points) into wire on the mesh: each end becomes a vertex (an existing one within
|
|
45
|
+
* `tolerance`, a new one on the surface when it is near it, otherwise a free point in the air), each segment a line,
|
|
46
|
+
* and two ends on one face cut that face. Returns the new mesh and wire.
|
|
47
|
+
*/
|
|
48
|
+
export declare const wireFromSegments: (mesh: Mesh, wire: Wire, segments: [Vec3, Vec3][], tolerance: number) => {
|
|
49
|
+
mesh: Mesh;
|
|
50
|
+
wire: Wire;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Moves several vertices at once: `moves` is [[index, newPosition], ...]. A polygon whose vertices all moved by the
|
|
54
|
+
* same step (a face dragged as a whole) stays as it is; any other polygon that uses a moved vertex becomes triangles,
|
|
55
|
+
* so every face stays planar.
|
|
56
|
+
*/
|
|
57
|
+
export declare const moveVertexSet: (mesh: Mesh, moves: [number, Vec3][]) => Mesh;
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Drawing on a mesh: put a vertex on a surface, cut a face along a line between two vertices,
|
|
2
|
+
// move a vertex, close a loop of lines into a face. The wire is a list of [a, b] vertex pairs next to the mesh:
|
|
3
|
+
// its ends are ordinary mesh vertices, so moving a vertex moves every line (and every face) that uses it.
|
|
4
|
+
// Faces stay convex and planar: a vertex placed inside a face splits it into triangles, a cut splits a convex
|
|
5
|
+
// polygon into two convex ones, and a moved vertex turns the polygons it touches into triangles.
|
|
6
|
+
import { sub, add, scale, dot, len } from './vec.js';
|
|
7
|
+
import { faceNormal, bounds } from './mesh.js';
|
|
8
|
+
const sizeOf = (mesh) => bounds(mesh).radius || 1;
|
|
9
|
+
/** Distance from `p` to the segment a-b, and where along it (0..1). */
|
|
10
|
+
const toSegment = (p, a, b) => {
|
|
11
|
+
const ab = sub(b, a);
|
|
12
|
+
const l2 = dot(ab, ab) || 1;
|
|
13
|
+
const t = Math.max(0, Math.min(1, dot(sub(p, a), ab) / l2));
|
|
14
|
+
return { distance: len(sub(p, add(a, scale(ab, t)))), t };
|
|
15
|
+
};
|
|
16
|
+
const withFaces = (mesh, faces, positions = mesh.positions) => ({ ...mesh, positions, faces });
|
|
17
|
+
/**
|
|
18
|
+
* Puts a vertex at `point`, which lies on face `face`. On an existing vertex it returns that one; on an edge it is
|
|
19
|
+
* added to both faces that share the edge (no crack); inside the face it splits the face into triangles around it.
|
|
20
|
+
* { mesh, index }
|
|
21
|
+
*/
|
|
22
|
+
export const insertVertex = (mesh, face, point) => {
|
|
23
|
+
const eps = 1e-6 * sizeOf(mesh);
|
|
24
|
+
const f = mesh.faces[face];
|
|
25
|
+
const { positions } = mesh;
|
|
26
|
+
for (const v of f.v)
|
|
27
|
+
if (len(sub(positions[v], point)) <= eps * 10)
|
|
28
|
+
return { mesh, index: v };
|
|
29
|
+
const index = positions.length;
|
|
30
|
+
const next = [...positions, point];
|
|
31
|
+
const m = f.v.length;
|
|
32
|
+
for (let j = 0; j < m; j++) {
|
|
33
|
+
const a = f.v[j];
|
|
34
|
+
const b = f.v[(j + 1) % m];
|
|
35
|
+
if (toSegment(point, positions[a], positions[b]).distance > eps * 10)
|
|
36
|
+
continue;
|
|
37
|
+
// on the edge a-b: every face holding that edge (same positions, either direction) gets the new vertex
|
|
38
|
+
const faces = mesh.faces.map((g, gi) => {
|
|
39
|
+
const n = g.v.length;
|
|
40
|
+
for (let k = 0; k < n; k++) {
|
|
41
|
+
const p = positions[g.v[k]];
|
|
42
|
+
const q = positions[g.v[(k + 1) % n]];
|
|
43
|
+
const same = (len(sub(p, positions[a])) <= eps && len(sub(q, positions[b])) <= eps) || (len(sub(p, positions[b])) <= eps && len(sub(q, positions[a])) <= eps);
|
|
44
|
+
if (same)
|
|
45
|
+
return { ...g, v: [...g.v.slice(0, k + 1), index, ...g.v.slice(k + 1)] };
|
|
46
|
+
}
|
|
47
|
+
return gi === face ? g : g;
|
|
48
|
+
});
|
|
49
|
+
return { mesh: withFaces(mesh, faces, next), index };
|
|
50
|
+
}
|
|
51
|
+
// inside: a fan of triangles around the new vertex, same side as the face
|
|
52
|
+
const fan = f.v.map((v, j) => ({ ...f, v: [v, f.v[(j + 1) % m], index] }));
|
|
53
|
+
return { mesh: withFaces(mesh, [...mesh.faces.slice(0, face), ...fan, ...mesh.faces.slice(face + 1)], next), index };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Cuts the face that holds vertices `a` and `b` (not next to each other) along the line a-b: two convex faces.
|
|
57
|
+
* `cut` is false when no face holds both, or they are already neighbours on every face.
|
|
58
|
+
*/
|
|
59
|
+
export const cutFace = (mesh, a, b) => {
|
|
60
|
+
for (let fi = 0; fi < mesh.faces.length; fi++) {
|
|
61
|
+
const f = mesh.faces[fi];
|
|
62
|
+
const ia = f.v.indexOf(a);
|
|
63
|
+
const ib = f.v.indexOf(b);
|
|
64
|
+
if (ia < 0 || ib < 0)
|
|
65
|
+
continue;
|
|
66
|
+
const m = f.v.length;
|
|
67
|
+
if ((ia - ib + m) % m === 1 || (ib - ia + m) % m === 1)
|
|
68
|
+
continue;
|
|
69
|
+
const walk = (from, to) => {
|
|
70
|
+
const out = [f.v[from]];
|
|
71
|
+
for (let k = from; k !== to;) {
|
|
72
|
+
k = (k + 1) % m;
|
|
73
|
+
out.push(f.v[k]);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const faces = [...mesh.faces.slice(0, fi), { ...f, v: walk(ia, ib) }, { ...f, v: walk(ib, ia) }, ...mesh.faces.slice(fi + 1)];
|
|
78
|
+
return { mesh: withFaces(mesh, faces), cut: true };
|
|
79
|
+
}
|
|
80
|
+
return { mesh, cut: false };
|
|
81
|
+
};
|
|
82
|
+
/** Moves vertex `i` to `to`. Polygons that use it become triangles, so every face stays planar. */
|
|
83
|
+
export const moveVertex = (mesh, i, to) => {
|
|
84
|
+
const positions = mesh.positions.map((p, k) => (k === i ? to : p));
|
|
85
|
+
const faces = [];
|
|
86
|
+
for (const f of mesh.faces) {
|
|
87
|
+
if (f.v.length <= 3 || !f.v.includes(i))
|
|
88
|
+
faces.push(f);
|
|
89
|
+
else
|
|
90
|
+
for (let j = 1; j < f.v.length - 1; j++)
|
|
91
|
+
faces.push({ ...f, v: [f.v[0], f.v[j], f.v[j + 1]] });
|
|
92
|
+
}
|
|
93
|
+
return withFaces(mesh, faces, positions);
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* A face from a closed loop of vertices. It is wound to point away from `awayFrom` (default: the middle of the
|
|
97
|
+
* mesh), which is what a patch closing a gap in a surface wants; 4 or more vertices become a fan of triangles.
|
|
98
|
+
*/
|
|
99
|
+
export const fillLoop = (mesh, loop, face = {}, awayFrom = bounds(mesh).center) => {
|
|
100
|
+
if (loop.length < 3)
|
|
101
|
+
return mesh;
|
|
102
|
+
const pts = loop.map((v) => mesh.positions[v]);
|
|
103
|
+
const centre = scale(pts.reduce((s, p) => add(s, p), [0, 0, 0]), 1 / pts.length);
|
|
104
|
+
const ordered = dot(faceNormal(pts), sub(centre, awayFrom)) >= 0 ? loop : [...loop].reverse();
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let j = 1; j < ordered.length - 1; j++)
|
|
107
|
+
out.push({ ...face, v: [ordered[0], ordered[j], ordered[j + 1]] });
|
|
108
|
+
return withFaces(mesh, [...mesh.faces, ...out]);
|
|
109
|
+
};
|
|
110
|
+
/** Adds the line a-b (not twice, not a point). */
|
|
111
|
+
export const addLine = (wire, a, b) => (a === b || wire.some(([p, q]) => (p === a && q === b) || (p === b && q === a)) ? wire : [...wire, [a, b]]);
|
|
112
|
+
/**
|
|
113
|
+
* The shortest closed loop of lines through line [a, b], at most `max` vertices: [a, ..., b], or null.
|
|
114
|
+
* Close it with fillLoop.
|
|
115
|
+
*/
|
|
116
|
+
export const findLoop = (wire, [a, b], max = 8) => {
|
|
117
|
+
const near = new Map();
|
|
118
|
+
for (const [p, q] of wire) {
|
|
119
|
+
if ((p === a && q === b) || (p === b && q === a))
|
|
120
|
+
continue; // not through the line itself
|
|
121
|
+
near.set(p, [...(near.get(p) ?? []), q]);
|
|
122
|
+
near.set(q, [...(near.get(q) ?? []), p]);
|
|
123
|
+
}
|
|
124
|
+
const from = new Map([[a, -1]]);
|
|
125
|
+
const queue = [a];
|
|
126
|
+
for (let head = 0; head < queue.length; head++) {
|
|
127
|
+
const v = queue[head];
|
|
128
|
+
if (v === b)
|
|
129
|
+
break;
|
|
130
|
+
for (const w of near.get(v) ?? [])
|
|
131
|
+
if (!from.has(w))
|
|
132
|
+
(from.set(w, v), queue.push(w));
|
|
133
|
+
}
|
|
134
|
+
if (!from.has(b))
|
|
135
|
+
return null;
|
|
136
|
+
const path = [];
|
|
137
|
+
for (let v = b; v !== -1; v = from.get(v))
|
|
138
|
+
path.unshift(v);
|
|
139
|
+
return path.length <= max ? path : null;
|
|
140
|
+
};
|
|
141
|
+
/** The point of triangle a-b-c closest to p (Ericson, Real-Time Collision Detection). */
|
|
142
|
+
const closestOnTriangle = (p, a, b, c) => {
|
|
143
|
+
const ab = sub(b, a);
|
|
144
|
+
const ac = sub(c, a);
|
|
145
|
+
const ap = sub(p, a);
|
|
146
|
+
const d1 = dot(ab, ap);
|
|
147
|
+
const d2 = dot(ac, ap);
|
|
148
|
+
if (d1 <= 0 && d2 <= 0)
|
|
149
|
+
return a;
|
|
150
|
+
const bp = sub(p, b);
|
|
151
|
+
const d3 = dot(ab, bp);
|
|
152
|
+
const d4 = dot(ac, bp);
|
|
153
|
+
if (d3 >= 0 && d4 <= d3)
|
|
154
|
+
return b;
|
|
155
|
+
const vc = d1 * d4 - d3 * d2;
|
|
156
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0)
|
|
157
|
+
return add(a, scale(ab, d1 / (d1 - d3)));
|
|
158
|
+
const cp = sub(p, c);
|
|
159
|
+
const d5 = dot(ab, cp);
|
|
160
|
+
const d6 = dot(ac, cp);
|
|
161
|
+
if (d6 >= 0 && d5 <= d6)
|
|
162
|
+
return c;
|
|
163
|
+
const vb = d5 * d2 - d1 * d6;
|
|
164
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0)
|
|
165
|
+
return add(a, scale(ac, d2 / (d2 - d6)));
|
|
166
|
+
const va = d3 * d6 - d5 * d4;
|
|
167
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0)
|
|
168
|
+
return add(b, scale(sub(c, b), (d4 - d3) / (d4 - d3 + (d5 - d6))));
|
|
169
|
+
const denom = 1 / (va + vb + vc);
|
|
170
|
+
return add(a, add(scale(ab, vb * denom), scale(ac, vc * denom)));
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* The surface point of the mesh nearest to `p`, if it is within `tolerance`: { face, point } (point lies on the face),
|
|
174
|
+
* otherwise null. For placing a point that was drawn near a surface on the surface.
|
|
175
|
+
*/
|
|
176
|
+
export const nearestSurface = (mesh, p, tolerance) => {
|
|
177
|
+
let best = null;
|
|
178
|
+
mesh.faces.forEach((f, face) => {
|
|
179
|
+
const pts = f.v.map((v) => mesh.positions[v]);
|
|
180
|
+
for (let j = 1; j < pts.length - 1; j++) {
|
|
181
|
+
const q = closestOnTriangle(p, pts[0], pts[j], pts[j + 1]);
|
|
182
|
+
const d = len(sub(q, p));
|
|
183
|
+
if (d <= tolerance && (!best || d < best.d))
|
|
184
|
+
best = { face, point: q, d };
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return best ? { face: best.face, point: best.point } : null;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Turns line segments (pairs of points) into wire on the mesh: each end becomes a vertex (an existing one within
|
|
191
|
+
* `tolerance`, a new one on the surface when it is near it, otherwise a free point in the air), each segment a line,
|
|
192
|
+
* and two ends on one face cut that face. Returns the new mesh and wire.
|
|
193
|
+
*/
|
|
194
|
+
export const wireFromSegments = (mesh, wire, segments, tolerance) => {
|
|
195
|
+
let m = mesh;
|
|
196
|
+
let w = wire;
|
|
197
|
+
const vertexAt = (p) => {
|
|
198
|
+
const near = m.positions.findIndex((q) => len(sub(q, p)) <= tolerance);
|
|
199
|
+
if (near >= 0)
|
|
200
|
+
return near;
|
|
201
|
+
const s = nearestSurface(m, p, tolerance);
|
|
202
|
+
if (s) {
|
|
203
|
+
const r = insertVertex(m, s.face, s.point);
|
|
204
|
+
m = r.mesh;
|
|
205
|
+
return r.index;
|
|
206
|
+
}
|
|
207
|
+
m = { ...m, positions: [...m.positions, p] };
|
|
208
|
+
return m.positions.length - 1;
|
|
209
|
+
};
|
|
210
|
+
for (const [a, b] of segments) {
|
|
211
|
+
const ia = vertexAt(a);
|
|
212
|
+
const ib = vertexAt(b);
|
|
213
|
+
if (ia === ib)
|
|
214
|
+
continue;
|
|
215
|
+
w = addLine(w, ia, ib);
|
|
216
|
+
m = cutFace(m, ia, ib).mesh;
|
|
217
|
+
}
|
|
218
|
+
return { mesh: m, wire: w };
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Moves several vertices at once: `moves` is [[index, newPosition], ...]. A polygon whose vertices all moved by the
|
|
222
|
+
* same step (a face dragged as a whole) stays as it is; any other polygon that uses a moved vertex becomes triangles,
|
|
223
|
+
* so every face stays planar.
|
|
224
|
+
*/
|
|
225
|
+
export const moveVertexSet = (mesh, moves) => {
|
|
226
|
+
const to = new Map(moves);
|
|
227
|
+
const positions = mesh.positions.map((p, k) => to.get(k) ?? p);
|
|
228
|
+
const step = (v) => sub(positions[v], mesh.positions[v]);
|
|
229
|
+
const faces = [];
|
|
230
|
+
for (const f of mesh.faces) {
|
|
231
|
+
if (f.v.length <= 3 || !f.v.some((v) => to.has(v))) {
|
|
232
|
+
faces.push(f);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const first = step(f.v[0]);
|
|
236
|
+
const rigid = f.v.every((v) => len(sub(step(v), first)) < 1e-9 * (bounds(mesh).radius || 1));
|
|
237
|
+
if (rigid)
|
|
238
|
+
faces.push(f);
|
|
239
|
+
else
|
|
240
|
+
for (let j = 1; j < f.v.length - 1; j++)
|
|
241
|
+
faces.push({ ...f, v: [f.v[0], f.v[j], f.v[j + 1]] });
|
|
242
|
+
}
|
|
243
|
+
return withFaces(mesh, faces, positions);
|
|
244
|
+
};
|