@logomarks/core 0.1.8 → 0.1.11

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +4 -1
  3. package/package.json +22 -3
  4. package/src/batch/args.d.ts +18 -0
  5. package/src/batch/args.js +44 -0
  6. package/src/batch/index.d.ts +7 -0
  7. package/src/batch/index.js +10 -0
  8. package/src/batch/run.d.ts +53 -0
  9. package/src/batch/run.js +92 -0
  10. package/src/batch/slug.d.ts +2 -0
  11. package/src/batch/slug.js +12 -0
  12. package/src/batch/source.d.ts +12 -0
  13. package/src/batch/source.js +23 -0
  14. package/src/fit/index.d.ts +4 -0
  15. package/src/fit/index.js +29 -5
  16. package/src/geometry/index.d.ts +1 -0
  17. package/src/geometry/index.js +1 -0
  18. package/src/geometry/mitre.d.ts +8 -0
  19. package/src/geometry/mitre.js +64 -0
  20. package/src/geometry/modular.d.ts +1 -1
  21. package/src/index.d.ts +1 -0
  22. package/src/index.js +3 -2
  23. package/src/parts/index.d.ts +2 -0
  24. package/src/parts/index.js +2 -0
  25. package/src/parts/pixels.d.ts +11 -0
  26. package/src/parts/pixels.js +74 -0
  27. package/src/parts/run.js +4 -2
  28. package/src/parts/span.d.ts +13 -0
  29. package/src/parts/span.js +100 -0
  30. package/src/sdf/fields.js +135 -0
  31. package/src/sdf/gap.js +42 -0
  32. package/src/sdf/index.d.ts +56 -0
  33. package/src/sdf/index.js +12 -0
  34. package/src/sdf/ops.js +102 -0
  35. package/src/sdf/trace.js +90 -0
  36. package/src/skill/bundle.js +1 -1
  37. package/src/stitch/border.d.ts +2 -0
  38. package/src/stitch/border.js +40 -0
  39. package/src/stitch/field.d.ts +14 -0
  40. package/src/stitch/field.js +207 -0
  41. package/src/stitch/fill.d.ts +2 -0
  42. package/src/stitch/fill.js +67 -0
  43. package/src/stitch/index.d.ts +6 -0
  44. package/src/stitch/index.js +14 -0
  45. package/src/stitch/plan.d.ts +46 -0
  46. package/src/stitch/plan.js +127 -0
  47. package/src/stitch/regions.d.ts +4 -0
  48. package/src/stitch/regions.js +54 -0
  49. package/src/stitch/satin.d.ts +2 -0
  50. package/src/stitch/satin.js +86 -0
@@ -14,7 +14,7 @@ const SRC = new URL('../', import.meta.url);
14
14
 
15
15
  /** The geometry kit's modules, in dependency order. */
16
16
  const KIT = ['geometry/core.js', 'geometry/booleans.js', 'geometry/polygon.js', 'geometry/random.js', 'geometry/shapes.js', 'geometry/stroke.js',
17
- 'geometry/path.js', 'geometry/offset.js', 'geometry/iso.js', 'geometry/modular.js', 'geometry/badges.js', 'geometry/letters.js',
17
+ 'geometry/path.js', 'geometry/offset.js', 'geometry/mitre.js', 'geometry/iso.js', 'geometry/modular.js', 'geometry/badges.js', 'geometry/letters.js',
18
18
  'svg/path.js', 'svg/shapes.js', 'svg/rings.js', 'svg/nonzero.js', 'svg/shape.js', 'geometry/glitch.js', 'geometry/needle.js'];
19
19
 
20
20
  /** The fit's modules, in dependency order. */
@@ -0,0 +1,2 @@
1
+ /** A satin border, `width` pixels wide, round the wide parts where they meet the outside (pixel coordinates, flat `x0, y0, x1, y1` each, from the edge inwards). */
2
+ export function borderStitches(wide: Uint8Array, dist: Float32Array, W: number, H: number, o: { width: number; spacing: number }): number[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @module @logomarks/core/stitch/border
3
+ *
4
+ * A satin border round the filled parts: a narrow column of threads laid
5
+ * over the fill's outer edge, each square to the edge, which hides the
6
+ * fill's ragged row ends as a real border does. It runs only where a fill
7
+ * meets the outside (or a hole), not where it meets a satin stroke.
8
+ */
9
+
10
+ import { gradient, spaced } from './field.js';
11
+
12
+ /**
13
+ * The border stitches round the wide parts.
14
+ *
15
+ * @param {Uint8Array} wide The filled parts (1 inside).
16
+ * @param {Float32Array} dist Each ink pixel's distance to the edge of the ink.
17
+ * @param {number} W The raster's width.
18
+ * @param {number} H Its height.
19
+ * @param {object} o How.
20
+ * @param {number} o.width The border's width, in pixels.
21
+ * @param {number} o.spacing The distance between threads, in pixels.
22
+ * @returns {number[]} The stitches, flat: `x0, y0, x1, y1` each, in pixel coordinates, from the edge inwards.
23
+ */
24
+ export function borderStitches(wide, dist, W, H, { width, spacing }) {
25
+ if (width <= 0) return [];
26
+ // the edge lies half a pixel out from the outermost ink pixels (whose distance is 1): the column's middle line
27
+ const mid = 0.5 + width / 2;
28
+ const line = [];
29
+ for (let i = 0; i < W * H; i++) if (wide[i] && Math.abs(dist[i] - mid) <= 0.75) line.push(i);
30
+ const out = [];
31
+ for (const i of spaced(line, W, spacing)) {
32
+ const x = i % W, y = (i - x) / W;
33
+ const [gx, gy] = gradient(dist, W, H, x, y);
34
+ if (!gx && !gy) continue;
35
+ const back = dist[i] - 0.5;
36
+ const on = width - back;
37
+ out.push(x - gx * back, y - gy * back, x + gx * on, y + gy * on);
38
+ }
39
+ return out;
40
+ }
@@ -0,0 +1,14 @@
1
+ /** A value read between pixel centres (bilinear), 0 outside the raster. */
2
+ export function sample(f: Float32Array, W: number, H: number, x: number, y: number): number;
3
+ /** The direction a field rises fastest at a pixel, as a unit vector (`[0, 0]` where flat). */
4
+ export function gradient(f: Float32Array, W: number, H: number, x: number, y: number): [number, number];
5
+ /** A box blur of radius `r`, in place. */
6
+ export function boxBlur(f: Float32Array, W: number, H: number, r: number): void;
7
+ /** The 4-connected regions of a mask: each pixel's region (−1 outside) and each region's size. */
8
+ export function regions(mask: Uint8Array, W: number, H: number): { label: Int32Array; sizes: number[] };
9
+ /** The angle across a stroke (radians) at each pixel of a mask, from the distance field's blurred structure tensor. */
10
+ export function acrossField(dist: Float32Array, mask: Uint8Array, W: number, H: number, radius: number): Float32Array;
11
+ /** Pixels picked from candidates, in order, so no two are closer than `spacing`; `taken` pixels are skipped. */
12
+ export function spaced(candidates: Iterable<number>, W: number, spacing: number, taken?: (i: number) => boolean): number[];
13
+ /** How far a ray goes before the distance field (read between pixels) falls to half a pixel: the ink's edge, or `max`. */
14
+ export function reach(dist: Float32Array, W: number, H: number, x: number, y: number, dx: number, dy: number, max: number): number;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @module @logomarks/core/stitch/field
3
+ *
4
+ * Raster helpers the stitch planner works on: values read between pixel
5
+ * centres, a blur, connected regions, the direction across a stroke (from a
6
+ * smoothed structure tensor of the distance field, so it doesn't flip on
7
+ * the stroke's middle line), and evenly spaced picks from a set of pixels.
8
+ * Coordinates are continuous pixel coordinates: pixel `i`'s centre is at `i`.
9
+ */
10
+
11
+ /**
12
+ * A value read between pixel centres (bilinear), 0 outside the raster.
13
+ *
14
+ * @param {Float32Array} f The values, row by row.
15
+ * @param {number} W The raster's width.
16
+ * @param {number} H Its height.
17
+ * @param {number} x Where, across.
18
+ * @param {number} y Where, down.
19
+ * @returns {number} The value.
20
+ */
21
+ export function sample(f, W, H, x, y) {
22
+ const x0 = Math.floor(x), y0 = Math.floor(y);
23
+ const fx = x - x0, fy = y - y0;
24
+ const at = (i, j) => (i < 0 || j < 0 || i >= W || j >= H ? 0 : f[j * W + i]);
25
+ return (at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx) * (1 - fy) + (at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx) * fy;
26
+ }
27
+
28
+ /**
29
+ * The gradient of a field at a pixel (central differences), as a unit vector, or `[0, 0]` where it is flat.
30
+ *
31
+ * @param {Float32Array} f The field.
32
+ * @param {number} W Its width.
33
+ * @param {number} H Its height.
34
+ * @param {number} x The pixel, across.
35
+ * @param {number} y The pixel, down.
36
+ * @returns {[number, number]} The direction it rises fastest.
37
+ */
38
+ export function gradient(f, W, H, x, y) {
39
+ const gx = sample(f, W, H, x + 1, y) - sample(f, W, H, x - 1, y);
40
+ const gy = sample(f, W, H, x, y + 1) - sample(f, W, H, x, y - 1);
41
+ const m = Math.hypot(gx, gy);
42
+ return m > 1e-9 ? [gx / m, gy / m] : [0, 0];
43
+ }
44
+
45
+ /**
46
+ * A box blur, in place, along rows then columns.
47
+ *
48
+ * @param {Float32Array} f The values.
49
+ * @param {number} W The width.
50
+ * @param {number} H The height.
51
+ * @param {number} r The radius, in pixels.
52
+ */
53
+ export function boxBlur(f, W, H, r) {
54
+ if (r < 1) return;
55
+ const tmp = new Float32Array(Math.max(W, H));
56
+ const pass = (n, count, at) => {
57
+ for (let k = 0; k < count; k++) {
58
+ let s = 0;
59
+ for (let i = -r; i <= r; i++) s += f[at(k, Math.min(n - 1, Math.max(0, i)))];
60
+ for (let i = 0; i < n; i++) {
61
+ tmp[i] = s / (2 * r + 1);
62
+ s += f[at(k, Math.min(n - 1, i + r + 1))] - f[at(k, Math.max(0, i - r))];
63
+ }
64
+ for (let i = 0; i < n; i++) f[at(k, i)] = tmp[i];
65
+ }
66
+ };
67
+ pass(W, H, (row, i) => row * W + i);
68
+ pass(H, W, (col, i) => i * W + col);
69
+ }
70
+
71
+ /**
72
+ * The connected regions (4-connected) of a mask.
73
+ *
74
+ * @param {Uint8Array} mask 1 inside.
75
+ * @param {number} W The width.
76
+ * @param {number} H The height.
77
+ * @returns {{ label: Int32Array, sizes: number[] }} Each pixel's region (−1 outside) and each region's size.
78
+ */
79
+ export function regions(mask, W, H) {
80
+ const label = new Int32Array(W * H).fill(-1);
81
+ const sizes = [];
82
+ const stack = [];
83
+ for (let i = 0; i < W * H; i++) {
84
+ if (!mask[i] || label[i] >= 0) continue;
85
+ const id = sizes.length;
86
+ let n = 0;
87
+ label[i] = id;
88
+ stack.push(i);
89
+ while (stack.length) {
90
+ const p = /** @type {number} */ (stack.pop());
91
+ n++;
92
+ const x = p % W, y = (p - x) / W;
93
+ for (const q of [x > 0 ? p - 1 : -1, x < W - 1 ? p + 1 : -1, y > 0 ? p - W : -1, y < H - 1 ? p + W : -1]) {
94
+ if (q >= 0 && mask[q] && label[q] < 0) {
95
+ label[q] = id;
96
+ stack.push(q);
97
+ }
98
+ }
99
+ }
100
+ sizes.push(n);
101
+ }
102
+ return { label, sizes };
103
+ }
104
+
105
+ /**
106
+ * The direction across a stroke at each pixel of a mask: the dominant direction of the distance field's gradient
107
+ * (its structure tensor, blurred within the mask), which is the same on both sides of the stroke's middle line.
108
+ *
109
+ * @param {Float32Array} dist The distance to the edge, per pixel.
110
+ * @param {Uint8Array} mask Where to measure (the narrow parts).
111
+ * @param {number} W The width.
112
+ * @param {number} H The height.
113
+ * @param {number} radius The blur, in pixels.
114
+ * @returns {Float32Array} The angle across, in radians, per pixel (0 outside the mask).
115
+ */
116
+ export function acrossField(dist, mask, W, H, radius) {
117
+ const n = W * H;
118
+ const [xx, xy, yy, w] = [new Float32Array(n), new Float32Array(n), new Float32Array(n), new Float32Array(n)];
119
+ for (let y = 0; y < H; y++) {
120
+ for (let x = 0; x < W; x++) {
121
+ const i = y * W + x;
122
+ if (!mask[i]) continue;
123
+ const gx = sample(dist, W, H, x + 1, y) - sample(dist, W, H, x - 1, y);
124
+ const gy = sample(dist, W, H, x, y + 1) - sample(dist, W, H, x, y - 1);
125
+ xx[i] = gx * gx;
126
+ xy[i] = gx * gy;
127
+ yy[i] = gy * gy;
128
+ w[i] = 1;
129
+ }
130
+ }
131
+ for (const f of [xx, xy, yy, w]) {
132
+ boxBlur(f, W, H, radius);
133
+ boxBlur(f, W, H, radius);
134
+ }
135
+ const out = new Float32Array(n);
136
+ for (let i = 0; i < n; i++) if (mask[i] && w[i] > 0) out[i] = 0.5 * Math.atan2(2 * xy[i], xx[i] - yy[i]);
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * Pixels picked from candidates so that no two are closer than `spacing`, greedily in the candidates' order.
142
+ *
143
+ * @param {Iterable<number>} candidates Pixel indices, in the order to try them.
144
+ * @param {number} W The raster's width.
145
+ * @param {number} spacing The least distance between picks, in pixels.
146
+ * @param {(i: number) => boolean} [taken] Pixels already covered, which are skipped.
147
+ * @returns {number[]} The picks.
148
+ */
149
+ export function spaced(candidates, W, spacing, taken) {
150
+ const cell = Math.max(1, spacing / Math.SQRT2);
151
+ /** @type {Map<number, number[]>} */
152
+ const grid = new Map();
153
+ const key = (cx, cy) => cy * 65536 + cx;
154
+ const out = [];
155
+ const s2 = spacing * spacing;
156
+ for (const i of candidates) {
157
+ if (taken?.(i)) continue;
158
+ const x = i % W, y = (i - x) / W;
159
+ const cx = Math.floor(x / cell), cy = Math.floor(y / cell);
160
+ const reach = Math.ceil(spacing / cell);
161
+ let free = true;
162
+ for (let dy = -reach; dy <= reach && free; dy++) {
163
+ for (let dx = -reach; dx <= reach && free; dx++) {
164
+ const pts = grid.get(key(cx + dx, cy + dy));
165
+ if (pts) for (const j of pts) {
166
+ const jx = j % W, jy = (j - jx) / W;
167
+ if ((jx - x) ** 2 + (jy - y) ** 2 < s2) {
168
+ free = false;
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ if (!free) continue;
175
+ out.push(i);
176
+ const k = key(cx, cy);
177
+ const list = grid.get(k);
178
+ if (list) list.push(i);
179
+ else grid.set(k, [i]);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * How far a ray goes from a point before it leaves the ink: where the distance field, read between pixels, first
186
+ * falls to half a pixel (the edge between an ink pixel and an empty one).
187
+ *
188
+ * @param {Float32Array} dist The distance to the edge (0 on empty pixels).
189
+ * @param {number} W The width.
190
+ * @param {number} H The height.
191
+ * @param {number} x Where it starts, across.
192
+ * @param {number} y Where it starts, down.
193
+ * @param {number} dx Its direction, across (unit).
194
+ * @param {number} dy Its direction, down.
195
+ * @param {number} max The farthest it goes.
196
+ * @returns {number} The distance to the edge (or `max`).
197
+ */
198
+ export function reach(dist, W, H, x, y, dx, dy, max) {
199
+ const step = 0.5;
200
+ let prev = sample(dist, W, H, x, y);
201
+ for (let t = step; t <= max; t += step) {
202
+ const v = sample(dist, W, H, x + dx * t, y + dy * t);
203
+ if (v < 0.5) return t - step + (step * (prev - 0.5)) / Math.max(1e-6, prev - v);
204
+ prev = v;
205
+ }
206
+ return max;
207
+ }
@@ -0,0 +1,2 @@
1
+ /** Tatami rows over a mask (pixel coordinates, flat `x0, y0, x1, y1` each), the stitch ends staggered row by row. */
2
+ export function fillStitches(mask: Uint8Array, W: number, H: number, o: { spacing: number; length: number; angle: number; random: () => number }): number[];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @module @logomarks/core/stitch/fill
3
+ *
4
+ * Tatami (fill stitch): the wide parts covered in parallel rows at one
5
+ * angle, each row a run of short stitches. The needle points step along each
6
+ * row at the stitch length from a phase that shifts row by row (half, then a
7
+ * quarter, then three quarters of a stitch), so the stitch ends never line
8
+ * up and the surface shows the staggered brick of a real fill.
9
+ */
10
+
11
+ /** Each row's phase, as a share of the stitch length, repeating. */
12
+ const PHASES = Object.freeze([0, 0.5, 0.25, 0.75]);
13
+
14
+ /**
15
+ * The fill stitches over a mask.
16
+ *
17
+ * @param {Uint8Array} mask Where to fill (1 inside).
18
+ * @param {number} W The raster's width.
19
+ * @param {number} H Its height.
20
+ * @param {object} o How.
21
+ * @param {number} o.spacing The distance between rows, in pixels.
22
+ * @param {number} o.length The stitch length, in pixels.
23
+ * @param {number} o.angle The rows' angle, in degrees (0 runs across, 90 down).
24
+ * @param {() => number} o.random A seeded source of numbers in [0, 1).
25
+ * @returns {number[]} The stitches, flat: `x0, y0, x1, y1` each, in pixel coordinates.
26
+ */
27
+ export function fillStitches(mask, W, H, { spacing, length, angle, random }) {
28
+ const a = (angle * Math.PI) / 180;
29
+ const [ux, uy] = [Math.cos(a), Math.sin(a)];
30
+ const [vx, vy] = [-uy, ux];
31
+ const corners = [[0, 0], [W, 0], [0, H], [W, H]];
32
+ const along = corners.map(([x, y]) => x * ux + y * uy);
33
+ const across = corners.map(([x, y]) => x * vx + y * vy);
34
+ const [s0, s1] = [Math.min(...along), Math.max(...along)];
35
+ const [c0, c1] = [Math.min(...across), Math.max(...across)];
36
+ const inside = (s, c) => {
37
+ const x = Math.round(s * ux + c * vx), y = Math.round(s * uy + c * vy);
38
+ return x >= 0 && y >= 0 && x < W && y < H && mask[y * W + x] === 1;
39
+ };
40
+ const step = 0.5;
41
+ const shortest = length * 0.18;
42
+ const out = [];
43
+ let row = 0;
44
+ for (let c = c0 + spacing / 2; c < c1; c += spacing, row++) {
45
+ const phase = (PHASES[row % PHASES.length] + (random() - 0.5) * 0.04) * length;
46
+ let start = null;
47
+ for (let s = s0; s <= s1 + step; s += step) {
48
+ const on = s <= s1 && inside(s, c);
49
+ if (on && start === null) start = s;
50
+ if (on || start === null) continue;
51
+ // each run stops short of the edge by half a row, so its round thread end lies on it
52
+ const end = s - step - spacing / 2;
53
+ start += spacing / 2;
54
+ if (end - start < shortest) {
55
+ start = null;
56
+ continue;
57
+ }
58
+ // the needle points: the run's two ends, and the row's grid between them (jittered a little)
59
+ const pts = [start];
60
+ for (let g = Math.ceil((start + shortest - phase) / length) * length + phase; g < end - shortest; g += length) pts.push(g + (random() - 0.5) * length * 0.06);
61
+ pts.push(end);
62
+ for (let i = 0; i + 1 < pts.length; i++) out.push(pts[i] * ux + c * vx, pts[i] * uy + c * vy, pts[i + 1] * ux + c * vx, pts[i + 1] * uy + c * vy);
63
+ start = null;
64
+ }
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1,6 @@
1
+ export type { StitchSettings, StitchPlan } from './plan.js';
2
+ export { STITCH, SATIN_DENSITY, stitchPlan, stitchCount } from './plan.js';
3
+ export { splitByWidth } from './regions.js';
4
+ export { fillStitches } from './fill.js';
5
+ export { satinStitches } from './satin.js';
6
+ export { borderStitches } from './border.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @module @logomarks/core/stitch
3
+ *
4
+ * Machine embroidery planned from a mark's outlines: the ink split by width
5
+ * into fill and satin, tatami rows over the fills, satin threads across the
6
+ * strokes, a satin border round the fills. Pure geometry, seeded; a
7
+ * renderer (`@logomarks/mockups`' embroidery finish) draws the stitches as
8
+ * thread.
9
+ */
10
+ export { STITCH, SATIN_DENSITY, stitchPlan, stitchCount } from './plan.js';
11
+ export { splitByWidth } from './regions.js';
12
+ export { fillStitches } from './fill.js';
13
+ export { satinStitches } from './satin.js';
14
+ export { borderStitches } from './border.js';
@@ -0,0 +1,46 @@
1
+ /** The planner's settings. Sizes are in millimetres. */
2
+ export interface StitchSettings {
3
+ /** How far the mark's canvas (its viewBox's longer side) spans, in mm (55). */
4
+ size: number;
5
+ /** The distance between rows of fill, in mm (0.42); satin threads lie `SATIN_DENSITY` times as far apart. */
6
+ density: number;
7
+ /** The widest part stitched in satin, in mm (3.6); wider parts are filled. */
8
+ satin: number;
9
+ /** The fill's stitch length, in mm (3.5). */
10
+ stitch: number;
11
+ /** The fill rows' angle, in degrees: 0 runs across, 90 down (45). */
12
+ angle: number;
13
+ /** The satin border round each fill, in mm; 0 for none (0.9). */
14
+ border: number;
15
+ /** The raster's pitch, in pixels a mm (10). */
16
+ resolution: number;
17
+ /** Seeds the small irregularities ('1'). */
18
+ seed: string;
19
+ }
20
+
21
+ /** A mark's stitches. Each list is flat, four numbers a stitch (`x0, y0, x1, y1`), in the mark's own units, in the order they're sewn. */
22
+ export interface StitchPlan {
23
+ /** The mark's viewBox: x, y, width, height. */
24
+ viewBox: [number, number, number, number];
25
+ /** Millimetres a unit of the mark. */
26
+ mm: number;
27
+ /** The settings it was planned with. */
28
+ settings: StitchSettings;
29
+ /** Tatami rows over the wide parts. */
30
+ fill: number[];
31
+ /** Satin threads across the narrow parts. */
32
+ satin: number[];
33
+ /** The satin border round each fill, from the edge inwards. */
34
+ border: number[];
35
+ /** How much of the mark is filled and how much is satin, in mm². */
36
+ area: { fill: number; satin: number };
37
+ }
38
+
39
+ /** Satin's thread spacing as a share of `density` (0.82). */
40
+ export const SATIN_DENSITY: number;
41
+ /** The defaults. */
42
+ export const STITCH: Readonly<StitchSettings>;
43
+ /** The stitches for a mark's SVG text. Throws a TypeError for a bad option or a file with nothing filled. */
44
+ export function stitchPlan(svg: string, options?: Partial<Omit<StitchSettings, 'seed'>> & { seed?: string | number }): StitchPlan;
45
+ /** How many stitches a plan has, of each kind and in all. */
46
+ export function stitchCount(plan: Pick<StitchPlan, 'fill' | 'satin' | 'border'>): { fill: number; satin: number; border: number; total: number };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @module @logomarks/core/stitch/plan
3
+ *
4
+ * A mark planned as machine embroidery, from its outlines: where each
5
+ * stitch lies. The ink is rasterised at a fine pitch (a tenth of a
6
+ * millimetre by default) at the size it will be stitched, split by width
7
+ * into fill (wide) and satin (narrow) parts, and each part covered as an
8
+ * embroiderer would: tatami rows over the fills, satin threads across the
9
+ * strokes, and a satin border round each fill. The stitches come back in the
10
+ * mark's own units, for a renderer to draw as thread. Seeded: the same mark,
11
+ * options and seed always give the same stitches.
12
+ */
13
+
14
+ import { rings } from '../svg/rings.js';
15
+ import { shapes, viewBox } from '../svg/shapes.js';
16
+ import { frameOf, fill } from '../raster/scanline.js';
17
+ import { distance } from '../raster/distance.js';
18
+ import { seeded } from '../geometry/random.js';
19
+ import { splitByWidth } from './regions.js';
20
+ import { fillStitches } from './fill.js';
21
+ import { satinStitches } from './satin.js';
22
+ import { borderStitches } from './border.js';
23
+
24
+ /** The defaults, in millimetres and degrees: the planner's settings for a mark stitched about 55 mm across. */
25
+ export const STITCH = Object.freeze({
26
+ /** How far the mark's canvas (its viewBox's longer side) spans, in mm. */
27
+ size: 55,
28
+ /** The distance between rows of fill, in mm; satin threads lie a little closer (`SATIN_DENSITY` of it). */
29
+ density: 0.42,
30
+ /** The widest part stitched in satin, in mm; wider parts are filled. */
31
+ satin: 3.6,
32
+ /** The fill's stitch length, in mm. */
33
+ stitch: 3.5,
34
+ /** The fill rows' angle, in degrees (0 runs across, 90 down). */
35
+ angle: 45,
36
+ /** The satin border round each fill, in mm (0 for none). */
37
+ border: 0.9,
38
+ /** The raster's pitch, in pixels a mm. */
39
+ resolution: 10,
40
+ /** Seeds the small irregularities. */
41
+ seed: '1',
42
+ });
43
+
44
+ /** Satin's thread spacing as a share of the fill's row spacing: satin is sewn denser, so its threads close up. */
45
+ export const SATIN_DENSITY = 0.82;
46
+
47
+ /**
48
+ * A positive number option, or its default.
49
+ *
50
+ * @param {Record<string, unknown>} o The options.
51
+ * @param {'size' | 'density' | 'satin' | 'stitch' | 'border' | 'resolution'} k The key.
52
+ * @param {boolean} [zero=false] Whether 0 is allowed too.
53
+ * @returns {number} The value.
54
+ * @throws {TypeError} When it isn't one.
55
+ */
56
+ function positive(o, k, zero = false) {
57
+ const v = o[k] ?? STITCH[k];
58
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || (v === 0 && !zero)) throw new TypeError(`stitchPlan: ${k} is a number over 0${zero ? ', or 0' : ''} (got ${JSON.stringify(o[k])})`);
59
+ return v;
60
+ }
61
+
62
+ /**
63
+ * The stitches for a mark.
64
+ *
65
+ * @param {string} svg The mark's SVG text (its filled paths, circles, ellipses, rects and polygons, each with its fill rule).
66
+ * @param {Partial<typeof STITCH>} [options] The settings (see `STITCH`).
67
+ * @returns {import('./plan.js').StitchPlan} The plan.
68
+ * @throws {TypeError} For a bad option or a file with nothing filled.
69
+ */
70
+ export function stitchPlan(svg, options = {}) {
71
+ if (typeof svg !== 'string') throw new TypeError('stitchPlan: pass the mark\'s SVG text');
72
+ const o = /** @type {Record<string, unknown>} */ (options ?? {});
73
+ const size = positive(o, 'size');
74
+ const density = positive(o, 'density');
75
+ const satin = positive(o, 'satin');
76
+ const stitch = positive(o, 'stitch');
77
+ const border = positive(o, 'border', true);
78
+ const resolution = positive(o, 'resolution');
79
+ const angle = o.angle ?? STITCH.angle;
80
+ if (typeof angle !== 'number' || !Number.isFinite(angle)) throw new TypeError(`stitchPlan: angle is a number of degrees (got ${JSON.stringify(o.angle)})`);
81
+ const seed = String(o.seed ?? STITCH.seed);
82
+
83
+ const paths = shapes(svg).map(({ segs, evenodd }) => ({ rings: rings(segs, 0.5), evenodd }));
84
+ if (!paths.length) throw new TypeError('stitchPlan: the SVG has nothing filled');
85
+ const vb = viewBox(svg) ?? [0, 0, 512, 512];
86
+ const mm = size / Math.max(vb[2], vb[3]);
87
+ const K = resolution * mm;
88
+ const box = frameOf(paths, K, 2);
89
+ const frame = { ...box, width: Math.ceil(box.width), height: Math.ceil(box.height) };
90
+ const bm = fill(paths, frame);
91
+ const { width: W, height: H } = bm;
92
+ const dist = distance(bm, 1);
93
+ const half = (satin / 2) * resolution;
94
+ const { wide, narrow } = splitByWidth(bm, dist, half);
95
+
96
+ const random = seeded(`stitch:${seed}`);
97
+ const toUnits = (px) => px.map((v, i) => Math.round(((i % 2 ? frame.y0 : frame.x0) + (v + 0.5) / K) * 1000) / 1000);
98
+ const rows = fillStitches(wide, W, H, { spacing: density * resolution, length: stitch * resolution, angle, random });
99
+ const threads = satinStitches(narrow, dist, W, H, { spacing: density * SATIN_DENSITY * resolution, half, smooth: 0.6 * resolution });
100
+ const edge = borderStitches(wide, dist, W, H, { width: border * resolution, spacing: density * resolution });
101
+ let [wideArea, narrowArea] = [0, 0];
102
+ for (let i = 0; i < W * H; i++) {
103
+ wideArea += wide[i];
104
+ narrowArea += narrow[i];
105
+ }
106
+ const px2 = resolution * resolution;
107
+ return {
108
+ viewBox: /** @type {[number, number, number, number]} */ (vb.slice(0, 4)),
109
+ mm,
110
+ settings: { size, density, satin, stitch, angle, border, resolution, seed },
111
+ fill: toUnits(rows),
112
+ satin: toUnits(threads),
113
+ border: toUnits(edge),
114
+ area: { fill: Math.round((wideArea / px2) * 10) / 10, satin: Math.round((narrowArea / px2) * 10) / 10 },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * How many stitches a plan has, of each kind and in all.
120
+ *
121
+ * @param {Pick<import('./plan.js').StitchPlan, 'fill' | 'satin' | 'border'>} plan The plan.
122
+ * @returns {{ fill: number, satin: number, border: number, total: number }} The counts.
123
+ */
124
+ export function stitchCount(plan) {
125
+ const [f, s, b] = [plan.fill.length / 4, plan.satin.length / 4, plan.border.length / 4];
126
+ return { fill: f, satin: s, border: b, total: f + s + b };
127
+ }
@@ -0,0 +1,4 @@
1
+ import type { Bitmap } from '../raster/scanline.js';
2
+
3
+ /** The ink split by width: `wide` where a disc of radius `half` (pixels) fits (the shape opened by it), `narrow` elsewhere; small corner slivers go to `wide`. */
4
+ export function splitByWidth(bm: Bitmap, dist: Float32Array, half: number): { wide: Uint8Array; narrow: Uint8Array };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module @logomarks/core/stitch/regions
3
+ *
4
+ * A mark's ink split by width, as an embroiderer splits it: the parts a
5
+ * disc wider than the longest satin stitch fits inside (the shape opened by
6
+ * that disc) are filled with rows of short stitches; the rest, the strokes
7
+ * and thin parts, are satin. The opening is two distance transforms. The
8
+ * slivers it leaves in a wide part's corners are small, and go back to the
9
+ * fill they belong to.
10
+ */
11
+
12
+ import { distance } from '../raster/distance.js';
13
+ import { regions } from './field.js';
14
+
15
+ /**
16
+ * The ink's wide and narrow parts.
17
+ *
18
+ * @param {import('../raster/scanline.js').Bitmap} bm The ink.
19
+ * @param {Float32Array} dist Each ink pixel's distance to the edge.
20
+ * @param {number} half Half the widest satin, in pixels.
21
+ * @returns {{ wide: Uint8Array, narrow: Uint8Array }} 1 where each is.
22
+ */
23
+ export function splitByWidth(bm, dist, half) {
24
+ const { width: W, height: H, data } = bm;
25
+ const n = W * H;
26
+ // the centres of discs wider than a satin stitch, then everything those discs cover
27
+ const core = new Uint8Array(n);
28
+ for (let i = 0; i < n; i++) core[i] = dist[i] > half ? 1 : 0;
29
+ const toCore = distance({ ...bm, data: core }, 0);
30
+ const wide = new Uint8Array(n);
31
+ const narrow = new Uint8Array(n);
32
+ for (let i = 0; i < n; i++) {
33
+ if (!data[i]) continue;
34
+ if (core[i] || toCore[i] <= half) wide[i] = 1;
35
+ else narrow[i] = 1;
36
+ }
37
+ // small narrow pieces against a wide part are the corners the disc couldn't reach: they're fill
38
+ const { label, sizes } = regions(narrow, W, H);
39
+ const touches = new Uint8Array(sizes.length);
40
+ for (let i = 0; i < n; i++) {
41
+ if (label[i] < 0) continue;
42
+ const x = i % W;
43
+ if ((x > 0 && wide[i - 1]) || (x < W - 1 && wide[i + 1]) || (i >= W && wide[i - W]) || (i < n - W && wide[i + W])) touches[label[i]] = 1;
44
+ }
45
+ const small = 2 * half * half;
46
+ for (let i = 0; i < n; i++) {
47
+ const l = label[i];
48
+ if (l >= 0 && touches[l] && sizes[l] < small) {
49
+ narrow[i] = 0;
50
+ wide[i] = 1;
51
+ }
52
+ }
53
+ return { wide, narrow };
54
+ }
@@ -0,0 +1,2 @@
1
+ /** Satin threads across a mask, edge to edge, along the stroke's local direction (pixel coordinates, flat `x0, y0, x1, y1` each). */
2
+ export function satinStitches(mask: Uint8Array, dist: Float32Array, W: number, H: number, o: { spacing: number; half: number; smooth: number }): number[];