@logomarks/core 0.1.7 → 0.1.9

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/symmetry
3
+ *
4
+ * How well a mark maps onto itself under a mirror or a turn, measured two ways: its coverage (overlap over union,
5
+ * which a big solid mass dominates) and its outline (the share of outline pixels that land on the outline, which a
6
+ * small asymmetric detail inside a badge still breaks).
7
+ */
8
+
9
+ import { distance } from '../../raster/distance.js';
10
+ import { bitmap } from './ink.js';
11
+
12
+ /** @typedef {(x: number, y: number) => [number, number]} PixelMap */
13
+
14
+ /**
15
+ * Coverage at a point between pixel centres (bilinear; 0 outside).
16
+ *
17
+ * @param {Float32Array} a Coverage, `n` × `n`.
18
+ * @param {number} n The side.
19
+ * @param {number} x Pixel coordinates (centres at whole numbers).
20
+ * @param {number} y
21
+ * @returns {number} The coverage there.
22
+ */
23
+ function sample(a, n, x, y) {
24
+ const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
25
+ const at = (/** @type {number} */ i, /** @type {number} */ j) => (i < 0 || j < 0 || i >= n || j >= n ? 0 : a[j * n + i]);
26
+ return at(x0, y0) * (1 - fx) * (1 - fy) + at(x0 + 1, y0) * fx * (1 - fy) + at(x0, y0 + 1) * (1 - fx) * fy + at(x0 + 1, y0 + 1) * fx * fy;
27
+ }
28
+
29
+ /**
30
+ * The coverage's overlap with itself mapped by `f` (intersection over union).
31
+ *
32
+ * @param {import('./ink.js').Ink} ink The ink.
33
+ * @param {PixelMap} f Where each pixel centre (x + 0.5, y + 0.5) comes from.
34
+ * @returns {number} 0–1.
35
+ */
36
+ export function coverageMatch({ a, n }, f) {
37
+ let i = 0, u = 0;
38
+ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
39
+ const [sx, sy] = f(x + 0.5, y + 0.5), p = a[y * n + x], q = sample(a, n, sx - 0.5, sy - 0.5);
40
+ i += Math.min(p, q); u += Math.max(p, q);
41
+ }
42
+ return u ? i / u : 0;
43
+ }
44
+
45
+ /**
46
+ * A matcher for the outline: maps each outline pixel and counts those landing within `tol` pixels of the outline.
47
+ *
48
+ * @param {import('./ink.js').Ink} ink The ink.
49
+ * @param {number[]} outline Its outline pixels (`outlineOf`).
50
+ * @returns {(f: PixelMap, tol: number) => number} The share matched, 0–1, for a map and a tolerance in pixels.
51
+ */
52
+ export function outlineMatcher({ n }, outline) {
53
+ const edge = new Uint8Array(n * n);
54
+ for (const k of outline) edge[k] = 1;
55
+ const near = distance(bitmap(edge, n), 0);
56
+ return (f, tol) => {
57
+ let ok = 0;
58
+ for (const k of outline) {
59
+ const x = k % n, [X, Y] = f(x + 0.5, (k - x) / n + 0.5), i = Math.floor(X), j = Math.floor(Y);
60
+ if (i >= 0 && j >= 0 && i < n && j < n && near[j * n + i] <= tol) ok++;
61
+ }
62
+ return outline.length ? ok / outline.length : 0;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The mirror across the vertical line x = `cx`.
68
+ *
69
+ * @param {number} cx The axis, in pixels.
70
+ * @returns {PixelMap} The map.
71
+ */
72
+ export const mirrorX = (cx) => (x, y) => [2 * cx - x, y];
73
+
74
+ /**
75
+ * A turn by a whole circle over `order` about `c`.
76
+ *
77
+ * @param {number} order How many turns make the circle.
78
+ * @param {[number, number]} c The centre, in pixels.
79
+ * @returns {PixelMap} The map.
80
+ */
81
+ export function turn(order, [cx, cy]) {
82
+ const t = 2 * Math.PI / order, co = Math.cos(t), si = Math.sin(t);
83
+ return (x, y) => [cx + (x - cx) * co - (y - cy) * si, cy + (x - cx) * si + (y - cy) * co];
84
+ }
@@ -0,0 +1,6 @@
1
+ import type { Ink } from './ink.js';
2
+
3
+ /** The share of the ink in parts thicker than 2r (covered by discs of radius `r` pixels inside it), 0–1. */
4
+ export function thickShare(ink: Pick<Ink, 'bin' | 'n' | 'count'>, depth: Float32Array, r: number): number;
5
+ /** How drawn-out the pieces are: area over width squared per piece, averaged by area (a stroke ≈ length / width). */
6
+ export function elongation(ink: Pick<Ink, 'bin' | 'n'>, depth: Float32Array): number;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/thickness
3
+ *
4
+ * How thick a mark's ink is, from its distance transform: the share of the ink in parts thicker than a width (what
5
+ * an opening by a disc keeps), and how drawn-out its pieces are (a stroke is long for its width; a dot or a block
6
+ * isn't).
7
+ */
8
+
9
+ import { distance } from '../../raster/distance.js';
10
+ import { bitmap } from './ink.js';
11
+
12
+ /**
13
+ * The share of the ink covered by discs of radius `r` that fit inside it: the ink in parts thicker than 2r.
14
+ *
15
+ * @param {import('./ink.js').Ink} ink The ink.
16
+ * @param {Float32Array} depth Each ink pixel's distance to the empty space (`distance(ink, 1)`).
17
+ * @param {number} r The radius in pixels.
18
+ * @returns {number} 0–1.
19
+ */
20
+ export function thickShare({ bin, n, count }, depth, r) {
21
+ const core = new Uint8Array(n * n);
22
+ let any = false;
23
+ for (let k = 0; k < n * n; k++) if (bin[k] && depth[k] >= r) { core[k] = 1; any = true; }
24
+ if (!any || !count) return 0;
25
+ const near = distance(bitmap(core, n), 0);
26
+ let kept = 0;
27
+ for (let k = 0; k < n * n; k++) if (bin[k] && near[k] <= r) kept++;
28
+ return kept / count;
29
+ }
30
+
31
+ /**
32
+ * How drawn-out the pieces are: each 8-connected piece's area over its width squared (its width four times its mean
33
+ * depth, exact for a long even stroke), averaged by area. A stroke L long and w wide scores about L / w; a dot about
34
+ * 1.8, a square block 2.3.
35
+ *
36
+ * @param {import('./ink.js').Ink} ink The ink.
37
+ * @param {Float32Array} depth Each ink pixel's distance to the empty space.
38
+ * @returns {number} The area-weighted mean.
39
+ */
40
+ export function elongation({ bin, n }, depth) {
41
+ const seen = new Uint8Array(n * n), stack = [];
42
+ let sum = 0, total = 0;
43
+ for (let s = 0; s < n * n; s++) {
44
+ if (!bin[s] || seen[s]) continue;
45
+ let area = 0, deep = 0;
46
+ seen[s] = 1;
47
+ stack.push(s);
48
+ while (stack.length) {
49
+ const k = /** @type {number} */ (stack.pop()), x = k % n, y = (k - x) / n;
50
+ area++;
51
+ deep += depth[k];
52
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
53
+ const X = x + dx, Y = y + dy, j = Y * n + X;
54
+ if (X >= 0 && Y >= 0 && X < n && Y < n && bin[j] && !seen[j]) { seen[j] = 1; stack.push(j); }
55
+ }
56
+ }
57
+ const width = 4 * deep / area;
58
+ sum += area * area / (width * width);
59
+ total += area;
60
+ }
61
+ return total ? sum / total : 0;
62
+ }
@@ -1,5 +1,21 @@
1
+ import type { ShapeMeasures } from './shape/measures.js';
2
+
1
3
  /** A shape tag measured from a mark's raster. */
2
4
  export type ShapeTag = 'wide' | 'tall' | 'round' | 'square' | 'solid' | 'light' | 'symmetric' | 'radial';
3
5
 
4
- /** A fitted mark's shape tags, from its coverage at `size` px (128) and its outline's width and height. */
6
+ /** The thresholds the tags are decided by. */
7
+ export const SHAPE: {
8
+ wide: number;
9
+ tall: number;
10
+ squarish: number;
11
+ round: { reach: number; ways: Partial<Record<'arc' | 'rim' | 'circularity', number>>[] };
12
+ square: { fill: number; corners: number };
13
+ symmetric: { coverage: number; outline: number };
14
+ radial: { coverage: number; outline: number };
15
+ light: { elongation: number; thick: number };
16
+ };
17
+
18
+ /** The shape tags a mark's measures earn, in a fixed order; exactly one of solid and light. */
19
+ export function tagsOf(ms: ShapeMeasures, t?: typeof SHAPE): ShapeTag[];
20
+ /** A fitted mark's shape tags, from its coverage at `size` px (256) and its outline's width and height. */
5
21
  export function shapeTags(a: Float32Array, m: { w: number; h: number; size?: number }): ShapeTag[];
@@ -2,47 +2,82 @@
2
2
  * @module @logomarks/core/analyse/tags
3
3
  *
4
4
  * Shape tags measured from a mark's coverage: wide or tall, round or square, solid or light, symmetric, radial.
5
+ * Tuned on the 4,040 Logomarks marks against contact sheets (about 95% read right by eye; see docs/tags.md).
5
6
  */
6
7
 
7
- import { remap, iou } from '../similar/signature.js';
8
+ import { shapeMeasures } from './shape/measures.js';
9
+
10
+ /** The thresholds the tags are decided by: the Logomarks settings. */
11
+ export const SHAPE = {
12
+ /** Width over height beyond which a mark is wide, or below which it is tall. */
13
+ wide: 1.25,
14
+ tall: 0.8,
15
+ /** Round and square marks are about as wide as tall: between this and its inverse. */
16
+ squarish: 0.9,
17
+ /** Round: the ink reaches the hull all round, and the hull runs on a circle (any one way). */
18
+ round: {
19
+ reach: 0.6,
20
+ ways: [
21
+ { arc: 0.74, circularity: 0.89 },
22
+ { arc: 0.65, circularity: 0.96 },
23
+ { rim: 0.8, circularity: 0.97 },
24
+ ],
25
+ },
26
+ /** Square: the closed silhouette fills its box, every corner too (and it isn't round). */
27
+ square: { fill: 0.88, corners: 0.6 },
28
+ /** Symmetric: the left-right mirror matches, coverage and outline both. */
29
+ symmetric: { coverage: 0.95, outline: 0.95 },
30
+ /** Radial: a turn of order 3, 4 or 5 matches, coverage and outline both. */
31
+ radial: { coverage: 0.9, outline: 0.9 },
32
+ /** Light: strokes (drawn-out pieces) with little mass; every other mark is solid. */
33
+ light: { elongation: 3, thick: 0.3 },
34
+ };
35
+
36
+ /**
37
+ * Whether the measures clear every threshold in `need`.
38
+ *
39
+ * @param {import('./shape/measures.js').ShapeMeasures} ms The measures.
40
+ * @param {Partial<Record<'arc' | 'rim' | 'circularity', number>>} need The thresholds, by measure.
41
+ * @returns {boolean} All met.
42
+ */
43
+ const meets = (ms, need) => Object.entries(need).every(([k, v]) => ms[/** @type {'arc' | 'rim' | 'circularity'} */ (k)] >= /** @type {number} */ (v));
44
+
45
+ /**
46
+ * The shape tags a mark's measures earn.
47
+ *
48
+ * @param {import('./shape/measures.js').ShapeMeasures} ms The measures (`shapeMeasures`).
49
+ * @param {typeof SHAPE} [t] The thresholds (SHAPE by default).
50
+ * @returns {import('./tags.js').ShapeTag[]} The tags, in a fixed order; exactly one of solid and light.
51
+ */
52
+ export function tagsOf(ms, t = SHAPE) {
53
+ /** @type {import('./tags.js').ShapeTag[]} */
54
+ const tags = [];
55
+ if (ms.ratio > t.wide) tags.push('wide'); else if (ms.ratio < t.tall) tags.push('tall');
56
+ const squarish = ms.ratio > t.squarish && ms.ratio < 1 / t.squarish;
57
+ if (squarish && ms.reach >= t.round.reach && t.round.ways.some((way) => meets(ms, way))) tags.push('round');
58
+ else if (squarish && ms.closedFill >= t.square.fill && ms.closedCorners >= t.square.corners) tags.push('square');
59
+ const light = ms.elongation >= t.light.elongation && Math.min(ms.thick, ms.thickRelative) < t.light.thick;
60
+ tags.push(light ? 'light' : 'solid');
61
+ if (ms.mirror.coverage >= t.symmetric.coverage && ms.mirror.outline >= t.symmetric.outline) tags.push('symmetric');
62
+ if (ms.turns.some((r) => r.coverage >= t.radial.coverage && r.outline >= t.radial.outline)) tags.push('radial');
63
+ return tags;
64
+ }
8
65
 
9
66
  /**
10
- * A fitted mark's shape tags, from its coverage at `size` px (128) and its outline's size on the 512 canvas.
67
+ * A fitted mark's shape tags, from its coverage at `size` px (256) and its outline's size on the 512 canvas.
11
68
  * - wide / tall: wider than 1.25 or narrower than 0.8;
12
- * - round: about square, every corner of its box empty and the silhouette reaching the edge midpoints and diagonals;
13
- * - square: about square with three corners or more filled;
14
- * - solid / light: over 62% or under 30% of its box inked;
15
- * - symmetric: mirrors onto itself (IoU over 0.94); radial: a quarter turn (0.92) or a half turn (0.94) does.
69
+ * - round: about square (0.9–1.11), the ink reaching its convex hull all round, and the hull running on a circle;
70
+ * - square: about square, not round, and its silhouette (gaps up to 60 closed, holes filled) filling its box to the
71
+ * corners;
72
+ * - solid / light: light when it's drawn in strokes with little mass (under 30% of the ink in parts thicker than 48
73
+ * units, or than 12% of its short side); every other mark is solid;
74
+ * - symmetric: its left-right mirror matches (coverage and outline 0.95); a top-bottom mirror alone doesn't count;
75
+ * - radial: a turn by a third, a quarter or a fifth matches (0.9): rotational symmetry of order 3 or more.
16
76
  *
17
77
  * @param {Float32Array} a Coverage 0–1, `size` × `size`, row by row.
18
78
  * @param {{ w: number, h: number, size?: number }} m The outline's width and height (fitted), and the raster's side.
19
79
  * @returns {import('./tags.js').ShapeTag[]} The tags, in a fixed order.
20
80
  */
21
- export function shapeTags(a, { w: mw, h: mh, size: N = 128 }) {
22
- const tags = new Set();
23
- const tf = (/** @type {(x: number, y: number) => number[]} */ f) => remap(a, N, f);
24
- // bbox in raster px
25
- const s = N / 512, x0 = Math.round((256 - mw / 2) * s), y0 = Math.round((256 - mh / 2) * s), w = Math.round(mw * s), h = Math.round(mh * s);
26
- let fill = 0; for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) fill += a[y * N + x] || 0;
27
- const density = fill / (w * h);
28
- const ratio = mw / mh;
29
- if (ratio > 1.25) tags.add('wide'); else if (ratio < 0.8) tags.add('tall');
30
- // corners of the bbox: empty everywhere → round silhouette; mostly filled → square
31
- const c = Math.max(2, Math.round(Math.min(w, h) * 0.1));
32
- const corner = (/** @type {number} */ cx, /** @type {number} */ cy) => { let f = 0; for (let y = cy; y < cy + c; y++) for (let x = cx; x < cx + c; x++) f += a[y * N + x] || 0; return f / (c * c); };
33
- const cs = [corner(x0, y0), corner(x0 + w - c, y0), corner(x0, y0 + h - c), corner(x0 + w - c, y0 + h - c)];
34
- const sq = ratio > 0.92 && ratio < 1.08;
35
- if (sq && cs.every((v) => v < 0.04)) {
36
- // also require the silhouette to reach the edge midpoints (a disc, not a diamond or a small blob)
37
- const mid = (/** @type {number} */ x, /** @type {number} */ y) => a[y * N + x] > 0.5;
38
- const r = Math.round(w / 2), cxp = x0 + r, cyp = y0 + r, d = Math.round(w * 0.06);
39
- const diag = Math.round(r * 0.70);
40
- if ([mid(cxp, y0 + d), mid(cxp, y0 + h - 1 - d), mid(x0 + d, cyp), mid(x0 + w - 1 - d, cyp)].filter(Boolean).length >= 3 &&
41
- [mid(cxp - diag + d, cyp - diag + d), mid(cxp + diag - d, cyp - diag + d), mid(cxp - diag + d, cyp + diag - d), mid(cxp + diag - d, cyp + diag - d)].filter(Boolean).length >= 3) tags.add('round');
42
- }
43
- if (sq && cs.filter((v) => v > 0.6).length >= 3) tags.add('square');
44
- if (density > 0.62) tags.add('solid'); else if (density < 0.3) tags.add('light');
45
- if (iou(a, tf((x, y) => [N - 1 - x, y])) > 0.94 || iou(a, tf((x, y) => [x, N - 1 - y])) > 0.94) tags.add('symmetric');
46
- if (iou(a, tf((x, y) => [y, N - 1 - x])) > 0.92 || iou(a, tf((x, y) => [N - 1 - x, N - 1 - y])) > 0.94) tags.add('radial');
47
- return /** @type {import('./tags.js').ShapeTag[]} */ ([...tags]);
81
+ export function shapeTags(a, m) {
82
+ return tagsOf(shapeMeasures(a, m));
48
83
  }
package/src/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './raster/index.js';
6
6
  export * from './analyse/index.js';
7
7
  export * from './similar/index.js';
8
8
  export * from './edit/index.js';
9
+ export * from './stitch/index.js';
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * @logomarks/core: geometry, SVG reading, fitting, analysis, similarity and shape edits for logomarks on a 512
3
- * grid. Each area is also its own subpath (`/geometry`, `/svg`, `/fit`, `/raster`, `/analyse`, `/similar`, `/edit`,
2
+ * @logomarks/core: geometry, SVG reading, fitting, analysis, similarity, shape edits and stitch plans for logomarks
3
+ * on a 512 grid. Each area is also its own subpath (`/geometry`, `/svg`, `/fit`, `/raster`, `/analyse`, `/similar`, `/edit`, `/stitch`,
4
4
  * and `/parts` for the industry-symbol parts, which needs Node for its batch writer).
5
5
  */
6
6
  export * from './geometry/index.js';
@@ -10,3 +10,4 @@ export * from './raster/index.js';
10
10
  export * from './analyse/index.js';
11
11
  export * from './similar/index.js';
12
12
  export * from './edit/index.js';
13
+ export * from './stitch/index.js';
@@ -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';