pattapatta 0.4.1 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c822954: Add `halftone` module: convert image tone (RGBA) to exclusive pen-plotter mark modes — angled lines or lattice circles (with optional overlapping circles for denser midtones). Stroke-only SVG via `toSvg`.
8
+
9
+ ## 0.4.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 1bb9bdd: Add `llms.txt` / `llms-full.txt` (and generated module pages) for AI consumers, keep them in sync with the docs site, and align sidebar chrome with the main content background.
14
+
3
15
  ## 0.4.1
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/MadOrkestra)
4
4
  [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?logo=buymeacoffee&logoColor=black)](https://www.buymeacoffee.com/madorkestra)
5
5
 
6
- Pen-plotter geometry library (inspired by [PGS](https://github.com/micycle1/PGS)): hatching, circle packing, triangulation / meshing, boolean path ops, Voronoi, tilings, and SVG I/O for Node and the browser.
6
+ Pen-plotter geometry library (inspired by [PGS](https://github.com/micycle1/PGS)): hatching, image halftone (lines or circles), circle packing, triangulation / meshing, boolean path ops, Voronoi, tilings, and SVG I/O for Node and the browser.
7
7
 
8
8
  **Stroke marks only** — solid area fills are not the goal.
9
9
 
@@ -40,13 +40,16 @@ pnpm dlx pattapatta svg:roundtrip in.svg -o out.svg
40
40
 
41
41
  Live: [pattapatta.madorkestra.com](https://pattapatta.madorkestra.com) (GitHub Pages; deploys on push to `main`).
42
42
 
43
+ For AI agents: [llms.txt](https://pattapatta.madorkestra.com/llms.txt) (index) and [llms-full.txt](https://pattapatta.madorkestra.com/llms-full.txt) (full API dump). Explainer: [/llms-txt](https://pattapatta.madorkestra.com/llms-txt).
44
+
43
45
  Local documentation PWA (SvelteKit + shadcn):
44
46
 
45
47
  ```bash
46
48
  pnpm install
47
49
  pnpm docs:dev
48
50
  pnpm docs:build
49
- pnpm docs:examples # regenerate SVG figures
51
+ pnpm docs:examples # regenerate SVG figures (+ llms.txt)
52
+ pnpm docs:llms # regenerate llms.txt / llms-full.txt only
50
53
  ```
51
54
 
52
55
  ## Modules
@@ -56,6 +59,7 @@ pnpm docs:examples # regenerate SVG figures
56
59
  | `pattapatta` | Types, SVG, all facades |
57
60
  | `pattapatta/shapeBoolean` | Union / intersect / subtract / occlusion |
58
61
  | `pattapatta/hatch` | Parallel / cross hatch fills |
62
+ | `pattapatta/halftone` | Image tone → lines **or** circles |
59
63
  | `pattapatta/circlePacking` | Lattices (`overlap` \| `contained`), LEC, stochastic |
60
64
  | `pattapatta/triangulation` | Earcut, Delaunay, Poisson Steiner, refine |
61
65
  | `pattapatta/morphology` | Buffer, simplify, warps |
@@ -0,0 +1,109 @@
1
+ import { G as Group } from '../group-YVmSJuGo.js';
2
+ import '../path-Boeshk9L.js';
3
+
4
+ /** Row-major darkness field: `1` = darkest (most ink), `0` = lightest. */
5
+ type ToneField = {
6
+ width: number;
7
+ height: number;
8
+ data: Float32Array;
9
+ };
10
+ type FromRgbaOptions = {
11
+ /**
12
+ * When true, light pixels become high darkness (ink).
13
+ * Default `false`: dark pixels → high darkness.
14
+ */
15
+ invert?: boolean;
16
+ };
17
+ /**
18
+ * Build a tone field from packed RGBA (or RGBX) bytes, 4 bytes per pixel.
19
+ */
20
+ declare function fromRgba(data: Uint8Array | Uint8ClampedArray, width: number, height: number, opts?: FromRgbaOptions): ToneField;
21
+ /** Build a tone field from row-major luminance floats in `[0,1]` (1 = white). */
22
+ declare function fromLuminance(data: ArrayLike<number>, width: number, height: number, opts?: FromRgbaOptions): ToneField;
23
+ /** Nearest-neighbor sample (integer coords, clamped). */
24
+ declare function sampleNearest(field: ToneField, x: number, y: number): number;
25
+ /** Bilinear sample in image coordinates; outside the field returns `0`. */
26
+ declare function sample(field: ToneField, x: number, y: number): number;
27
+
28
+ type HalftoneLinesOptions = {
29
+ /** Line direction in radians. Default `Math.PI / 4`. */
30
+ angle?: number;
31
+ /** Perpendicular pitch between scanlines (image units). Default `2`. */
32
+ spacing?: number;
33
+ /** Sample step along each scanline (image units). Default `1`. */
34
+ step?: number;
35
+ /**
36
+ * Multi-threshold overlays (darker regions get more ink). Default `1`.
37
+ * Each level uses threshold `i / (levels + 1)` for `i = 1..levels`.
38
+ */
39
+ levels?: number;
40
+ /** Ignore samples lighter than this darkness. Default `0`. */
41
+ minDarkness?: number;
42
+ /** Output scale (image unit → SVG unit). Default `1`. */
43
+ scale?: number;
44
+ };
45
+ /**
46
+ * Angled scanline hatching modulated by tone.
47
+ * Darker regions produce more / longer stroked segments.
48
+ */
49
+ declare function lines(field: ToneField, opts?: HalftoneLinesOptions): Group;
50
+
51
+ type HalftoneCirclesOptions = {
52
+ /**
53
+ * Lattice pitch in image units. Default `4` when `overlap` is false.
54
+ * When `overlap` is true, pitch is derived from `maxRadius` and `overlapAmount`
55
+ * (this value is ignored).
56
+ */
57
+ cell?: number;
58
+ /** Lattice geometry. Default `'square'`. */
59
+ lattice?: 'square' | 'hex';
60
+ /**
61
+ * Minimum drawn radius (image units). Sites mapping below this are skipped.
62
+ * Default `0.3`.
63
+ */
64
+ minRadius?: number;
65
+ /**
66
+ * Radius at full darkness (image units).
67
+ * Default: `cell * 0.45` when `overlap` is false; `8` when `overlap` is true.
68
+ */
69
+ maxRadius?: number;
70
+ /**
71
+ * When true, lattice pitch is derived so max-radius neighbors overlap.
72
+ * Default `false`.
73
+ */
74
+ overlap?: boolean;
75
+ /**
76
+ * Fraction of max diameter that max-radius neighbors share along the lattice
77
+ * neighbor vector. In `(0, 1]`. Default `0.35` when `overlap` is true.
78
+ * Ignored when `overlap` is false.
79
+ */
80
+ overlapAmount?: number;
81
+ /**
82
+ * Radius curve: `r = maxRadius * darkness^gamma`, then skip if `r < minRadius`.
83
+ * Default `1`.
84
+ */
85
+ gamma?: number;
86
+ /** N-gon segments for each stroked circle. Default `24`. */
87
+ segments?: number;
88
+ /** Output scale (image unit → SVG unit). Default `1`. */
89
+ scale?: number;
90
+ };
91
+ /**
92
+ * Tone-scaled circle lattice (square or hex).
93
+ * Darker sites produce larger stroked circles; light sites below `minRadius` are skipped.
94
+ * With `overlap: true`, pitch is tightened so max-radius neighbors cross
95
+ * (single-pen optical density from stroke crossings).
96
+ */
97
+ declare function circles(field: ToneField, opts?: HalftoneCirclesOptions): Group;
98
+
99
+ /** Exclusive mark modes: pick `lines` or `circles` (not both in one pass). */
100
+ declare const halftone: {
101
+ fromRgba: typeof fromRgba;
102
+ fromLuminance: typeof fromLuminance;
103
+ sample: typeof sample;
104
+ sampleNearest: typeof sampleNearest;
105
+ lines: typeof lines;
106
+ circles: typeof circles;
107
+ };
108
+
109
+ export { type FromRgbaOptions, type HalftoneCirclesOptions, type HalftoneLinesOptions, type ToneField, circles, fromLuminance, fromRgba, halftone, lines, sample, sampleNearest };
@@ -0,0 +1,246 @@
1
+ // src/halftone/toneField.ts
2
+ var LUMA_R = 0.2126;
3
+ var LUMA_G = 0.7152;
4
+ var LUMA_B = 0.0722;
5
+ function fromRgba(data, width, height, opts = {}) {
6
+ const w = Math.floor(width);
7
+ const h = Math.floor(height);
8
+ if (w < 1 || h < 1) {
9
+ throw new Error("fromRgba: width and height must be \u2265 1");
10
+ }
11
+ const expected = w * h * 4;
12
+ if (data.length < expected) {
13
+ throw new Error(
14
+ `fromRgba: expected at least ${expected} bytes for ${w}\xD7${h} RGBA, got ${data.length}`
15
+ );
16
+ }
17
+ const invert = opts.invert === true;
18
+ const out = new Float32Array(w * h);
19
+ for (let i = 0; i < w * h; i++) {
20
+ const o = i * 4;
21
+ const r = data[o];
22
+ const g = data[o + 1];
23
+ const b = data[o + 2];
24
+ const a = data[o + 3] / 255;
25
+ const luma = (LUMA_R * r + LUMA_G * g + LUMA_B * b) / 255;
26
+ let darkness = (1 - luma) * a;
27
+ if (invert) darkness = (1 - darkness) * a;
28
+ out[i] = darkness;
29
+ }
30
+ return { width: w, height: h, data: out };
31
+ }
32
+ function fromLuminance(data, width, height, opts = {}) {
33
+ const w = Math.floor(width);
34
+ const h = Math.floor(height);
35
+ if (w < 1 || h < 1) {
36
+ throw new Error("fromLuminance: width and height must be \u2265 1");
37
+ }
38
+ if (data.length < w * h) {
39
+ throw new Error(
40
+ `fromLuminance: expected at least ${w * h} samples, got ${data.length}`
41
+ );
42
+ }
43
+ const invert = opts.invert === true;
44
+ const out = new Float32Array(w * h);
45
+ for (let i = 0; i < w * h; i++) {
46
+ const luma = clamp01(data[i]);
47
+ out[i] = invert ? luma : 1 - luma;
48
+ }
49
+ return { width: w, height: h, data: out };
50
+ }
51
+ function sampleNearest(field, x, y) {
52
+ const ix = clampInt(Math.round(x), 0, field.width - 1);
53
+ const iy = clampInt(Math.round(y), 0, field.height - 1);
54
+ return field.data[iy * field.width + ix];
55
+ }
56
+ function sample(field, x, y) {
57
+ const { width: w, height: h, data } = field;
58
+ if (x < 0 || y < 0 || x > w - 1 || y > h - 1) return 0;
59
+ const x0 = Math.floor(x);
60
+ const y0 = Math.floor(y);
61
+ const x1 = Math.min(x0 + 1, w - 1);
62
+ const y1 = Math.min(y0 + 1, h - 1);
63
+ const tx = x - x0;
64
+ const ty = y - y0;
65
+ const v00 = data[y0 * w + x0];
66
+ const v10 = data[y0 * w + x1];
67
+ const v01 = data[y1 * w + x0];
68
+ const v11 = data[y1 * w + x1];
69
+ const a = v00 * (1 - tx) + v10 * tx;
70
+ const b = v01 * (1 - tx) + v11 * tx;
71
+ return a * (1 - ty) + b * ty;
72
+ }
73
+ function clamp01(v) {
74
+ return Math.max(0, Math.min(1, v));
75
+ }
76
+ function clampInt(v, lo, hi) {
77
+ return Math.max(lo, Math.min(hi, v | 0));
78
+ }
79
+
80
+ // src/types/vec2.ts
81
+ function cloneVec2(v) {
82
+ return { x: v.x, y: v.y };
83
+ }
84
+
85
+ // src/types/path.ts
86
+ function path(rings, closed = true) {
87
+ return {
88
+ rings: rings.map((ring) => ring.map(cloneVec2)),
89
+ closed
90
+ };
91
+ }
92
+ function polyline(points) {
93
+ return path([points], false);
94
+ }
95
+ function polygon(exterior, holes = []) {
96
+ return path([exterior, ...holes], true);
97
+ }
98
+ function clonePath(p) {
99
+ return {
100
+ closed: p.closed,
101
+ rings: p.rings.map((ring) => ring.map(cloneVec2))
102
+ };
103
+ }
104
+
105
+ // src/types/group.ts
106
+ function group(paths = []) {
107
+ return { paths: paths.map(clonePath) };
108
+ }
109
+
110
+ // src/halftone/lines.ts
111
+ function lines(field, opts = {}) {
112
+ const angle = opts.angle ?? Math.PI / 4;
113
+ const spacing = opts.spacing ?? 2;
114
+ const step = opts.step ?? 1;
115
+ const levels = Math.max(1, Math.floor(opts.levels ?? 1));
116
+ const minDarkness = opts.minDarkness ?? 0;
117
+ const scale = opts.scale ?? 1;
118
+ if (spacing <= 0 || step <= 0 || scale <= 0) return group([]);
119
+ const w = field.width;
120
+ const h = field.height;
121
+ const ux = Math.cos(angle);
122
+ const uy = Math.sin(angle);
123
+ const px = -uy;
124
+ const py = ux;
125
+ const corners = [
126
+ [0, 0],
127
+ [w - 1, 0],
128
+ [w - 1, h - 1],
129
+ [0, h - 1]
130
+ ];
131
+ let minP = Infinity;
132
+ let maxP = -Infinity;
133
+ let minA = Infinity;
134
+ let maxA = -Infinity;
135
+ for (const [cx, cy] of corners) {
136
+ const p = cx * px + cy * py;
137
+ const a = cx * ux + cy * uy;
138
+ if (p < minP) minP = p;
139
+ if (p > maxP) maxP = p;
140
+ if (a < minA) minA = a;
141
+ if (a > maxA) maxA = a;
142
+ }
143
+ const paths = [];
144
+ const pad = step;
145
+ for (let level = 1; level <= levels; level++) {
146
+ const threshold = Math.max(minDarkness, level / (levels + 1));
147
+ const phase = (level - 1) / levels * spacing * 0.5;
148
+ for (let p = minP + phase; p <= maxP + 1e-9; p += spacing) {
149
+ let run = [];
150
+ const flush = () => {
151
+ if (run.length >= 2) {
152
+ paths.push(polyline(run));
153
+ }
154
+ run = [];
155
+ };
156
+ for (let a = minA - pad; a <= maxA + pad + 1e-9; a += step) {
157
+ const x = px * p + ux * a;
158
+ const y = py * p + uy * a;
159
+ if (x < 0 || y < 0 || x > w - 1 || y > h - 1) {
160
+ flush();
161
+ continue;
162
+ }
163
+ const d = sample(field, x, y);
164
+ if (d >= threshold) {
165
+ run.push({ x: x * scale, y: y * scale });
166
+ } else {
167
+ flush();
168
+ }
169
+ }
170
+ flush();
171
+ }
172
+ }
173
+ return group(paths);
174
+ }
175
+
176
+ // src/construction/index.ts
177
+ function createCircle(cx, cy, radius, segments = 64) {
178
+ return createRegularPolygon(cx, cy, radius, Math.max(3, Math.floor(segments)));
179
+ }
180
+ function createRegularPolygon(cx, cy, radius, sides, rotation = -Math.PI / 2) {
181
+ const n = Math.max(3, Math.floor(sides));
182
+ const ring = [];
183
+ for (let i = 0; i < n; i++) {
184
+ const t = rotation + i / n * Math.PI * 2;
185
+ ring.push({ x: cx + Math.cos(t) * radius, y: cy + Math.sin(t) * radius });
186
+ }
187
+ return polygon(ring);
188
+ }
189
+
190
+ // src/halftone/circles.ts
191
+ function circles(field, opts = {}) {
192
+ const lattice = opts.lattice ?? "square";
193
+ const overlap = opts.overlap === true;
194
+ const minRadius = opts.minRadius ?? 0.3;
195
+ const gamma = opts.gamma ?? 1;
196
+ const segments = Math.max(3, Math.floor(opts.segments ?? 24));
197
+ const scale = opts.scale ?? 1;
198
+ let cell;
199
+ let maxRadius;
200
+ if (overlap) {
201
+ maxRadius = opts.maxRadius ?? 8;
202
+ const amount = clampOverlapAmount(opts.overlapAmount ?? 0.35);
203
+ cell = 2 * maxRadius * (1 - amount);
204
+ } else {
205
+ cell = opts.cell ?? 4;
206
+ maxRadius = opts.maxRadius ?? cell * 0.45;
207
+ }
208
+ if (cell <= 0 || maxRadius <= 0 || minRadius < 0 || maxRadius < minRadius || scale <= 0) {
209
+ return group([]);
210
+ }
211
+ const w = field.width;
212
+ const h = field.height;
213
+ const paths = [];
214
+ const rowPitch = lattice === "hex" ? cell * Math.sqrt(3) * 0.5 : cell;
215
+ for (let row = 0, y = cell * 0.5; y < h; row++, y += rowPitch) {
216
+ const xOff = lattice === "hex" && row % 2 === 1 ? cell * 0.5 : 0;
217
+ for (let x = cell * 0.5 + xOff; x < w; x += cell) {
218
+ const darkness = sample(field, x, y);
219
+ if (darkness <= 0) continue;
220
+ const r = maxRadius * darkness ** gamma;
221
+ if (r < minRadius) continue;
222
+ paths.push(
223
+ createCircle(x * scale, y * scale, r * scale, segments)
224
+ );
225
+ }
226
+ }
227
+ return group(paths);
228
+ }
229
+ function clampOverlapAmount(amount) {
230
+ if (!(amount > 0) || amount > 1) return 0.35;
231
+ return amount;
232
+ }
233
+
234
+ // src/halftone/index.ts
235
+ var halftone = {
236
+ fromRgba,
237
+ fromLuminance,
238
+ sample,
239
+ sampleNearest,
240
+ lines,
241
+ circles
242
+ };
243
+
244
+ export { circles, fromLuminance, fromRgba, halftone, lines, sample, sampleNearest };
245
+ //# sourceMappingURL=index.js.map
246
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/halftone/toneField.ts","../../src/types/vec2.ts","../../src/types/path.ts","../../src/types/group.ts","../../src/halftone/lines.ts","../../src/construction/index.ts","../../src/halftone/circles.ts","../../src/halftone/index.ts"],"names":[],"mappings":";AAgBA,IAAM,MAAA,GAAS,MAAA;AACf,IAAM,MAAA,GAAS,MAAA;AACf,IAAM,MAAA,GAAS,MAAA;AAKR,SAAS,SACd,IAAA,EACA,KAAA,EACA,MAAA,EACA,IAAA,GAAwB,EAAC,EACd;AACX,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,KAAK,CAAA;AAC1B,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA;AAC3B,EAAA,IAAI,CAAA,GAAI,CAAA,IAAK,CAAA,GAAI,CAAA,EAAG;AAClB,IAAA,MAAM,IAAI,MAAM,6CAAwC,CAAA;AAAA,EAC1D;AACA,EAAA,MAAM,QAAA,GAAW,IAAI,CAAA,GAAI,CAAA;AACzB,EAAA,IAAI,IAAA,CAAK,SAAS,QAAA,EAAU;AAC1B,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,4BAAA,EAA+B,QAAQ,CAAA,WAAA,EAAc,CAAC,OAAI,CAAC,CAAA,WAAA,EAAc,KAAK,MAAM,CAAA;AAAA,KACtF;AAAA,EACF;AAEA,EAAA,MAAM,MAAA,GAAS,KAAK,MAAA,KAAW,IAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,IAAI,YAAA,CAAa,CAAA,GAAI,CAAC,CAAA;AAClC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,GAAI,GAAG,CAAA,EAAA,EAAK;AAC9B,IAAA,MAAM,IAAI,CAAA,GAAI,CAAA;AACd,IAAA,MAAM,CAAA,GAAI,KAAK,CAAC,CAAA;AAChB,IAAA,MAAM,CAAA,GAAI,IAAA,CAAK,CAAA,GAAI,CAAC,CAAA;AACpB,IAAA,MAAM,CAAA,GAAI,IAAA,CAAK,CAAA,GAAI,CAAC,CAAA;AACpB,IAAA,MAAM,CAAA,GAAI,IAAA,CAAK,CAAA,GAAI,CAAC,CAAA,GAAK,GAAA;AACzB,IAAA,MAAM,QAAQ,MAAA,GAAS,CAAA,GAAI,MAAA,GAAS,CAAA,GAAI,SAAS,CAAA,IAAK,GAAA;AAEtD,IAAA,IAAI,QAAA,GAAA,CAAY,IAAI,IAAA,IAAQ,CAAA;AAC5B,IAAA,IAAI,MAAA,EAAQ,QAAA,GAAA,CAAY,CAAA,GAAI,QAAA,IAAY,CAAA;AACxC,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,QAAA;AAAA,EACX;AACA,EAAA,OAAO,EAAE,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,CAAA,EAAG,MAAM,GAAA,EAAI;AAC1C;AAGO,SAAS,cACd,IAAA,EACA,KAAA,EACA,MAAA,EACA,IAAA,GAAwB,EAAC,EACd;AACX,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,KAAK,CAAA;AAC1B,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA;AAC3B,EAAA,IAAI,CAAA,GAAI,CAAA,IAAK,CAAA,GAAI,CAAA,EAAG;AAClB,IAAA,MAAM,IAAI,MAAM,kDAA6C,CAAA;AAAA,EAC/D;AACA,EAAA,IAAI,IAAA,CAAK,MAAA,GAAS,CAAA,GAAI,CAAA,EAAG;AACvB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,iCAAA,EAAoC,CAAA,GAAI,CAAC,CAAA,cAAA,EAAiB,KAAK,MAAM,CAAA;AAAA,KACvE;AAAA,EACF;AACA,EAAA,MAAM,MAAA,GAAS,KAAK,MAAA,KAAW,IAAA;AAC/B,EAAA,MAAM,GAAA,GAAM,IAAI,YAAA,CAAa,CAAA,GAAI,CAAC,CAAA;AAClC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,GAAI,GAAG,CAAA,EAAA,EAAK;AAC9B,IAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAE,CAAA;AAC7B,IAAA,GAAA,CAAI,CAAC,CAAA,GAAI,MAAA,GAAS,IAAA,GAAO,CAAA,GAAI,IAAA;AAAA,EAC/B;AACA,EAAA,OAAO,EAAE,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,CAAA,EAAG,MAAM,GAAA,EAAI;AAC1C;AAGO,SAAS,aAAA,CAAc,KAAA,EAAkB,CAAA,EAAW,CAAA,EAAmB;AAC5E,EAAA,MAAM,EAAA,GAAK,SAAS,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA,EAAG,CAAA,EAAG,KAAA,CAAM,KAAA,GAAQ,CAAC,CAAA;AACrD,EAAA,MAAM,EAAA,GAAK,SAAS,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA,EAAG,CAAA,EAAG,KAAA,CAAM,MAAA,GAAS,CAAC,CAAA;AACtD,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,EAAA,GAAK,KAAA,CAAM,QAAQ,EAAE,CAAA;AACzC;AAGO,SAAS,MAAA,CAAO,KAAA,EAAkB,CAAA,EAAW,CAAA,EAAmB;AACrE,EAAA,MAAM,EAAE,KAAA,EAAO,CAAA,EAAG,MAAA,EAAQ,CAAA,EAAG,MAAK,GAAI,KAAA;AACtC,EAAA,IAAI,CAAA,GAAI,CAAA,IAAK,CAAA,GAAI,CAAA,IAAK,CAAA,GAAI,IAAI,CAAA,IAAK,CAAA,GAAI,CAAA,GAAI,CAAA,EAAG,OAAO,CAAA;AACrD,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA;AACvB,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA;AACvB,EAAA,MAAM,KAAK,IAAA,CAAK,GAAA,CAAI,EAAA,GAAK,CAAA,EAAG,IAAI,CAAC,CAAA;AACjC,EAAA,MAAM,KAAK,IAAA,CAAK,GAAA,CAAI,EAAA,GAAK,CAAA,EAAG,IAAI,CAAC,CAAA;AACjC,EAAA,MAAM,KAAK,CAAA,GAAI,EAAA;AACf,EAAA,MAAM,KAAK,CAAA,GAAI,EAAA;AACf,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,EAAA,GAAK,CAAA,GAAI,EAAE,CAAA;AAC5B,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,EAAA,GAAK,CAAA,GAAI,EAAE,CAAA;AAC5B,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,EAAA,GAAK,CAAA,GAAI,EAAE,CAAA;AAC5B,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,EAAA,GAAK,CAAA,GAAI,EAAE,CAAA;AAC5B,EAAA,MAAM,CAAA,GAAI,GAAA,IAAO,CAAA,GAAI,EAAA,CAAA,GAAM,GAAA,GAAM,EAAA;AACjC,EAAA,MAAM,CAAA,GAAI,GAAA,IAAO,CAAA,GAAI,EAAA,CAAA,GAAM,GAAA,GAAM,EAAA;AACjC,EAAA,OAAO,CAAA,IAAK,CAAA,GAAI,EAAA,CAAA,GAAM,CAAA,GAAI,EAAA;AAC5B;AAEA,SAAS,QAAQ,CAAA,EAAmB;AAClC,EAAA,OAAO,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,CAAA,EAAG,CAAC,CAAC,CAAA;AACnC;AAEA,SAAS,QAAA,CAAS,CAAA,EAAW,EAAA,EAAY,EAAA,EAAoB;AAC3D,EAAA,OAAO,IAAA,CAAK,IAAI,EAAA,EAAI,IAAA,CAAK,IAAI,EAAA,EAAI,CAAA,GAAI,CAAC,CAAC,CAAA;AACzC;;;AC1GO,SAAS,UAAU,CAAA,EAAe;AACvC,EAAA,OAAO,EAAE,CAAA,EAAG,CAAA,CAAE,CAAA,EAAG,CAAA,EAAG,EAAE,CAAA,EAAE;AAC1B;;;ACIO,SAAS,IAAA,CAAK,KAAA,EAAe,MAAA,GAAS,IAAA,EAAY;AACvD,EAAA,OAAO;AAAA,IACL,KAAA,EAAO,MAAM,GAAA,CAAI,CAAC,SAAS,IAAA,CAAK,GAAA,CAAI,SAAS,CAAC,CAAA;AAAA,IAC9C;AAAA,GACF;AACF;AAEO,SAAS,SAAS,MAAA,EAAoB;AAC3C,EAAA,OAAO,IAAA,CAAK,CAAC,MAAM,CAAA,EAAG,KAAK,CAAA;AAC7B;AAEO,SAAS,OAAA,CAAQ,QAAA,EAAgB,KAAA,GAAgB,EAAC,EAAS;AAChE,EAAA,OAAO,KAAK,CAAC,QAAA,EAAU,GAAG,KAAK,GAAG,IAAI,CAAA;AACxC;AAaO,SAAS,UAAU,CAAA,EAAe;AACvC,EAAA,OAAO;AAAA,IACL,QAAQ,CAAA,CAAE,MAAA;AAAA,IACV,KAAA,EAAO,EAAE,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS,IAAA,CAAK,GAAA,CAAI,SAAS,CAAC;AAAA,GAClD;AACF;;;ACrCO,SAAS,KAAA,CAAM,KAAA,GAAgB,EAAC,EAAU;AAC/C,EAAA,OAAO,EAAE,KAAA,EAAO,KAAA,CAAM,GAAA,CAAI,SAAS,CAAA,EAAE;AACvC;;;ACcO,SAAS,KAAA,CACd,KAAA,EACA,IAAA,GAA6B,EAAC,EACvB;AACP,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,IAAS,IAAA,CAAK,EAAA,GAAK,CAAA;AACtC,EAAA,MAAM,OAAA,GAAU,KAAK,OAAA,IAAW,CAAA;AAChC,EAAA,MAAM,IAAA,GAAO,KAAK,IAAA,IAAQ,CAAA;AAC1B,EAAA,MAAM,MAAA,GAAS,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,KAAA,CAAM,IAAA,CAAK,MAAA,IAAU,CAAC,CAAC,CAAA;AACvD,EAAA,MAAM,WAAA,GAAc,KAAK,WAAA,IAAe,CAAA;AACxC,EAAA,MAAM,KAAA,GAAQ,KAAK,KAAA,IAAS,CAAA;AAE5B,EAAA,IAAI,OAAA,IAAW,KAAK,IAAA,IAAQ,CAAA,IAAK,SAAS,CAAA,EAAG,OAAO,KAAA,CAAM,EAAE,CAAA;AAE5D,EAAA,MAAM,IAAI,KAAA,CAAM,KAAA;AAChB,EAAA,MAAM,IAAI,KAAA,CAAM,MAAA;AAChB,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA;AACzB,EAAA,MAAM,EAAA,GAAK,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA;AACzB,EAAA,MAAM,KAAK,CAAC,EAAA;AACZ,EAAA,MAAM,EAAA,GAAK,EAAA;AAEX,EAAA,MAAM,OAAA,GAAmC;AAAA,IACvC,CAAC,GAAG,CAAC,CAAA;AAAA,IACL,CAAC,CAAA,GAAI,CAAA,EAAG,CAAC,CAAA;AAAA,IACT,CAAC,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAC,CAAA;AAAA,IACb,CAAC,CAAA,EAAG,CAAA,GAAI,CAAC;AAAA,GACX;AACA,EAAA,IAAI,IAAA,GAAO,QAAA;AACX,EAAA,IAAI,IAAA,GAAO,CAAA,QAAA;AACX,EAAA,IAAI,IAAA,GAAO,QAAA;AACX,EAAA,IAAI,IAAA,GAAO,CAAA,QAAA;AACX,EAAA,KAAA,MAAW,CAAC,EAAA,EAAI,EAAE,CAAA,IAAK,OAAA,EAAS;AAC9B,IAAA,MAAM,CAAA,GAAI,EAAA,GAAK,EAAA,GAAK,EAAA,GAAK,EAAA;AACzB,IAAA,MAAM,CAAA,GAAI,EAAA,GAAK,EAAA,GAAK,EAAA,GAAK,EAAA;AACzB,IAAA,IAAI,CAAA,GAAI,MAAM,IAAA,GAAO,CAAA;AACrB,IAAA,IAAI,CAAA,GAAI,MAAM,IAAA,GAAO,CAAA;AACrB,IAAA,IAAI,CAAA,GAAI,MAAM,IAAA,GAAO,CAAA;AACrB,IAAA,IAAI,CAAA,GAAI,MAAM,IAAA,GAAO,CAAA;AAAA,EACvB;AAEA,EAAA,MAAM,QAAgB,EAAC;AACvB,EAAA,MAAM,GAAA,GAAM,IAAA;AAEZ,EAAA,KAAA,IAAS,KAAA,GAAQ,CAAA,EAAG,KAAA,IAAS,MAAA,EAAQ,KAAA,EAAA,EAAS;AAC5C,IAAA,MAAM,YAAY,IAAA,CAAK,GAAA,CAAI,WAAA,EAAa,KAAA,IAAS,SAAS,CAAA,CAAE,CAAA;AAC5D,IAAA,MAAM,KAAA,GAAA,CAAU,KAAA,GAAQ,CAAA,IAAK,MAAA,GAAU,OAAA,GAAU,GAAA;AAEjD,IAAA,KAAA,IAAS,IAAI,IAAA,GAAO,KAAA,EAAO,KAAK,IAAA,GAAO,IAAA,EAAM,KAAK,OAAA,EAAS;AACzD,MAAA,IAAI,MAAc,EAAC;AAEnB,MAAA,MAAM,QAAQ,MAAM;AAClB,QAAA,IAAI,GAAA,CAAI,UAAU,CAAA,EAAG;AACnB,UAAA,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,GAAG,CAAC,CAAA;AAAA,QAC1B;AACA,QAAA,GAAA,GAAM,EAAC;AAAA,MACT,CAAA;AAEA,MAAA,KAAA,IAAS,CAAA,GAAI,OAAO,GAAA,EAAK,CAAA,IAAK,OAAO,GAAA,GAAM,IAAA,EAAM,KAAK,IAAA,EAAM;AAC1D,QAAA,MAAM,CAAA,GAAI,EAAA,GAAK,CAAA,GAAI,EAAA,GAAK,CAAA;AACxB,QAAA,MAAM,CAAA,GAAI,EAAA,GAAK,CAAA,GAAI,EAAA,GAAK,CAAA;AACxB,QAAA,IAAI,CAAA,GAAI,KAAK,CAAA,GAAI,CAAA,IAAK,IAAI,CAAA,GAAI,CAAA,IAAK,CAAA,GAAI,CAAA,GAAI,CAAA,EAAG;AAC5C,UAAA,KAAA,EAAM;AACN,UAAA;AAAA,QACF;AACA,QAAA,MAAM,CAAA,GAAI,MAAA,CAAO,KAAA,EAAO,CAAA,EAAG,CAAC,CAAA;AAC5B,QAAA,IAAI,KAAK,SAAA,EAAW;AAClB,UAAA,GAAA,CAAI,IAAA,CAAK,EAAE,CAAA,EAAG,CAAA,GAAI,OAAO,CAAA,EAAG,CAAA,GAAI,OAAO,CAAA;AAAA,QACzC,CAAA,MAAO;AACL,UAAA,KAAA,EAAM;AAAA,QACR;AAAA,MACF;AACA,MAAA,KAAA,EAAM;AAAA,IACR;AAAA,EACF;AAEA,EAAA,OAAO,MAAM,KAAK,CAAA;AACpB;;;AC7FO,SAAS,YAAA,CACd,EAAA,EACA,EAAA,EACA,MAAA,EACA,WAAW,EAAA,EACL;AACN,EAAA,OAAO,oBAAA,CAAqB,EAAA,EAAI,EAAA,EAAI,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,QAAQ,CAAC,CAAC,CAAA;AAC/E;AAgBO,SAAS,oBAAA,CACd,IACA,EAAA,EACA,MAAA,EACA,OACA,QAAA,GAAW,CAAC,IAAA,CAAK,EAAA,GAAK,CAAA,EAChB;AACN,EAAA,MAAM,IAAI,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,KAAA,CAAM,KAAK,CAAC,CAAA;AACvC,EAAA,MAAM,OAAe,EAAC;AACtB,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,CAAA,EAAG,CAAA,EAAA,EAAK;AAC1B,IAAA,MAAM,CAAA,GAAI,QAAA,GAAY,CAAA,GAAI,CAAA,GAAK,KAAK,EAAA,GAAK,CAAA;AACzC,IAAA,IAAA,CAAK,KAAK,EAAE,CAAA,EAAG,EAAA,GAAK,IAAA,CAAK,IAAI,CAAC,CAAA,GAAI,MAAA,EAAQ,CAAA,EAAG,KAAK,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,GAAI,QAAQ,CAAA;AAAA,EAC1E;AACA,EAAA,OAAO,QAAQ,IAAI,CAAA;AACrB;;;ACOO,SAAS,OAAA,CACd,KAAA,EACA,IAAA,GAA+B,EAAC,EACzB;AACP,EAAA,MAAM,OAAA,GAAU,KAAK,OAAA,IAAW,QAAA;AAChC,EAAA,MAAM,OAAA,GAAU,KAAK,OAAA,KAAY,IAAA;AACjC,EAAA,MAAM,SAAA,GAAY,KAAK,SAAA,IAAa,GAAA;AACpC,EAAA,MAAM,KAAA,GAAQ,KAAK,KAAA,IAAS,CAAA;AAC5B,EAAA,MAAM,QAAA,GAAW,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,KAAA,CAAM,IAAA,CAAK,QAAA,IAAY,EAAE,CAAC,CAAA;AAC5D,EAAA,MAAM,KAAA,GAAQ,KAAK,KAAA,IAAS,CAAA;AAE5B,EAAA,IAAI,IAAA;AACJ,EAAA,IAAI,SAAA;AAEJ,EAAA,IAAI,OAAA,EAAS;AACX,IAAA,SAAA,GAAY,KAAK,SAAA,IAAa,CAAA;AAC9B,IAAA,MAAM,MAAA,GAAS,kBAAA,CAAmB,IAAA,CAAK,aAAA,IAAiB,IAAI,CAAA;AAC5D,IAAA,IAAA,GAAO,CAAA,GAAI,aAAa,CAAA,GAAI,MAAA,CAAA;AAAA,EAC9B,CAAA,MAAO;AACL,IAAA,IAAA,GAAO,KAAK,IAAA,IAAQ,CAAA;AACpB,IAAA,SAAA,GAAY,IAAA,CAAK,aAAa,IAAA,GAAO,IAAA;AAAA,EACvC;AAEA,EAAA,IACE,IAAA,IAAQ,KACR,SAAA,IAAa,CAAA,IACb,YAAY,CAAA,IACZ,SAAA,GAAY,SAAA,IACZ,KAAA,IAAS,CAAA,EACT;AACA,IAAA,OAAO,KAAA,CAAM,EAAE,CAAA;AAAA,EACjB;AAEA,EAAA,MAAM,IAAI,KAAA,CAAM,KAAA;AAChB,EAAA,MAAM,IAAI,KAAA,CAAM,MAAA;AAChB,EAAA,MAAM,QAAgB,EAAC;AACvB,EAAA,MAAM,QAAA,GAAW,YAAY,KAAA,GAAQ,IAAA,GAAO,KAAK,IAAA,CAAK,CAAC,IAAI,GAAA,GAAM,IAAA;AAEjE,EAAA,KAAA,IAAS,GAAA,GAAM,GAAG,CAAA,GAAI,IAAA,GAAO,KAAK,CAAA,GAAI,CAAA,EAAG,GAAA,EAAA,EAAO,CAAA,IAAK,QAAA,EAAU;AAC7D,IAAA,MAAM,OAAO,OAAA,KAAY,KAAA,IAAS,MAAM,CAAA,KAAM,CAAA,GAAI,OAAO,GAAA,GAAM,CAAA;AAC/D,IAAA,KAAA,IAAS,IAAI,IAAA,GAAO,GAAA,GAAM,MAAM,CAAA,GAAI,CAAA,EAAG,KAAK,IAAA,EAAM;AAChD,MAAA,MAAM,QAAA,GAAW,MAAA,CAAO,KAAA,EAAO,CAAA,EAAG,CAAC,CAAA;AACnC,MAAA,IAAI,YAAY,CAAA,EAAG;AACnB,MAAA,MAAM,CAAA,GAAI,YAAY,QAAA,IAAY,KAAA;AAClC,MAAA,IAAI,IAAI,SAAA,EAAW;AACnB,MAAA,KAAA,CAAM,IAAA;AAAA,QACJ,aAAa,CAAA,GAAI,KAAA,EAAO,IAAI,KAAA,EAAO,CAAA,GAAI,OAAO,QAAQ;AAAA,OACxD;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO,MAAM,KAAK,CAAA;AACpB;AAEA,SAAS,mBAAmB,MAAA,EAAwB;AAClD,EAAA,IAAI,EAAE,MAAA,GAAS,CAAA,CAAA,IAAM,MAAA,GAAS,GAAG,OAAO,IAAA;AACxC,EAAA,OAAO,MAAA;AACT;;;AC1FO,IAAM,QAAA,GAAW;AAAA,EACtB,QAAA;AAAA,EACA,aAAA;AAAA,EACA,MAAA;AAAA,EACA,aAAA;AAAA,EACA,KAAA;AAAA,EACA;AACF","file":"index.js","sourcesContent":["/** Row-major darkness field: `1` = darkest (most ink), `0` = lightest. */\nexport type ToneField = {\n width: number\n height: number\n data: Float32Array\n}\n\nexport type FromRgbaOptions = {\n /**\n * When true, light pixels become high darkness (ink).\n * Default `false`: dark pixels → high darkness.\n */\n invert?: boolean\n}\n\n/** Rec.709 luma on sRGB 0–255 channels (weights applied without linearization). */\nconst LUMA_R = 0.2126\nconst LUMA_G = 0.7152\nconst LUMA_B = 0.0722\n\n/**\n * Build a tone field from packed RGBA (or RGBX) bytes, 4 bytes per pixel.\n */\nexport function fromRgba(\n data: Uint8Array | Uint8ClampedArray,\n width: number,\n height: number,\n opts: FromRgbaOptions = {},\n): ToneField {\n const w = Math.floor(width)\n const h = Math.floor(height)\n if (w < 1 || h < 1) {\n throw new Error('fromRgba: width and height must be ≥ 1')\n }\n const expected = w * h * 4\n if (data.length < expected) {\n throw new Error(\n `fromRgba: expected at least ${expected} bytes for ${w}×${h} RGBA, got ${data.length}`,\n )\n }\n\n const invert = opts.invert === true\n const out = new Float32Array(w * h)\n for (let i = 0; i < w * h; i++) {\n const o = i * 4\n const r = data[o]!\n const g = data[o + 1]!\n const b = data[o + 2]!\n const a = data[o + 3]! / 255\n const luma = (LUMA_R * r + LUMA_G * g + LUMA_B * b) / 255\n // Transparent → no ink\n let darkness = (1 - luma) * a\n if (invert) darkness = (1 - darkness) * a\n out[i] = darkness\n }\n return { width: w, height: h, data: out }\n}\n\n/** Build a tone field from row-major luminance floats in `[0,1]` (1 = white). */\nexport function fromLuminance(\n data: ArrayLike<number>,\n width: number,\n height: number,\n opts: FromRgbaOptions = {},\n): ToneField {\n const w = Math.floor(width)\n const h = Math.floor(height)\n if (w < 1 || h < 1) {\n throw new Error('fromLuminance: width and height must be ≥ 1')\n }\n if (data.length < w * h) {\n throw new Error(\n `fromLuminance: expected at least ${w * h} samples, got ${data.length}`,\n )\n }\n const invert = opts.invert === true\n const out = new Float32Array(w * h)\n for (let i = 0; i < w * h; i++) {\n const luma = clamp01(data[i]!)\n out[i] = invert ? luma : 1 - luma\n }\n return { width: w, height: h, data: out }\n}\n\n/** Nearest-neighbor sample (integer coords, clamped). */\nexport function sampleNearest(field: ToneField, x: number, y: number): number {\n const ix = clampInt(Math.round(x), 0, field.width - 1)\n const iy = clampInt(Math.round(y), 0, field.height - 1)\n return field.data[iy * field.width + ix]!\n}\n\n/** Bilinear sample in image coordinates; outside the field returns `0`. */\nexport function sample(field: ToneField, x: number, y: number): number {\n const { width: w, height: h, data } = field\n if (x < 0 || y < 0 || x > w - 1 || y > h - 1) return 0\n const x0 = Math.floor(x)\n const y0 = Math.floor(y)\n const x1 = Math.min(x0 + 1, w - 1)\n const y1 = Math.min(y0 + 1, h - 1)\n const tx = x - x0\n const ty = y - y0\n const v00 = data[y0 * w + x0]!\n const v10 = data[y0 * w + x1]!\n const v01 = data[y1 * w + x0]!\n const v11 = data[y1 * w + x1]!\n const a = v00 * (1 - tx) + v10 * tx\n const b = v01 * (1 - tx) + v11 * tx\n return a * (1 - ty) + b * ty\n}\n\nfunction clamp01(v: number): number {\n return Math.max(0, Math.min(1, v))\n}\n\nfunction clampInt(v: number, lo: number, hi: number): number {\n return Math.max(lo, Math.min(hi, v | 0))\n}\n","/** 2D point / vector. */\nexport type Vec2 = {\n x: number\n y: number\n}\n\nexport function vec2(x: number, y: number): Vec2 {\n return { x, y }\n}\n\nexport function cloneVec2(v: Vec2): Vec2 {\n return { x: v.x, y: v.y }\n}\n\nexport function equalsVec2(a: Vec2, b: Vec2, eps = 1e-9): boolean {\n return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps\n}\n","import type { Vec2 } from './vec2.js'\nimport { cloneVec2, equalsVec2 } from './vec2.js'\n\n/** Ordered ring of vertices (typically without repeating the closing vertex). */\nexport type Ring = Vec2[]\n\n/**\n * A path: one exterior ring plus optional holes, or an open polyline.\n * `rings[0]` is the exterior (or the open polyline when `closed` is false).\n * Additional rings are holes (only meaningful when `closed` is true).\n */\nexport type Path = {\n rings: Ring[]\n closed: boolean\n}\n\nexport function path(rings: Ring[], closed = true): Path {\n return {\n rings: rings.map((ring) => ring.map(cloneVec2)),\n closed,\n }\n}\n\nexport function polyline(points: Ring): Path {\n return path([points], false)\n}\n\nexport function polygon(exterior: Ring, holes: Ring[] = []): Path {\n return path([exterior, ...holes], true)\n}\n\n/** Drop a trailing vertex that duplicates the first (SVG often closes this way). */\nexport function normalizeRing(ring: Ring, eps = 1e-9): Ring {\n if (ring.length < 2) return ring.map(cloneVec2)\n const first = ring[0]\n const last = ring[ring.length - 1]\n if (first && last && equalsVec2(first, last, eps)) {\n return ring.slice(0, -1).map(cloneVec2)\n }\n return ring.map(cloneVec2)\n}\n\nexport function clonePath(p: Path): Path {\n return {\n closed: p.closed,\n rings: p.rings.map((ring) => ring.map(cloneVec2)),\n }\n}\n","import type { Path } from './path.js'\nimport { clonePath } from './path.js'\n\n/**\n * Ordered collection of paths. Later entries are “on top” for occlusion-style ops.\n */\nexport type Group = {\n paths: Path[]\n}\n\nexport function group(paths: Path[] = []): Group {\n return { paths: paths.map(clonePath) }\n}\n\nexport function groupFromPaths(...paths: Path[]): Group {\n return group(paths)\n}\n\nexport function cloneGroup(g: Group): Group {\n return group(g.paths)\n}\n","import type { Group, Path, Vec2 } from '../types/index.js'\nimport { group, polyline } from '../types/index.js'\nimport { sample, type ToneField } from './toneField.js'\n\nexport type HalftoneLinesOptions = {\n /** Line direction in radians. Default `Math.PI / 4`. */\n angle?: number\n /** Perpendicular pitch between scanlines (image units). Default `2`. */\n spacing?: number\n /** Sample step along each scanline (image units). Default `1`. */\n step?: number\n /**\n * Multi-threshold overlays (darker regions get more ink). Default `1`.\n * Each level uses threshold `i / (levels + 1)` for `i = 1..levels`.\n */\n levels?: number\n /** Ignore samples lighter than this darkness. Default `0`. */\n minDarkness?: number\n /** Output scale (image unit → SVG unit). Default `1`. */\n scale?: number\n}\n\n/**\n * Angled scanline hatching modulated by tone.\n * Darker regions produce more / longer stroked segments.\n */\nexport function lines(\n field: ToneField,\n opts: HalftoneLinesOptions = {},\n): Group {\n const angle = opts.angle ?? Math.PI / 4\n const spacing = opts.spacing ?? 2\n const step = opts.step ?? 1\n const levels = Math.max(1, Math.floor(opts.levels ?? 1))\n const minDarkness = opts.minDarkness ?? 0\n const scale = opts.scale ?? 1\n\n if (spacing <= 0 || step <= 0 || scale <= 0) return group([])\n\n const w = field.width\n const h = field.height\n const ux = Math.cos(angle)\n const uy = Math.sin(angle)\n const px = -uy\n const py = ux\n\n const corners: Array<[number, number]> = [\n [0, 0],\n [w - 1, 0],\n [w - 1, h - 1],\n [0, h - 1],\n ]\n let minP = Infinity\n let maxP = -Infinity\n let minA = Infinity\n let maxA = -Infinity\n for (const [cx, cy] of corners) {\n const p = cx * px + cy * py\n const a = cx * ux + cy * uy\n if (p < minP) minP = p\n if (p > maxP) maxP = p\n if (a < minA) minA = a\n if (a > maxA) maxA = a\n }\n\n const paths: Path[] = []\n const pad = step\n\n for (let level = 1; level <= levels; level++) {\n const threshold = Math.max(minDarkness, level / (levels + 1))\n const phase = ((level - 1) / levels) * spacing * 0.5\n\n for (let p = minP + phase; p <= maxP + 1e-9; p += spacing) {\n let run: Vec2[] = []\n\n const flush = () => {\n if (run.length >= 2) {\n paths.push(polyline(run))\n }\n run = []\n }\n\n for (let a = minA - pad; a <= maxA + pad + 1e-9; a += step) {\n const x = px * p + ux * a\n const y = py * p + uy * a\n if (x < 0 || y < 0 || x > w - 1 || y > h - 1) {\n flush()\n continue\n }\n const d = sample(field, x, y)\n if (d >= threshold) {\n run.push({ x: x * scale, y: y * scale })\n } else {\n flush()\n }\n }\n flush()\n }\n }\n\n return group(paths)\n}\n","import type { Group, Path, Vec2 } from '../types/index.js'\nimport { group, path, polygon, polyline } from '../types/index.js'\nimport { compoundVoronoi } from '../voronoi/index.js'\nimport { stochasticMerge } from '../meshing/index.js'\nimport { buffer, smooth } from '../morphology/index.js'\nimport { subtractAll } from '../shapeBoolean/index.js'\n\n/** Regular n-gon approximating a circle. */\nexport function createCircle(\n cx: number,\n cy: number,\n radius: number,\n segments = 64,\n): Path {\n return createRegularPolygon(cx, cy, radius, Math.max(3, Math.floor(segments)))\n}\n\nexport function createRect(\n x: number,\n y: number,\n width: number,\n height: number,\n): Path {\n return polygon([\n { x, y },\n { x: x + width, y },\n { x: x + width, y: y + height },\n { x, y: y + height },\n ])\n}\n\nexport function createRegularPolygon(\n cx: number,\n cy: number,\n radius: number,\n sides: number,\n rotation = -Math.PI / 2,\n): Path {\n const n = Math.max(3, Math.floor(sides))\n const ring: Vec2[] = []\n for (let i = 0; i < n; i++) {\n const t = rotation + (i / n) * Math.PI * 2\n ring.push({ x: cx + Math.cos(t) * radius, y: cy + Math.sin(t) * radius })\n }\n return polygon(ring)\n}\n\n/** Annulus as exterior + hole. */\nexport function createRing(\n cx: number,\n cy: number,\n outerRadius: number,\n innerRadius: number,\n segments = 64,\n): Path {\n const outer = createCircle(cx, cy, outerRadius, segments).rings[0]!\n const inner = createCircle(cx, cy, Math.min(innerRadius, outerRadius), segments)\n .rings[0]!\n // hole winding opposite for Clipper\n return path([outer, [...inner].reverse()], true)\n}\n\n/** Open circular arc polyline from `startAngle` to `endAngle` (radians). */\nexport function createArc(\n cx: number,\n cy: number,\n radius: number,\n startAngle: number,\n endAngle: number,\n segments = 32,\n): Path {\n const n = Math.max(2, Math.floor(segments))\n const pts: Vec2[] = []\n for (let i = 0; i <= n; i++) {\n const t = startAngle + ((endAngle - startAngle) * i) / n\n pts.push({ x: cx + Math.cos(t) * radius, y: cy + Math.sin(t) * radius })\n }\n return polyline(pts)\n}\n\n/** Star polygon (outer/inner radius alternating). */\nexport function createStar(\n cx: number,\n cy: number,\n outerRadius: number,\n innerRadius: number,\n points = 5,\n rotation = -Math.PI / 2,\n): Path {\n const n = Math.max(3, Math.floor(points))\n const ring: Vec2[] = []\n for (let i = 0; i < n * 2; i++) {\n const r = i % 2 === 0 ? outerRadius : innerRadius\n const t = rotation + (i / (n * 2)) * Math.PI * 2\n ring.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r })\n }\n return polygon(ring)\n}\n\n/** Koch snowflake after `iterations` refinement (0 = equilateral triangle). */\nexport function createKochSnowflake(\n cx: number,\n cy: number,\n radius: number,\n iterations = 3,\n): Path {\n const tri = createRegularPolygon(cx, cy, radius, 3)\n let ring = tri.rings[0]!\n const iters = Math.max(0, Math.min(6, Math.floor(iterations)))\n for (let i = 0; i < iters; i++) {\n ring = kochRefine(ring)\n }\n return polygon(ring)\n}\n\n/**\n * Sponge-like porous structure (PGS `createSponge`).\n * Voronoi cells are randomly class-merged, smoothed (Chaikin), optionally\n * eroded for wall thickness, then subtracted from the bounding rectangle.\n * Returns a group (may be multi-component).\n */\nexport function createSponge(\n width: number,\n height: number,\n generators: number,\n thickness: number,\n smoothing: number,\n classes: number,\n seed = 1,\n): Group {\n const w = Math.max(width, 1e-9)\n const h = Math.max(height, 1e-9)\n const nGen = Math.max(6, Math.floor(generators))\n const nClasses = Math.max(1, Math.floor(classes))\n const smoothIters = Math.max(0, Math.floor(smoothing))\n const wall = Math.max(0, thickness)\n\n const sites = randomInBox(nGen, 0, 0, w, h, seed)\n const cells = compoundVoronoi(sites, {\n bounds: { minX: 0, minY: 0, maxX: w, maxY: h },\n })\n const merged = stochasticMerge(cells.paths, nClasses, seed + 1)\n\n const pores: Path[] = []\n for (let p of merged.paths) {\n if (smoothIters > 0) p = smooth(p, smoothIters)\n if (wall > 1e-12) {\n // Erode pore blobs so remaining walls are thicker\n pores.push(...buffer(p, -wall * 0.5).paths)\n } else {\n pores.push(p)\n }\n }\n\n const frame = createRect(0, 0, w, h)\n if (pores.length === 0) return group([frame])\n return subtractAll(frame, pores)\n}\n\nfunction randomInBox(\n count: number,\n minX: number,\n minY: number,\n maxX: number,\n maxY: number,\n seed: number,\n): Vec2[] {\n const rng = mulberry32(seed >>> 0)\n const out: Vec2[] = []\n for (let i = 0; i < count; i++) {\n out.push({\n x: minX + rng() * (maxX - minX),\n y: minY + rng() * (maxY - minY),\n })\n }\n return out\n}\n\nfunction mulberry32(a: number): () => number {\n return function () {\n let t = (a += 0x6d2b79f5)\n t = Math.imul(t ^ (t >>> 15), t | 1)\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61)\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction kochRefine(ring: Vec2[]): Vec2[] {\n const out: Vec2[] = []\n const n = ring.length\n for (let i = 0; i < n; i++) {\n const a = ring[i]!\n const b = ring[(i + 1) % n]!\n const dx = b.x - a.x\n const dy = b.y - a.y\n const p1 = { x: a.x + dx / 3, y: a.y + dy / 3 }\n const p2 = { x: a.x + (2 * dx) / 3, y: a.y + (2 * dy) / 3 }\n // outward peak (left of directed edge in Y-down SVG coords = rotate +90 for CCW tri)\n const px = a.x + dx / 2 - dy * (Math.sqrt(3) / 6)\n const py = a.y + dy / 2 + dx * (Math.sqrt(3) / 6)\n out.push(a, p1, { x: px, y: py }, p2)\n }\n return out\n}\n\nexport const construction = {\n createCircle,\n createRect,\n createRegularPolygon,\n createRing,\n createArc,\n createStar,\n createKochSnowflake,\n createSponge,\n}\n","import type { Group, Path } from '../types/index.js'\nimport { group } from '../types/index.js'\nimport { createCircle } from '../construction/index.js'\nimport { sample, type ToneField } from './toneField.js'\n\nexport type HalftoneCirclesOptions = {\n /**\n * Lattice pitch in image units. Default `4` when `overlap` is false.\n * When `overlap` is true, pitch is derived from `maxRadius` and `overlapAmount`\n * (this value is ignored).\n */\n cell?: number\n /** Lattice geometry. Default `'square'`. */\n lattice?: 'square' | 'hex'\n /**\n * Minimum drawn radius (image units). Sites mapping below this are skipped.\n * Default `0.3`.\n */\n minRadius?: number\n /**\n * Radius at full darkness (image units).\n * Default: `cell * 0.45` when `overlap` is false; `8` when `overlap` is true.\n */\n maxRadius?: number\n /**\n * When true, lattice pitch is derived so max-radius neighbors overlap.\n * Default `false`.\n */\n overlap?: boolean\n /**\n * Fraction of max diameter that max-radius neighbors share along the lattice\n * neighbor vector. In `(0, 1]`. Default `0.35` when `overlap` is true.\n * Ignored when `overlap` is false.\n */\n overlapAmount?: number\n /**\n * Radius curve: `r = maxRadius * darkness^gamma`, then skip if `r < minRadius`.\n * Default `1`.\n */\n gamma?: number\n /** N-gon segments for each stroked circle. Default `24`. */\n segments?: number\n /** Output scale (image unit → SVG unit). Default `1`. */\n scale?: number\n}\n\n/**\n * Tone-scaled circle lattice (square or hex).\n * Darker sites produce larger stroked circles; light sites below `minRadius` are skipped.\n * With `overlap: true`, pitch is tightened so max-radius neighbors cross\n * (single-pen optical density from stroke crossings).\n */\nexport function circles(\n field: ToneField,\n opts: HalftoneCirclesOptions = {},\n): Group {\n const lattice = opts.lattice ?? 'square'\n const overlap = opts.overlap === true\n const minRadius = opts.minRadius ?? 0.3\n const gamma = opts.gamma ?? 1\n const segments = Math.max(3, Math.floor(opts.segments ?? 24))\n const scale = opts.scale ?? 1\n\n let cell: number\n let maxRadius: number\n\n if (overlap) {\n maxRadius = opts.maxRadius ?? 8\n const amount = clampOverlapAmount(opts.overlapAmount ?? 0.35)\n cell = 2 * maxRadius * (1 - amount)\n } else {\n cell = opts.cell ?? 4\n maxRadius = opts.maxRadius ?? cell * 0.45\n }\n\n if (\n cell <= 0 ||\n maxRadius <= 0 ||\n minRadius < 0 ||\n maxRadius < minRadius ||\n scale <= 0\n ) {\n return group([])\n }\n\n const w = field.width\n const h = field.height\n const paths: Path[] = []\n const rowPitch = lattice === 'hex' ? cell * Math.sqrt(3) * 0.5 : cell\n\n for (let row = 0, y = cell * 0.5; y < h; row++, y += rowPitch) {\n const xOff = lattice === 'hex' && row % 2 === 1 ? cell * 0.5 : 0\n for (let x = cell * 0.5 + xOff; x < w; x += cell) {\n const darkness = sample(field, x, y)\n if (darkness <= 0) continue\n const r = maxRadius * darkness ** gamma\n if (r < minRadius) continue\n paths.push(\n createCircle(x * scale, y * scale, r * scale, segments),\n )\n }\n }\n\n return group(paths)\n}\n\nfunction clampOverlapAmount(amount: number): number {\n if (!(amount > 0) || amount > 1) return 0.35\n return amount\n}\n","export type { ToneField, FromRgbaOptions } from './toneField.js'\nexport {\n fromRgba,\n fromLuminance,\n sample,\n sampleNearest,\n} from './toneField.js'\n\nexport type { HalftoneLinesOptions } from './lines.js'\nexport { lines } from './lines.js'\n\nexport type { HalftoneCirclesOptions } from './circles.js'\nexport { circles } from './circles.js'\n\nimport { fromRgba, fromLuminance, sample, sampleNearest } from './toneField.js'\nimport { lines } from './lines.js'\nimport { circles } from './circles.js'\n\n/** Exclusive mark modes: pick `lines` or `circles` (not both in one pass). */\nexport const halftone = {\n fromRgba,\n fromLuminance,\n sample,\n sampleNearest,\n lines,\n circles,\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { complement, intersect, occlusionSubtract, overlapRegions, shapeBoolean,
10
10
  export { area, areaGroup, areaSimilarity, bounds, centroid, containsPoint, height, predicates, ringArea, width } from './predicates/index.js';
11
11
  export { SegmentLengthFn, WeaveSegmentsOptions, filterAxisAligned, filterByMinLength, parallelSegments, perpendicularPathSegments, segmentLength, segmentSet, segmentsToOpenPaths, stochasticSegments, weaveSegments } from './segmentSet/index.js';
12
12
  export { ConcentricHatchOptions, ParallelHatchOptions, PerpendicularHatchOptions, StochasticHatchOptions, WeaveHatchOptions, hatch, concentric as hatchConcentric, cross as hatchCross, parallel as hatchParallel, perpendicular as hatchPerpendicular, stochastic as hatchStochastic, weave as hatchWeave } from './hatch/index.js';
13
+ export { FromRgbaOptions, HalftoneCirclesOptions, HalftoneLinesOptions, ToneField, fromLuminance, fromRgba, halftone, circles as halftoneCircles, lines as halftoneLines, sample as sampleTone, sampleNearest as sampleToneNearest } from './halftone/index.js';
13
14
  export { LatticePackMode, circleContainedInPath, circleOverlapsPath, circlePacking, frontChainPack, hexLatticePack, maximumInscribedPack, maximumInscribedPackUntil, obstaclePack, repulsionPack, squareLatticePack, stochasticPack } from './circlePacking/index.js';
14
15
  export { BufferOptions, buffer, chaikinCut, dilationErosion, erosionDilation, minkDifference, minkSum, morphology, radialWarp, reducePrecision, simplify, sineWarp, smooth } from './morphology/index.js';
15
16
  export { flipHorizontal, flipVertical, originScale, resizeByHeight, resizeByWidth, rotate, rotateAroundCenter, scale, shear, transformation, translate, translateCentroidTo, translateCornerTo, translateToOrigin } from './transformation/index.js';