@logomarks/core 0.1.5 → 0.1.8

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
  }
@@ -12,7 +12,7 @@ export type { SkelEnd, SkelStroke, Skeleton } from './letters.js';
12
12
  export type { GlitchOptions } from './glitch.js';
13
13
  export { C, GAP, polar, poly, mapPts, rot, move, scale, flipX, sample, closedCurve, polygonPath, TAU, rad } from './core.js';
14
14
  export { U, I, robust, robustOps, OPS, D, X } from './booleans.js';
15
- export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe } from './polygon.js';
15
+ export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing, closeRing } from './polygon.js';
16
16
  export { seeded } from './random.js';
17
17
  export { circle, ellipse, rect, regular, annulus, sector, arcBand, rotN, bar, ring, roundPoly, rr, lens, drop, squircle, pill, egg } from './shapes.js';
18
18
  export { stroke, weave, offsetPts, fillet, tracks, filletClosed, trackLoop, walk, balloon, linePts, arcPts, joinPts } from './stroke.js';
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { C, GAP, polar, poly, mapPts, rot, move, scale, flipX, sample, closedCurve, polygonPath, TAU, rad } from './core.js';
7
7
  export { U, I, robust, robustOps, OPS, D, X } from './booleans.js';
8
- export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe } from './polygon.js';
8
+ export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing, closeRing } from './polygon.js';
9
9
  export { seeded } from './random.js';
10
10
  export { circle, ellipse, rect, regular, annulus, sector, arcBand, rotN, bar, ring, roundPoly, rr, lens, drop, squircle, pill, egg } from './shapes.js';
11
11
  export { stroke, weave, offsetPts, fillet, tracks, filletClosed, trackLoop, walk, balloon, linePts, arcPts, joinPts } from './stroke.js';
@@ -2,6 +2,7 @@
2
2
  * Needle trimming for iso solids: where a round-ended gap meets a silhouette corner at a shallow angle, the face runs on
3
3
  * past the gap's end as a sliver about 1 wide; deneedle() cuts it off so the face ends square on the gap.
4
4
  */
5
+ import { openRing } from './polygon.js';
5
6
 
6
7
  /** Where line a–b meets line c–d. */
7
8
  const meet = (a, b, c, d) => { const r = [b[0] - a[0], b[1] - a[1]], s = [d[0] - c[0], d[1] - c[1]], den = r[0] * s[1] - r[1] * s[0];
@@ -9,9 +10,9 @@ const meet = (a, b, c, d) => { const r = [b[0] - a[0], b[1] - a[1]], s = [d[0] -
9
10
  /** Cuts the needles off a shape. A needle is a corner sharper than `deg` with one edge shorter than `len`: where a round-ended
10
11
  gap meets a silhouette corner at a shallow angle (the hex solids' 60° corners), the face runs on past the gap's end as a
11
12
  sliver about 1 wide. The tip and the short edge's far end become one point, where the long edge into the tip meets the
12
- edge out of the short one, so the face ends square on the gap. */
13
+ edge out of the short one, so the face ends square on the gap. Rings may come open or closed; they go back closed. */
13
14
  export const deneedle = (shape, { deg = 12, len = 16 } = {}) => shape.map((pg) => pg.map((ring) => {
14
- const q = ring.slice(0, -1), d = (u, v) => Math.hypot(u[0] - v[0], u[1] - v[1]);
15
+ const q = openRing(ring).slice(), d = (u, v) => Math.hypot(u[0] - v[0], u[1] - v[1]);
15
16
  for (let i = 0; i < q.length && q.length > 4; i++) { const n = q.length, w = (k) => q[(k + n) % n], v = q[i], a = w(i - 1), b = w(i + 1);
16
17
  const ang = Math.abs(Math.atan2((a[0] - v[0]) * (b[1] - v[1]) - (a[1] - v[1]) * (b[0] - v[0]), (a[0] - v[0]) * (b[0] - v[0]) + (a[1] - v[1]) * (b[1] - v[1])));
17
18
  if (ang * 180 / Math.PI >= deg) continue;
@@ -7,11 +7,11 @@ import { U, D, I } from './booleans.js';
7
7
  import { circle, rect, bar } from './shapes.js';
8
8
  import { stroke } from './stroke.js';
9
9
  import { dp } from './path.js';
10
- import { dedupe } from './polygon.js';
10
+ import { dedupe, openRing } from './polygon.js';
11
11
 
12
12
  const polys = (g) => (g.length && typeof g[0][0][0] === 'number' ? [g] : g);
13
13
  /** A ring's corner points, simplified (an open copy, split at its farthest point so the simplifier never sees first = last). */
14
- const ringPts = (r, tol = 0.2) => { const q = r.length > 1 && r[0][0] === r.at(-1)[0] && r[0][1] === r.at(-1)[1] ? r.slice(0, -1) : r; let far = 0, fd = -1;
14
+ const ringPts = (r, tol = 0.2) => { const q = openRing(r, 0); let far = 0, fd = -1;
15
15
  q.forEach((p, i) => { const d = Math.hypot(p[0] - q[0][0], p[1] - q[0][1]); if (d > fd) { fd = d; far = i; } });
16
16
  return [...dp(q.slice(0, far + 1), tol).slice(0, -1), ...dp([...q.slice(far), q[0]], tol).slice(0, -1)]; };
17
17
  /** A true round offset: grows a shape by d (shrinks it when d < 0), every corner rounded. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Shapes out: SVG path data (Douglas-Peucker simplified, one decimal), the SVG file, and writing it.
3
3
  */
4
+ import { openRing } from './polygon.js';
4
5
 
5
6
  // Douglas-Peucker on each ring, then path string (evenodd)
6
7
  export function dp(pts, tol) {
@@ -13,8 +14,10 @@ export function dp(pts, tol) {
13
14
  if (md > tol) { keep[mi] = 1; st.push([a, mi], [mi, b]); } }
14
15
  return pts.filter((_, i) => keep[i]);
15
16
  }
17
+ /** A shape as SVG path data (even-odd): each ring opened (closed or open, every corner kept), split at its farthest point,
18
+ simplified within `tol` and written to one decimal. */
16
19
  export function toPath(mp, tol = 0.12) {
17
- return mp.map(pg => pg.map(ring => { let pts = ring.slice(0, -1); let far = 0, fd = -1;
20
+ return mp.map(pg => pg.map(ring => { let pts = openRing(ring); let far = 0, fd = -1;
18
21
  pts.forEach((q, i) => { const d = Math.hypot(q[0] - pts[0][0], q[1] - pts[0][1]); if (d > fd) { fd = d; far = i; } });
19
22
  pts = [...dp(pts.slice(0, far + 1), tol).slice(0, -1), ...dp([...pts.slice(far), pts[0]], tol).slice(0, -1)];
20
23
  return pts.length < 3 ? '' : 'M' + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join('L') + 'Z'; }).join('')).join('');
@@ -16,3 +16,7 @@ export function bbox(g: Shape): Box;
16
16
  export function winding(r: readonly Point[], p: Point): number;
17
17
  /** The points without repeated consecutive ones. */
18
18
  export function dedupe<P extends Point>(pts: readonly P[]): P[];
19
+ /** A ring without its closing point: the last goes only when within `eps` (default 1e-6) of the first, so open rings keep every corner. */
20
+ export function openRing<P extends Point>(ring: readonly P[], eps?: number): P[];
21
+ /** A ring with its closing point: the first repeated at the end, unless the last is already within `eps` (default 1e-6) of it. */
22
+ export function closeRing<P extends Point>(ring: readonly P[], eps?: number): P[];
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Measures of a ring (a list of [x, y] points, closed implicitly) and of a shape: signed area, area, perimeter,
3
- * bounding box, winding number round a point, and consecutive duplicates dropped.
3
+ * bounding box, winding number round a point, consecutive duplicates dropped, and a ring opened or closed.
4
+ *
5
+ * Rings come both ways: the shape builders (poly, rect, circle, ellipse, rr, roundPoly, …) return them open (no repeated
6
+ * first point), the booleans (U, D, I, grow, lens, drop, …) closed (the first point repeated at the end). Code that wants
7
+ * one or the other asks for it with openRing() or closeRing() rather than slicing the last point off blind.
4
8
  */
5
9
 
6
10
  /**
@@ -91,3 +95,26 @@ export const dedupe = (pts) => pts.filter((p, i) => {
91
95
  const q = pts[(i + 1) % pts.length];
92
96
  return Math.hypot(p[0] - q[0], p[1] - q[1]) > 1e-6;
93
97
  });
98
+
99
+ /**
100
+ * A ring without its closing point: the last point goes only when it lies within `eps` of the first (a sampled full turn
101
+ * ends near, not exactly on, its start), so an open ring comes back as it is, every corner kept.
102
+ *
103
+ * @template {import('./types.js').Point} P
104
+ * @param {readonly P[]} ring The ring, open or closed.
105
+ * @param {number} [eps] How near the last point must be to the first to count as its repeat.
106
+ * @returns {P[]} The ring, open (the input itself when it already was).
107
+ */
108
+ export const openRing = (ring, eps = 1e-6) =>
109
+ /** @type {P[]} */ (ring.length > 1 && Math.hypot(ring[0][0] - ring[ring.length - 1][0], ring[0][1] - ring[ring.length - 1][1]) <= eps ? ring.slice(0, -1) : ring);
110
+
111
+ /**
112
+ * A ring with its closing point: the first point repeated at the end, unless the last already lies within `eps` of it.
113
+ *
114
+ * @template {import('./types.js').Point} P
115
+ * @param {readonly P[]} ring The ring, open or closed.
116
+ * @param {number} [eps] How near the last point must be to the first to count as its repeat.
117
+ * @returns {P[]} The ring, closed (the input itself when it already was).
118
+ */
119
+ export const closeRing = (ring, eps = 1e-6) =>
120
+ /** @type {P[]} */ (ring.length && openRing(ring, eps) === ring ? [...ring, ring[0]] : ring);
@@ -15,7 +15,7 @@ const SRC = new URL('../', import.meta.url);
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
17
  'geometry/path.js', 'geometry/offset.js', 'geometry/iso.js', 'geometry/modular.js', 'geometry/badges.js', 'geometry/letters.js',
18
- 'svg/path.js', 'svg/shapes.js', 'svg/rings.js', 'svg/shape.js', 'geometry/glitch.js', 'geometry/needle.js'];
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. */
21
21
  const FIT = ['svg/path.js', 'svg/shapes.js', 'svg/outline.js', 'util/number.js', 'fit/index.js'];
@@ -6,3 +6,4 @@ export { attr, shapes, viewBox, body } from './shapes.js';
6
6
  export { outlinePoints, bounds } from './outline.js';
7
7
  export { rings } from './rings.js';
8
8
  export { filledShape, svgShape, pathRings } from './shape.js';
9
+ export { nonzeroFill, windingFill, ringsMeet } from './nonzero.js';
package/src/svg/index.js CHANGED
@@ -7,3 +7,4 @@ export { attr, shapes, viewBox, body } from './shapes.js';
7
7
  export { outlinePoints, bounds } from './outline.js';
8
8
  export { rings } from './rings.js';
9
9
  export { filledShape, svgShape, pathRings } from './shape.js';
10
+ export { nonzeroFill, windingFill, ringsMeet } from './nonzero.js';
@@ -0,0 +1,12 @@
1
+ import type { BooleanOps } from '../geometry/booleans.js';
2
+ import type { Point, Ring, Shape } from '../geometry/types.js';
3
+
4
+ /** Whether any two rings meet: an edge of one crosses or touches an edge of another. */
5
+ export function ringsMeet(rs: readonly (readonly Point[])[]): boolean;
6
+ /** Exact non-zero fill by winding levels, however the rings overlap (each ring taken as simple). */
7
+ export function windingFill(rs: Ring[], b: BooleanOps): Shape;
8
+ /**
9
+ * Non-zero fill from rings: classed one by one when no two meet; by winding levels when any do, keeping the classed
10
+ * polygons when they cover the same ground.
11
+ */
12
+ export function nonzeroFill(rs: Ring[], b: BooleanOps): Shape;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @module @logomarks/core/svg/nonzero
3
+ *
4
+ * Non-zero fill from rings. polygon-clipping (the booleans' library) has no non-zero fill rule across rings: it takes
5
+ * a polygon's first ring as its outside and the rest as holes. So the fill is built from booleans: rings that never
6
+ * meet are classed one by one (union or cut), and rings that cross or touch (overlapping subpaths, as font glyphs
7
+ * draw) go through winding levels, which are exact however they overlap. (clipper-lib has a non-zero fill type, but it
8
+ * is only an optional peer here, for the shape edits.)
9
+ */
10
+
11
+ import { area, ringBox, signedArea, winding } from '../geometry/polygon.js';
12
+
13
+ /**
14
+ * Whether segments ab and cd meet (crossing, touching or overlapping along a line).
15
+ *
16
+ * @param {import('../geometry/types.js').Point} a
17
+ * @param {import('../geometry/types.js').Point} b
18
+ * @param {import('../geometry/types.js').Point} c
19
+ * @param {import('../geometry/types.js').Point} d
20
+ * @returns {boolean} True when they share a point.
21
+ */
22
+ function segmentsMeet(a, b, c, d) {
23
+ const side = (/** @type {number[]} */ p, /** @type {number[]} */ q, /** @type {number[]} */ r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
24
+ const within = (/** @type {number[]} */ p, /** @type {number[]} */ q, /** @type {number[]} */ r) =>
25
+ Math.min(p[0], q[0]) <= r[0] && r[0] <= Math.max(p[0], q[0]) && Math.min(p[1], q[1]) <= r[1] && r[1] <= Math.max(p[1], q[1]);
26
+ const d1 = side(c, d, a), d2 = side(c, d, b), d3 = side(a, b, c), d4 = side(a, b, d);
27
+ if (d1 * d2 < 0 && d3 * d4 < 0) return true;
28
+ return (d1 === 0 && within(c, d, a)) || (d2 === 0 && within(c, d, b)) || (d3 === 0 && within(a, b, c)) || (d4 === 0 && within(a, b, d));
29
+ }
30
+
31
+ /**
32
+ * Whether two boxes overlap (edges touching count).
33
+ *
34
+ * @param {import('../geometry/types.js').Box} p
35
+ * @param {import('../geometry/types.js').Box} q
36
+ * @returns {boolean} True when they share a point.
37
+ */
38
+ const boxesMeet = (p, q) => p[0] <= q[2] && q[0] <= p[2] && p[1] <= q[3] && q[1] <= p[3];
39
+
40
+ /**
41
+ * The edges of a ring that reach into a box, each as its two ends and its own box.
42
+ *
43
+ * @param {readonly import('../geometry/types.js').Point[]} r The ring.
44
+ * @param {import('../geometry/types.js').Box} box The box.
45
+ * @returns {[import('../geometry/types.js').Point, import('../geometry/types.js').Point, import('../geometry/types.js').Box][]} The edges.
46
+ */
47
+ function edgesIn(r, box) {
48
+ /** @type {[import('../geometry/types.js').Point, import('../geometry/types.js').Point, import('../geometry/types.js').Box][]} */
49
+ const out = [];
50
+ r.forEach((p, i) => {
51
+ const q = r[(i + 1) % r.length];
52
+ /** @type {import('../geometry/types.js').Box} */
53
+ const eb = [Math.min(p[0], q[0]), Math.min(p[1], q[1]), Math.max(p[0], q[0]), Math.max(p[1], q[1])];
54
+ if (boxesMeet(eb, box)) out.push([p, q, eb]);
55
+ });
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Whether any two of the rings meet: an edge of one crosses or touches an edge of another.
61
+ *
62
+ * @param {readonly (readonly import('../geometry/types.js').Point[])[]} rs The rings.
63
+ * @returns {boolean} True when two rings meet.
64
+ */
65
+ export function ringsMeet(rs) {
66
+ const boxes = rs.map(ringBox);
67
+ for (let i = 0; i < rs.length; i++) {
68
+ for (let j = i + 1; j < rs.length; j++) {
69
+ if (!boxesMeet(boxes[i], boxes[j])) continue;
70
+ /** @type {import('../geometry/types.js').Box} */
71
+ const both = [Math.max(boxes[i][0], boxes[j][0]), Math.max(boxes[i][1], boxes[j][1]), Math.min(boxes[i][2], boxes[j][2]), Math.min(boxes[i][3], boxes[j][3])];
72
+ const ei = edgesIn(rs[i], both), ej = edgesIn(rs[j], both);
73
+ for (const [a, b, ab] of ei) for (const [c, d, cb] of ej) if (boxesMeet(ab, cb) && segmentsMeet(a, b, c, d)) return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Non-zero fill for rings that never meet (nested or apart): largest first, each ring is added or cut by the winding
81
+ * just either side of its longest edge. With no other ring touching it, one test point pair speaks for the whole ring.
82
+ *
83
+ * @param {import('../geometry/types.js').Ring[]} rs The rings.
84
+ * @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
85
+ * @returns {import('../geometry/types.js').Shape} The filled shape.
86
+ */
87
+ function classedFill(rs, b) {
88
+ const sorted = rs.slice().sort((p, q) => Math.abs(signedArea(q)) - Math.abs(signedArea(p)));
89
+ const total = (/** @type {import('../geometry/types.js').Point} */ pt) => rs.reduce((s, r) => s + winding(r, pt), 0);
90
+ /** @type {import('../geometry/types.js').Shape} */
91
+ let g = [];
92
+ for (const r of sorted) {
93
+ let k = 0, longest = 0;
94
+ r.forEach((p, i) => {
95
+ const q = r[(i + 1) % r.length], L = Math.hypot(q[0] - p[0], q[1] - p[1]);
96
+ if (L > longest) { k = i; longest = L; }
97
+ });
98
+ const p = r[k], q = r[(k + 1) % r.length], e = 0.05;
99
+ const nx = ((q[1] - p[1]) / longest) * e, ny = ((q[0] - p[0]) / longest) * e;
100
+ const m = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
101
+ /** @type {import('../geometry/types.js').Point} */
102
+ const a = [m[0] - nx, m[1] + ny];
103
+ /** @type {import('../geometry/types.js').Point} */
104
+ const c = [m[0] + nx, m[1] - ny];
105
+ const [inside, outside] = winding(r, a) ? [a, c] : [c, a];
106
+ const fi = total(inside) !== 0, fo = total(outside) !== 0;
107
+ if (fi && !fo) g = b.union(g, [r]);
108
+ else if (!fi && fo) g = b.difference(g, [r]);
109
+ }
110
+ return g;
111
+ }
112
+
113
+ /**
114
+ * Exact non-zero fill for rings however they overlap. The plane is kept as winding levels (the region where the rings
115
+ * so far sum to each non-zero winding); each ring moves the part of every level it covers one step, up or down by its
116
+ * direction. The fill is every non-zero level unioned. Each ring is taken as simple, wound one way.
117
+ *
118
+ * @param {import('../geometry/types.js').Ring[]} rs The rings.
119
+ * @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
120
+ * @returns {import('../geometry/types.js').Shape} The filled shape.
121
+ */
122
+ export function windingFill(rs, b) {
123
+ /** @type {Map<number, import('../geometry/types.js').Shape>} */
124
+ let levels = new Map();
125
+ for (const r of rs) {
126
+ const s = Math.sign(signedArea(r)), R = b.union([r]);
127
+ /** @type {Map<number, import('../geometry/types.js').Shape>} */
128
+ const next = new Map();
129
+ const add = (/** @type {number} */ k, /** @type {import('../geometry/types.js').Shape} */ g) => {
130
+ if (k === 0 || !g.length) return;
131
+ const had = next.get(k);
132
+ next.set(k, had ? b.union(had, g) : g);
133
+ };
134
+ add(s, levels.size ? b.difference(R, ...levels.values()) : R);
135
+ for (const [k, g] of levels) {
136
+ add(k, b.difference(g, R));
137
+ add(k + s, b.intersection(g, R));
138
+ }
139
+ levels = next;
140
+ }
141
+ return b.union(...levels.values());
142
+ }
143
+
144
+ /**
145
+ * A shape's filled area (outsides less holes).
146
+ *
147
+ * @param {import('../geometry/types.js').Shape} g The shape.
148
+ * @returns {number} The area.
149
+ */
150
+ const filledArea = (g) => g.reduce((t, pg) => t + area(pg[0]) - pg.slice(1).reduce((h, r) => h + area(r), 0), 0);
151
+
152
+ /**
153
+ * Non-zero fill from rings. When no two rings meet, each is classed on its own (as filledShape always read them).
154
+ * When any two cross or touch, the winding levels decide; the classed polygons are still returned when they cover the
155
+ * same ground (their exclusive-or has no area to speak of), so an outline that always read right keeps its exact
156
+ * polygons, and only a misread one (an outer whose longest edge lies inside another ring) changes.
157
+ *
158
+ * @param {import('../geometry/types.js').Ring[]} rs The rings.
159
+ * @param {import('../geometry/booleans.js').BooleanOps} b The booleans.
160
+ * @returns {import('../geometry/types.js').Shape} The filled shape.
161
+ */
162
+ export function nonzeroFill(rs, b) {
163
+ if (!ringsMeet(rs)) return classedFill(rs, b);
164
+ const exact = windingFill(rs, b), classed = classedFill(rs, b);
165
+ return filledArea(b.xor(classed, exact)) <= 1e-9 * Math.max(1, filledArea(exact)) ? classed : exact;
166
+ }