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 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
- | `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 |
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). 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.
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
- 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.
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
  ![table sheet](examples/out/table.sheet.svg)
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-coverage transparency and instancing.
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 | 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 |
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; on messy data (coincident faces) it can still come out open, so check `validate`.
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) => Mesh;
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
- const n = faceNormal(v.map((k) => positions[k]));
149
- return len(n) < 1e-9 * scale * scale ? [] : [{ v, color: p.color, mat: p.mat }];
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
- closedSolid('a', a);
201
- closedSolid('b', b);
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: { module, entryPoint: 'fs', targets: [{ format }] },
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
- depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
180
- // the fragment alpha becomes coverage: a half transparent face covers half of the samples (one pass, no sorting)
181
- multisample: sampleCount > 1 ? { count: sampleCount, alphaToCoverageEnabled: true } : { count: sampleCount },
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: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
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 verts = buildVertices(mesh, palette);
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 u = new Float32Array(36);
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.3001";
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.3001';
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';
@@ -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. Drawn with alpha-to-coverage: one pass, no sorting. */
9
+ /** 1 = solid, 0 = invisible. Blended over what is behind it (WebGPU: a second pass, far to near). */
10
10
  alpha?: number;
11
- /** 0..1: how much the color glows on its own, unlit. */
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 clamp01 = (n, fallback) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : fallback);
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: clamp01(m?.metal, DEFAULTS.metal),
13
- rough: clamp01(m?.rough, DEFAULTS.rough),
14
- alpha: clamp01(m?.alpha, DEFAULTS.alpha),
15
- emit: clamp01(m?.emit, DEFAULTS.emit),
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3draft",
3
- "version": "26.11.3001",
3
+ "version": "26.11.4010",
4
4
  "description": "Minimalist AI first 3D engine: palette-texture, WASM core, WebGPU/WebGL2 renderer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",