3draft 26.11.3001 → 26.11.5001

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. **Smooth** (`N`) shades faces that meet at less than 40 degrees as one round surface (box corners stay sharp), and **Studio** (`K`) lights the model with a key, a fill and a rim light that follow the camera; both are on at the start (WebGPU view only). The palette's `soft` slider gives a material skin-like light: it wraps round the form, with a warm band where light turns to shade.
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.
@@ -22,21 +22,31 @@ It is one file, `app/index.html`, plus a dependency-free static server (`app/ser
22
22
  | --- | --- |
23
23
  | `w a s d r f` | fly: forward, left, back, right, up, down. Every press is a speed step; the opposite key slows down |
24
24
  | `Space` | stop moving (all speeds to zero) |
25
- | `T` | fly to the object under the cross. Over nothing, or when too close or inside a model: out to the nearest object |
25
+ | `T` | target the object under the cross (see Target mode); with no target, `T` over empty space targets the nearest object. `Tab` next target (the first one when none), `Esc` let go |
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
+ | `Q` | 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) |
40
+ | `N` | smooth shading on / off (the `Smooth` button; on at the start, WebGPU view only) |
41
+ | `K` | studio light (key, fill, rim; follows the camera) on / off (the `Studio` button; on at the start, WebGPU view only) |
37
42
  | `G`, `L` | SVG / WebGPU, turn the light |
38
43
  | `?` | show / hide these shortcuts under the view (hidden at the start; also the `Keys` button) |
39
44
 
45
+ ## Clear and example models
46
+
47
+ - **`Clear`** (footer) removes every object in the scene. It is one undo step: `U` brings them all back.
48
+ - **`+ 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.
49
+
40
50
  ## Moving an object: the gizmo (`H`)
41
51
 
42
52
  There is one gizmo at a time, and `H` cycles through three states:
@@ -47,6 +57,28 @@ There is one gizmo at a time, and `H` cycles through three states:
47
57
 
48
58
  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
59
 
60
+ ## Edit mode: drawing on a model (`Q`)
61
+
62
+ 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 `Q`. A red bar at the top says which object is being edited. Its vertices show as small grey crosses.
63
+
64
+ - **`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.
65
+ - **Two points on one face cut it.** The line becomes a real edge of the mesh: the surface gets detail exactly where you drew.
66
+ - **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.
67
+ - **`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.
68
+ - **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:
69
+ - `1` **move points:** hover a vertex (white cross), drag it.
70
+ - `2` **move lines:** hover an edge or a line (white), drag it; both ends move.
71
+ - `3` **move faces:** hover a face (white outline), drag it; all its vertices move together and the face stays flat.
72
+ - 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.
73
+ - `V` goes back to drawing (and drops the selection); `Esc` does the same.
74
+ - **`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.
75
+ - **`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.
76
+ - **`Esc`** stops the line you are drawing; then it leaves a move mode back to drawing; then it leaves edit mode (so does `Q`). The lines are only shown in edit mode.
77
+
78
+ 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`.
79
+
80
+ 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).
81
+
50
82
  ## Boolean operations (`O`)
51
83
 
52
84
  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,17 +93,26 @@ Press `O` over an object: it gets an orange box. Press `O` over another one (gre
61
93
 
62
94
  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
95
 
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.
96
+ 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
97
 
66
98
  ## Materials (`P`)
67
99
 
68
100
  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
101
 
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.
102
+ **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.
103
+
104
+ For the selected material there are five sliders (`metal`, `rough`, `alpha`, `soft`, 0 to 1, and `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
105
 
72
106
  ## Isolating, travelling
73
107
 
74
- `/` hides everything but the object under the cross and frames it. The camera pose from before is remembered and restored when you leave. `T` flies the camera to the object under the cross (nothing under it: out to the nearest object, which also gets you out of a model you are inside).
108
+ `/` hides everything but the object under the cross and frames it. The camera pose from before is remembered and restored when you leave.
109
+ ### Target mode
110
+
111
+ The HUD in the top left corner shows the object under the cross (name, distance, face count) and the target.
112
+
113
+ - `T` on an object **targets** it: the camera turns to face it and from then on moves on an imaginary sphere round it, always looking at its centre. `A`/`D` swing it sideways, `R`/`F` up and down (speed levels, like everywhere), `W`/`S` change the distance (`W` stops at the object's surface), and dragging the mouse turns it over the same sphere.
114
+ - `T` on another object re-targets; `T` on the target or over empty space brings the camera back onto the target. There is no travel: `W` flies there.
115
+ - `Tab` switches to the next object (`Shift+Tab`: previous). `Space` only sets the speed to zero. `Esc` lets go, and the camera then stays exactly where it is.
75
116
 
76
117
  ## Phone
77
118
 
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,22 @@ 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
+ | `soft` 0..1 | hard ... soft | skin-like light: it wraps round the form, and a warm scattered band shows where light turns to shade (WebGPU) |
60
+ | `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
61
 
61
62
  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
63
 
64
+ `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).
65
+
66
+ `smooth: true` (or a crease angle in degrees, default 40) shades by corner normals: faces that meet at a shallow angle share their normals, so a faceted surface looks round, and a 90 degree box corner stays sharp (`cornerNormals(mesh, degrees)` is the pure helper). `studio: true` replaces the single sun with three lights that follow the camera: key (upper left), fill (right, softer) and a rim light from behind that edges the shape (`studio: { fill, rim }` to tune). Both are off by default.
67
+
63
68
  ### Surfaces, picking, editing
64
69
 
65
70
  - `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
71
  - `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`.
72
+ - `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.
73
+ - **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 (`Q`, see [APP.md](APP.md)) uses.
68
74
  - **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
75
 
70
76
  ### TypeScript and `|>`
@@ -89,6 +95,60 @@ A single `app/index.html` plus a dependency-free static server. Person and AI wo
89
95
 
90
96
  **How to use it, all keys and the database layout: [APP.md](APP.md).**
91
97
 
98
+ ### Keys
99
+
100
+ **Moving and looking**
101
+
102
+ | Key | What it does |
103
+ |---|---|
104
+ | drag | turns the view (in target mode: moves the camera over the sphere round the target) |
105
+ | arrow keys | turn the view in steps |
106
+ | `W` / `S` | forward / back: speed levels, every press is one step more or less |
107
+ | `A` / `D` | left / right (speed levels) |
108
+ | `R` / `F` | up / down (speed levels) |
109
+ | `Space` | speed to zero (the target stays) |
110
+ | `/` | show only the model under the cross; again: the whole scene |
111
+
112
+ **Target mode**
113
+
114
+ | Key | What it does |
115
+ |---|---|
116
+ | `T` | target the object under the cross; with a target: another object re-targets, the target itself or empty space brings the camera back onto it; with no target over empty space: the nearest object |
117
+ | `Tab` / `Shift+Tab` | next / previous target (the first one when there is none) |
118
+ | `Esc` | let go; the camera stays where it is |
119
+ | `A D R F`, `W S`, drag | swing round the target, closer or farther (`W` stops at the surface) |
120
+
121
+ **Editing and objects**
122
+
123
+ | Key | What it does |
124
+ |---|---|
125
+ | `Q` | edit mode on / off |
126
+ | `V` | spider bar between two points; in edit mode: draw a line |
127
+ | `Enter` | edit mode: close a loop into a face |
128
+ | `B` | edit mode: turn the old `V` bars into lines |
129
+ | `1` / `2` / `3` | edit mode: move points / lines / faces |
130
+ | `M` | put a prism down; in isolated mode: shoot it (it carves) |
131
+ | `O` | boolean: mark two objects, then pick the operation |
132
+ | `X` | delete the object under the cross |
133
+ | `H` | move gizmo: 1st world axes, 2nd own axes, 3rd off |
134
+ | `U` / `Backspace` | undo |
135
+
136
+ **Material and look**
137
+
138
+ | Key | What it does |
139
+ |---|---|
140
+ | `P` | material palette (Shift+click paints a face, Shift+double click the whole object) |
141
+ | `I` | pick the material under the cross |
142
+ | `G` | SVG / WebGPU view |
143
+ | `C` | filmic look |
144
+ | `N` | smooth shading |
145
+ | `K` | studio light |
146
+ | `L` | turn the light |
147
+ | `E` | edges and points on / off |
148
+ | `?` | show / hide the key hints |
149
+
150
+ In edit mode `Esc` first stops the line being drawn, then leaves a move mode, then leaves edit mode (and keeps the target).
151
+
92
152
  ---
93
153
 
94
154
  ## 3. Engine and LLM
@@ -103,7 +163,7 @@ The engine is small enough to read whole (about 2000 lines) and everything it wo
103
163
 
104
164
  Y is up, the ground is y = 0, faces are counter-clockwise seen from outside.
105
165
 
106
- Examples: `examples/table.js`, `examples/bridge.js` (50 named objects), `examples/cessna.js` (one mesh, 9 colours), and `examples/house.js` (a house converted from Blender with `tools/blend/`, 45 objects, approximate colours).
166
+ Examples: `examples/table.js`, `examples/bridge.js` (50 named objects), `examples/cessna.js` (one mesh, 9 colours), and `examples/head.js` (a stylized head: 7 objects with their own palette, soft skin; try N and K), and `examples/house.js` (a house converted from Blender with `tools/blend/`, 45 objects, approximate colours).
107
167
 
108
168
  ---
109
169
 
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,28 @@ 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
+ };
42
+ /**
43
+ * Smooth shading: faces that meet at an angle smaller than this (degrees; `true` = 40) share their normals, so a curved
44
+ * surface made of flat faces looks round and sharper corners stay crisp. Off by default (every face flat).
45
+ */
46
+ smooth?: boolean | number;
47
+ /**
48
+ * Studio light: three lights that follow the camera instead of one fixed sun: a key light (upper left), a fill light
49
+ * (right, softer) and a rim light from behind that edges the shape. Off by default.
50
+ */
51
+ studio?: boolean | {
52
+ fill?: number;
53
+ rim?: number;
54
+ };
33
55
  }
34
56
  /** One copy of a prototype: its position, orthonormal frame and color. */
35
57
  export interface Instance {
@@ -55,8 +77,19 @@ export interface Gpu {
55
77
  * like camera.project(): the same pixels, plus a depth between near and far.
56
78
  */
57
79
  export declare const viewProjection: (cam: Camera, near?: number, far?: number) => Float32Array;
58
- /** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
59
- export declare const buildVertices: (mesh: Mesh, palette?: Palette) => Float32Array;
80
+ /**
81
+ * Smooth shading: a normal for every corner of every face. The faces that meet at a point (welded by position, so a
82
+ * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
83
+ * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
84
+ */
85
+ export declare const cornerNormals: (mesh: Mesh, crease: number) => Vec3[][];
86
+ /**
87
+ * Triangle list for the GPU: face normals (flat, or smoothed with `smooth` degrees), face colors and materials.
88
+ * Float32Array, 14 floats per vertex.
89
+ */
90
+ export declare const buildVertices: (mesh: Mesh, palette?: Palette, { smooth }?: {
91
+ smooth?: number;
92
+ }) => Float32Array;
60
93
  /**
61
94
  * Per-instance data for drawInstances: one entry { at, axes: [ex, ey, ez], color } per copy of a
62
95
  * prototype mesh (modelled around the origin). Float32Array, 15 floats per instance.
@@ -71,7 +104,8 @@ export declare const defaultMaterial: Required<GpuMaterial>;
71
104
  * prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
72
105
  * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
73
106
  */
74
- export declare const createGpu: (canvas: HTMLCanvasElement, { sampleCount }?: {
107
+ export declare const createGpu: (canvas: HTMLCanvasElement, { sampleCount, onError }?: {
75
108
  sampleCount?: number;
109
+ onError?: (message: string) => void;
76
110
  }) => Promise<Gpu>;
77
111
  export {};