@logomarks/core 0.1.9 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to `@logomarks/core` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.11] — 2026-11-15
9
+
10
+ ### Added
11
+
12
+ - **Exact mitred offsets in `/geometry`:** `mitreOffset(pts, d)` (an open polyline's parallel, corners mitred), `mitreBand(pts, w)` (a solid band along it, ends square) and `offsetPolygon(pts, d)` (a closed polygon grown or shrunk, either winding). The Logomarks tools had them five ways; these are the same arithmetic as the ones there.
13
+ - **Span tiles in `/parts`:** `spanGrid`, `spanTile`, `evenGrid`, `evenCell` and `longSide`: a tile grid where one token may span a block of cells (`tok^2`, `tok>2`, `tok_2`, `tok@2x3`), from the tools' span parts.
14
+ - **Pixel editions in `/parts`:** `rasterRows`, `rasterMark` (a drawn mark sampled into rows of text, `#` a pixel, on spans or square pixels with a phase and hand fixes) and `insideShape` (even-odd point in shape), from the tools' dither parts.
15
+ - **Golden tests:** a hash of a drawing by every geometry and parts export the other tests don't reach (`tests/golden.json`; refresh with `GOLDEN=update npm test`).
16
+
17
+ ### Fixed
18
+
19
+ - `nest()`'s type: `cut` is a function `(w, h, side) => number`, as the code has always read it (the type said a number would do).
20
+
21
+ Proved on the Logomarks generators: the nineteen drawn with span tiles or pixel rasters, the ten drawn with the tools' mitred offsets and the twelve drawn with distance fields all redraw byte-identical with these in place of their local copies.
22
+
23
+ ## [0.1.10] — 2026-11-15
24
+
25
+ ### Fixed
26
+
27
+ - **`fitMark()` no longer moves a mark that's already fitted.** A finished mark's points sit on the 0.1 grid, so it measured a hair off 448 and was scaled by that hair: points tipped over a rounding edge and arc radii, rounded down, lost 0.1 on every refit. Of the 5,255 Logomarks marks, 24 changed on a refit and 16 never settled (Trio 52.9 → 52.8 → 52.7 …). A mark on the 512 canvas whose longest side and centre are within `REFIT_TOLERANCE` (0.25) keeps its scale and position, and an arc radius within `ARC_SLACK` (0.005) of the next 0.1 step is taken as it. Every live mark now refits to the same numbers (four older files only have their spacing normalised, once). Semicircles still round down.
28
+ - `runIndustry()`'s `fam:` also takes the family as passed, so `fam:Letters · Latch` builds a batch that passes whole families as its trades (it built none).
29
+
30
+ ### Added
31
+
32
+ - **`/batch`: running a generator (Node).** `runBatch(batch, marks, options)` draws each mark seeded by its name (and puts `Math.random` back), with `exact()` so the gap is 22 once fitted, into `<workdir>/newraw<N>/`, and writes `_names.json`, `_families.json`, `_styles.json`, and `_variants.json` / `_pairs.json` when used; held marks are left out, and re-running one mark keeps every sidecar whole. Also `batchArgs` and `selected` (`"Name,Name"`, `fam:`, `pair:` and the generator's own modes), `sidecars`, `rawSvg`, `withSeed`, `slug` (the one slug: no hyphen at either end) and `sourceMark` (an edition's source: live first, then the queue; `LOGOMARKS_SVG`, `LOGOMARKS_QUEUE`). See [docs/api.md](docs/api.md#batches).
33
+ - **`/sdf`: signed distance fields**, from the Logomarks tools' Y2K parts: the fields (`sdCircle`, `sdCapsule`, `sdPath`, `sdBox`, `sdEllipse`, `sdRing`, and the new exact `sdPoly`), smooth and sharp combining (`smin`, `smax`, `ssub`, `fmin`, and the new `fmax` and `fsub`), the gap (`gapCut`, `fover`, `flink`) and `shapeOf`, which traces a field into a kit shape. The twelve generators drawn with them redraw byte-identical on it. See [docs/api.md](docs/api.md#distance-fields).
34
+
8
35
  ## [0.1.9] — 2026-11-08
9
36
 
10
37
  ### Added
package/README.md CHANGED
@@ -25,13 +25,15 @@ checkFit(mark).problems; // []
25
25
 
26
26
  - `/geometry`: shapes and real booleans, strokes, tracks, iso solids, badges and letter skeletons
27
27
  - `/svg`: one SVG path parser, filled shapes, bounds and rings
28
- - `/fit`: the 448 fit and its check
28
+ - `/fit`: the 448 fit and its check (refitting a fitted mark gives the same file)
29
29
  - `/analyse`: specks, slivers and pin-holes, gap and stroke widths, shape tags
30
30
  - `/similar`: outline and letter signatures, IoU and nearest marks
31
31
  - `/raster`: scanline fill, components, distance transform
32
32
  - `/edit`: the studio's shape edits (weight, soften, gap, detail) and pixelate
33
+ - `/sdf`: shapes as signed distance fields, smooth unions and exact gap cuts, traced to kit shapes
33
34
  - `/stitch`: a mark planned as machine embroidery (fill, satin and border stitches), for a renderer to draw as thread
34
35
  - `/parts`: the parts the industry symbols are built from (Node)
36
+ - `/batch`: a generator's runner: seeded marks, the sidecars, the command line, `slug` (Node)
35
37
  - `logomarks-skill <dir>`: writes the design skill's `kit.mjs` and `fit.mjs`
36
38
 
37
39
  Every export, repeatable drawings and the skill scripts: [API](https://github.com/Logomarks/core/blob/main/docs/api.md).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@logomarks/core",
3
- "version": "0.1.9",
4
- "description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, stitch plans for embroidery, and the industry-symbol parts.",
3
+ "version": "0.1.11",
4
+ "description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, signed distance fields traced to shapes, stitch plans for embroidery, the industry-symbol parts and the batch runner generators use.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "types": "src/index.d.ts",
@@ -22,6 +22,11 @@
22
22
  "import": "./src/parts/index.js",
23
23
  "default": "./src/parts/index.js"
24
24
  },
25
+ "./batch": {
26
+ "types": "./src/batch/index.d.ts",
27
+ "import": "./src/batch/index.js",
28
+ "default": "./src/batch/index.js"
29
+ },
25
30
  "./svg": {
26
31
  "types": "./src/svg/index.d.ts",
27
32
  "import": "./src/svg/index.js",
@@ -47,6 +52,11 @@
47
52
  "import": "./src/similar/index.js",
48
53
  "default": "./src/similar/index.js"
49
54
  },
55
+ "./sdf": {
56
+ "types": "./src/sdf/index.d.ts",
57
+ "import": "./src/sdf/index.js",
58
+ "default": "./src/sdf/index.js"
59
+ },
50
60
  "./edit": {
51
61
  "types": "./src/edit/index.d.ts",
52
62
  "import": "./src/edit/index.js",
@@ -91,7 +101,9 @@
91
101
  "fit",
92
102
  "similarity",
93
103
  "embroidery",
94
- "satin-stitch"
104
+ "satin-stitch",
105
+ "signed-distance-field",
106
+ "sdf"
95
107
  ],
96
108
  "author": "David Sherlock",
97
109
  "license": "MIT",
@@ -0,0 +1,18 @@
1
+ /** What a generator was asked to build. */
2
+ export interface BatchArgs {
3
+ /** Where the batch's `newraw<N>/` folder goes. */
4
+ workdir: string;
5
+ /** Only these marks, by name; null for no name list. */
6
+ names: string[] | null;
7
+ /** Only this family's marks; null for any. */
8
+ family: string | null;
9
+ /** Only the monograms of these letter pairs; null for any. */
10
+ pairs: string[] | null;
11
+ /** A mode the generator handles itself (one of its `modes`); null for none. */
12
+ mode: string | null;
13
+ }
14
+
15
+ /** The work folder and the selection from `node genbatchN.mjs <workdir> ["Name,Name" | "fam:…" | "pair:DS,JS" | mode]`. */
16
+ export function batchArgs(argv?: readonly string[], options?: { modes?: readonly string[] }): BatchArgs;
17
+ /** Whether a mark is in the selection (in no mark when a mode was asked for). */
18
+ export function selected(args: BatchArgs, name: string, mark: { family?: string; pair?: string }): boolean;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A generator's command line: `node genbatchN.mjs <workdir> [what]`.
3
+ */
4
+
5
+ /**
6
+ * What a generator was asked to build. `what` is one of:
7
+ * - nothing: every mark;
8
+ * - `"Name,Name"`: those marks;
9
+ * - `"fam:<family>"`: one family's marks;
10
+ * - `"pair:DS,JS"`: the monograms of those letter pairs;
11
+ * - one of `modes` (e.g. `"matrix"`): a mode the generator handles itself.
12
+ *
13
+ * @param {readonly string[]} [argv] The arguments after the script (default `process.argv.slice(2)`).
14
+ * @param {{ modes?: readonly string[] }} [options] The generator's own modes.
15
+ * @returns {import('./args.js').BatchArgs} The work folder and the selection.
16
+ * @throws {Error} When there's no work folder.
17
+ */
18
+ export function batchArgs(argv = process.argv.slice(2), { modes = [] } = {}) {
19
+ const [workdir, what = ''] = argv;
20
+ if (!workdir) throw new Error('usage: node genbatchN.mjs <workdir> ["Name,Name" | "fam:<family>" | "pair:DS,JS"]');
21
+ const list = (/** @type {string} */ s) => s.split(',').map((x) => x.trim()).filter(Boolean);
22
+ return {
23
+ workdir,
24
+ names: what && !/^(fam|pair):/.test(what) && !modes.includes(what) ? list(what) : null,
25
+ family: what.startsWith('fam:') ? what.slice(4).trim() : null,
26
+ pairs: what.startsWith('pair:') ? list(what.slice(5)) : null,
27
+ mode: modes.includes(what) ? what : null,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Whether a mark is in the selection.
33
+ *
34
+ * @param {import('./args.js').BatchArgs} args The selection.
35
+ * @param {string} name The mark's name.
36
+ * @param {{ family?: string, pair?: string }} mark Its family and letter pair.
37
+ * @returns {boolean} True when it should be built.
38
+ */
39
+ export function selected(args, name, mark) {
40
+ if (args.names) return args.names.includes(name);
41
+ if (args.family) return mark.family === args.family;
42
+ if (args.pairs) return !!mark.pair && args.pairs.includes(mark.pair);
43
+ return !args.mode;
44
+ }
@@ -0,0 +1,7 @@
1
+ export { slug } from './slug.js';
2
+ export { batchArgs, selected } from './args.js';
3
+ export type { BatchArgs } from './args.js';
4
+ export { sourceMark } from './source.js';
5
+ export type { SourceMark } from './source.js';
6
+ export { runBatch, sidecars, rawSvg, withSeed } from './run.js';
7
+ export type { BatchMark, BatchOptions, BatchResult } from './run.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @module @logomarks/core/batch
3
+ *
4
+ * Running a generator (Node only): the command line, a mark's slug, reading an existing mark for an edition, and the
5
+ * runner that draws a batch seeded per mark and writes the sidecars the export and the review page read.
6
+ */
7
+ export { slug } from './slug.js';
8
+ export { batchArgs, selected } from './args.js';
9
+ export { sourceMark } from './source.js';
10
+ export { runBatch, sidecars, rawSvg, withSeed } from './run.js';
@@ -0,0 +1,53 @@
1
+ import type { Shape } from '../geometry/types.js';
2
+ import type { BatchArgs } from './args.js';
3
+
4
+ /** One mark in a batch. */
5
+ export interface BatchMark {
6
+ /** Its family, e.g. "Letters · Latch" (`_families.json`; `fam:` selects by it). */
7
+ family: string;
8
+ /** Its style, e.g. "Bold" (`_styles.json`). */
9
+ style?: string;
10
+ /** The live mark it is an edition or variation of, by slug (`_variants.json`). */
11
+ variantOf?: string;
12
+ /** Its letter pair, for monograms (`_pairs.json`; `pair:` selects by it). */
13
+ pair?: string;
14
+ /** The drawing: takes the gap (or nothing with `gap: false`) and returns the mark. */
15
+ build: (g: number) => Shape;
16
+ }
17
+
18
+ /** How a batch is drawn. */
19
+ export interface BatchOptions {
20
+ /** Draw each build with the gap and exact(), so the gap is 22 once fitted (default true). */
21
+ gap?: boolean;
22
+ /** Drop the pieces under `piece` and the holes under `hole` square units after drawing. */
23
+ despeck?: { piece?: number; hole?: number };
24
+ /** Names held back for a later drop: built by nobody, left out of the sidecars. */
25
+ held?: readonly string[];
26
+ /** The command line (default `process.argv.slice(2)`). */
27
+ argv?: readonly string[];
28
+ /** The generator's own modes (e.g. "matrix"): asking for one builds no mark here. */
29
+ modes?: readonly string[];
30
+ /** Where progress goes (default console.log). */
31
+ log?: (line: string) => void;
32
+ }
33
+
34
+ /** What a batch run did. */
35
+ export interface BatchResult {
36
+ /** The `newraw<N>` folder. */
37
+ out: string;
38
+ /** The command line it read. */
39
+ args: BatchArgs;
40
+ /** The names it drew. */
41
+ built: string[];
42
+ /** The names that threw, and why. */
43
+ failed: { name: string; error: string }[];
44
+ }
45
+
46
+ /** A raw mark's file: one even-odd path on the 512 canvas. */
47
+ export function rawSvg(shape: Shape): string;
48
+ /** Runs a drawing with Math.random seeded by the name, then puts Math.random back. */
49
+ export function withSeed<T>(name: string, draw: () => T): T;
50
+ /** The batch's sidecar files: file name → contents (`_names`, `_families`, `_styles`, and `_variants` / `_pairs` when used). */
51
+ export function sidecars(marks: Readonly<Record<string, BatchMark>>): Record<string, unknown>;
52
+ /** Draws a batch into `<workdir>/newraw<batch>/`, seeded per mark, and writes its sidecars. */
53
+ export function runBatch(batch: number, marks: Readonly<Record<string, BatchMark>>, options?: BatchOptions): BatchResult;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The batch runner: draws a generator's marks into `<workdir>/newraw<N>/` with the sidecars the export and the review
3
+ * page read (Node only: it writes files).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { seeded, toPath } from '../geometry/index.js';
8
+ import { exact, despeckShape } from '../parts/frame.js';
9
+ import { batchArgs, selected } from './args.js';
10
+ import { slug } from './slug.js';
11
+
12
+ /**
13
+ * A finished raw mark: one even-odd path on the 512 canvas (normalize fits it).
14
+ *
15
+ * @param {import('../geometry/types.js').Shape} shape The mark.
16
+ * @returns {string} The SVG file's text.
17
+ */
18
+ export const rawSvg = (shape) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path fill-rule="evenodd" d="${toPath(shape)}"/></svg>\n`;
19
+
20
+ /**
21
+ * Runs a drawing with Math.random seeded by the mark's name, putting Math.random back afterwards, so the mark is the
22
+ * same on every run whatever else ran first.
23
+ *
24
+ * @template T
25
+ * @param {string} name The mark's name (the seed).
26
+ * @param {() => T} draw The drawing.
27
+ * @returns {T} What it drew.
28
+ */
29
+ export function withSeed(name, draw) {
30
+ const random = Math.random;
31
+ Math.random = seeded(name);
32
+ try { return draw(); } finally { Math.random = random; }
33
+ }
34
+
35
+ /**
36
+ * The sidecars for a batch's marks: `_names.json` (how normalize finds the batch), `_families.json` and `_styles.json`,
37
+ * and `_variants.json` and `_pairs.json` when any mark has a `variantOf` or a `pair`. Each maps slug → value.
38
+ *
39
+ * @param {Readonly<Record<string, import('./run.js').BatchMark>>} marks The batch's marks, by name.
40
+ * @returns {Record<string, unknown>} File name → contents.
41
+ */
42
+ export function sidecars(marks) {
43
+ const entries = Object.entries(marks);
44
+ const map = (/** @type {keyof import('./run.js').BatchMark} */ key) =>
45
+ Object.fromEntries(entries.filter(([, m]) => m[key] !== undefined).map(([name, m]) => [slug(name), m[key]]));
46
+ /** @type {Record<string, unknown>} */
47
+ const files = { '_names.json': Object.keys(marks), '_families.json': map('family'), '_styles.json': map('style') };
48
+ for (const [key, file] of /** @type {const} */ ([['variantOf', '_variants.json'], ['pair', '_pairs.json']])) {
49
+ const m = map(key);
50
+ if (Object.keys(m).length) files[file] = m;
51
+ }
52
+ return files;
53
+ }
54
+
55
+ /**
56
+ * Draws a batch: every mark (or the ones the command line names: `"Name,Name"`, `"fam:<family>"`, `"pair:DS,JS"`)
57
+ * into `<workdir>/newraw<batch>/<slug>.svg`, seeded per mark so a re-run is byte-identical, then writes the sidecars
58
+ * for every mark that isn't held (so re-running one mark never trims them). A mark that throws is reported and the
59
+ * rest still drawn.
60
+ *
61
+ * With `gap: true` (the default) each build takes the gap and is drawn with exact(), so its gap is 22 once fitted;
62
+ * with `gap: false` it takes nothing. `despeck` drops the pieces and holes a boolean left under those areas.
63
+ *
64
+ * @param {number} batch The batch number.
65
+ * @param {Readonly<Record<string, import('./run.js').BatchMark>>} marks The marks, by name.
66
+ * @param {import('./run.js').BatchOptions} [options] The drawing and the command line.
67
+ * @returns {import('./run.js').BatchResult} What was built and what failed.
68
+ */
69
+ export function runBatch(batch, marks, { gap = true, despeck, held = [], argv, modes, log = console.log } = {}) {
70
+ const args = batchArgs(argv, { modes });
71
+ const out = path.join(args.workdir, `newraw${batch}`);
72
+ fs.mkdirSync(out, { recursive: true });
73
+ const live = Object.fromEntries(Object.entries(marks).filter(([name]) => !held.includes(name)));
74
+ /** @type {string[]} */ const built = [];
75
+ /** @type {{ name: string, error: string }[]} */ const failed = [];
76
+ for (const [name, mark] of Object.entries(live)) {
77
+ if (!selected(args, name, mark)) continue;
78
+ try {
79
+ // exact() redraws up to three times to land the gap: each attempt starts from the same seed
80
+ let shape = withSeed(name, () => (gap ? exact(mark.build, () => { Math.random = seeded(name); }) : mark.build(0)));
81
+ if (despeck) shape = despeckShape(shape, despeck.piece, despeck.hole);
82
+ fs.writeFileSync(path.join(out, `${slug(name)}.svg`), rawSvg(shape));
83
+ built.push(name);
84
+ } catch (e) {
85
+ failed.push({ name, error: e instanceof Error ? e.message : String(e) });
86
+ log(`FAIL ${name}: ${failed[failed.length - 1].error.slice(0, 160)}`);
87
+ }
88
+ }
89
+ for (const [file, data] of Object.entries(sidecars(live))) fs.writeFileSync(path.join(out, file), JSON.stringify(data, null, 1));
90
+ log(`built ${built.length} of ${Object.keys(live).length}${held.length ? ` (${held.length} held)` : ''} into ${out}`);
91
+ return { out, args, built, failed };
92
+ }
@@ -0,0 +1,2 @@
1
+ /** A mark's slug from its name: lower case, runs of anything but a–z and 0–9 one hyphen, none at either end. */
2
+ export function slug(name: string): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A mark's slug: the name its file, its page and every sidecar are keyed by.
3
+ */
4
+
5
+ /**
6
+ * A mark's slug from its name: lower case, each run of anything but a–z and 0–9 one hyphen, no hyphen at either end
7
+ * ("Ship's Wheel" → "ship-s-wheel", "Tree (r)" → "tree-r"). The site, normalize and the review tools all use this one.
8
+ *
9
+ * @param {string} name The mark's name.
10
+ * @returns {string} Its slug.
11
+ */
12
+ export const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
@@ -0,0 +1,12 @@
1
+ /** An existing mark read for an edition or a variation. */
2
+ export interface SourceMark {
3
+ /** The file's text. */
4
+ svg: string;
5
+ /** Where it was read from. */
6
+ file: string;
7
+ /** The live set, or the review queue. */
8
+ from: 'live' | 'queue';
9
+ }
10
+
11
+ /** An existing mark: the live file first, then the queue's `<prefix>NNN-<slug>.svg` (`LOGOMARKS_SVG` / `LOGOMARKS_QUEUE` replace the folders); null when it's in neither. */
12
+ export function sourceMark(slug: string, folders: { live: string; queue?: string }): SourceMark | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Reading a mark that already exists, for editions and variations drawn from it (Node only).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * An existing mark's SVG: the live file first, then the review queue, where a mark approved for the next drop waits
9
+ * as `<prefix>NNN-<slug>.svg`. `LOGOMARKS_SVG` and `LOGOMARKS_QUEUE` replace the two folders, so a proof can read a
10
+ * snapshot while an export rewrites them.
11
+ *
12
+ * @param {string} slug The mark's slug.
13
+ * @param {{ live: string, queue?: string }} folders The live svg folder and the queue folder.
14
+ * @returns {import('./source.js').SourceMark | null} The file, or null when it's in neither.
15
+ */
16
+ export function sourceMark(slug, { live, queue }) {
17
+ const liveDir = process.env.LOGOMARKS_SVG || live, queueDir = process.env.LOGOMARKS_QUEUE || queue;
18
+ const file = path.join(liveDir, `${slug}.svg`);
19
+ if (fs.existsSync(file)) return { svg: fs.readFileSync(file, 'utf8'), file, from: 'live' };
20
+ if (!queueDir || !fs.existsSync(queueDir)) return null;
21
+ const queued = fs.readdirSync(queueDir).find((f) => f.replace(/^[a-z]+\d+-/, '') === `${slug}.svg`);
22
+ return queued ? { svg: fs.readFileSync(path.join(queueDir, queued), 'utf8'), file: path.join(queueDir, queued), from: 'queue' } : null;
23
+ }
@@ -21,6 +21,10 @@ export const CANVAS: 512;
21
21
  export const FIT: 448;
22
22
  /** How far size, centre and edges may drift: 0.5. */
23
23
  export const FIT_TOLERANCE: 0.5;
24
+ /** How far an already-fitted mark may sit from 448 and the centre and keep its scale on a refit: 0.25. */
25
+ export const REFIT_TOLERANCE: 0.25;
26
+ /** How close an arc radius may sit below the next 0.1 step and still be taken as it: 0.005. */
27
+ export const ARC_SLACK: 0.005;
24
28
  /** Markup a finished mark must not contain. */
25
29
  export const FORBIDDEN_MARKUP: RegExp;
26
30
  /** A mark fitted to the standard; throws when the file has no filled shapes. */
package/src/fit/index.js CHANGED
@@ -16,6 +16,10 @@ export const CANVAS = 512;
16
16
  export const FIT = 448;
17
17
  /** How far size, centre and canvas edges may drift before a check fails. */
18
18
  export const FIT_TOLERANCE = 0.5;
19
+ /** How far a mark already on the 512 canvas may sit from 448 and the centre and still keep its scale and position on a refit. */
20
+ export const REFIT_TOLERANCE = 0.25;
21
+ /** How close (in units) an arc radius may sit below the next 0.1 step and still be taken as it. */
22
+ export const ARC_SLACK = 0.005;
19
23
  /** Markup a finished mark must not contain: flatten to filled paths. */
20
24
  export const FORBIDDEN_MARKUP = /<(g|style|mask|clipPath|use|text|image)\b|transform=|stroke=/;
21
25
 
@@ -31,7 +35,9 @@ const snap = (v) => Math.round(v * 10) / 10;
31
35
  * Segments as compact relative path data. Points are snapped to the 0.1 grid
32
36
  * first and the relative steps taken between snapped points, so rounding never
33
37
  * drifts. Arc radii are rounded down: a semicircle stays exact (a radius a
34
- * hair too big would bulge the arc).
38
+ * hair too big would bulge the arc). A radius within ARC_SLACK of the next
39
+ * step up is taken as that step, so refitting a fitted mark (its scale a hair
40
+ * under 1) keeps its radii instead of shaving 0.1 off each time.
35
41
  *
36
42
  * @param {readonly import('./path.js').Segment[]} segs The segments.
37
43
  * @param {(x: number, y: number) => number[]} f Where each point goes.
@@ -60,7 +66,7 @@ function serialise(segs, f, k) {
60
66
  cx = sx = x; cy = sy = y;
61
67
  } else if (s[0] === 'A') {
62
68
  const [x, y] = f(s[6], s[7]).map(snap);
63
- const r = (/** @type {number} */ v) => r1(Math.floor(v * k * 10) / 10);
69
+ const r = (/** @type {number} */ v) => r1(Math.floor(v * k * 10 + ARC_SLACK * 10) / 10);
64
70
  d += cmd('a', `${r(s[1])} ${r(s[2])} ${r1(s[3])} ${s[4] ? 1 : 0} ${s[5] ? 1 : 0} ${rel(x, y)}`);
65
71
  cx = x; cy = y;
66
72
  } else {
@@ -74,7 +80,24 @@ function serialise(segs, f, k) {
74
80
  }
75
81
 
76
82
  /**
77
- * A mark fitted to the standard.
83
+ * Whether a mark is already fitted: on the 512 canvas, its longest side and centre within REFIT_TOLERANCE. Its
84
+ * points sit on the 0.1 grid, so it measures a hair off 448; scaling it by that hair would tip points and arc radii
85
+ * over a rounding edge, so a refit keeps it as it is.
86
+ *
87
+ * @param {string} svg The file's text.
88
+ * @param {{ x: number, y: number, w: number, h: number }} b Its outline's box.
89
+ * @returns {boolean} True when it is.
90
+ */
91
+ function isFitted(svg, b) {
92
+ const vb = viewBox(svg);
93
+ if (!vb || vb.join(' ') !== `0 0 ${CANVAS} ${CANVAS}`) return false;
94
+ const near = (/** @type {number} */ a, /** @type {number} */ e) => Math.abs(a - e) <= REFIT_TOLERANCE;
95
+ return near(Math.max(b.w, b.h), FIT) && near(b.x + b.w / 2, CANVAS / 2) && near(b.y + b.h / 2, CANVAS / 2);
96
+ }
97
+
98
+ /**
99
+ * A mark fitted to the standard. A mark already fitted keeps its scale and position (see isFitted), so refitting a
100
+ * finished file gives the same file.
78
101
  *
79
102
  * @param {string} svg The file's text.
80
103
  * @returns {import('./index.js').Fitted} The fitted file and the box it had.
@@ -84,8 +107,9 @@ export function fitMark(svg) {
84
107
  const all = shapes(svg);
85
108
  if (!all.length) throw new Error('no filled shapes found');
86
109
  const before = bounds(all);
87
- const k = FIT / Math.max(before.w, before.h);
88
- const tx = CANVAS / 2 - (before.x + before.w / 2) * k, ty = CANVAS / 2 - (before.y + before.h / 2) * k;
110
+ const fitted = isFitted(svg, before);
111
+ const k = fitted ? 1 : FIT / Math.max(before.w, before.h);
112
+ const tx = fitted ? 0 : CANVAS / 2 - (before.x + before.w / 2) * k, ty = fitted ? 0 : CANVAS / 2 - (before.y + before.h / 2) * k;
89
113
  const f = (/** @type {number} */ x, /** @type {number} */ y) => [x * k + tx, y * k + ty];
90
114
  const paths = all.map((s) => `<path${s.evenodd ? ' fill-rule="evenodd"' : ''} d="${serialise(s.segs, f, k)}"/>`).join('');
91
115
  return { svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CANVAS} ${CANVAS}" fill="currentColor">${paths}</svg>\n`, before };
@@ -16,6 +16,7 @@ export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing,
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';
19
+ export { mitreOffset, mitreBand, offsetPolygon } from './mitre.js';
19
20
  export { toPath, svg, write } from './path.js';
20
21
  export { grow, soften, grown, over, slices } from './offset.js';
21
22
  export { iso, cubes, isoPrism, isoBlock, isoRing, isoPile } from './iso.js';
@@ -9,6 +9,7 @@ export { signedArea, area, perimeter, ringBox, bbox, winding, dedupe, openRing,
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';
12
+ export { mitreOffset, mitreBand, offsetPolygon } from './mitre.js';
12
13
  export { toPath, svg, write } from './path.js';
13
14
  export { grow, soften, grown, over, slices } from './offset.js';
14
15
  export { iso, cubes, isoPrism, isoBlock, isoRing, isoPile } from './iso.js';
@@ -0,0 +1,8 @@
1
+ import type { Point, Shape } from './types.js';
2
+
3
+ /** An open polyline offset d to its left (y down), corners mitred so it runs exactly parallel; d < 0 goes right. */
4
+ export function mitreOffset(pts: readonly (readonly number[])[], d: number): Point[];
5
+ /** A solid band w wide along an open polyline, corners mitred, ends square. */
6
+ export function mitreBand(pts: readonly (readonly number[])[], w: number): Shape;
7
+ /** A closed polygon grown (d > 0) or shrunk (d < 0), corners mitred; either winding. */
8
+ export function offsetPolygon(pts: readonly (readonly number[])[], d: number): Point[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Exact offsets with mitred corners: a polyline's parallel, a band along it, and a polygon grown or shrunk.
3
+ */
4
+ import { poly } from './core.js';
5
+
6
+ /**
7
+ * The unit left normal (y down) of the step a → b.
8
+ *
9
+ * @param {readonly number[]} a From.
10
+ * @param {readonly number[]} b To.
11
+ * @returns {number[]} The normal.
12
+ */
13
+ const leftNormal = (a, b) => {
14
+ const v = [b[0] - a[0], b[1] - a[1]], l = Math.hypot(...v) || 1;
15
+ return [v[1] / l, -(v[0] / l)];
16
+ };
17
+
18
+ /**
19
+ * An open polyline offset to its left (y down) by d, every corner mitred, so the offset runs exactly parallel to it
20
+ * (d < 0 offsets to the right). The ends move square to their segments.
21
+ *
22
+ * @param {readonly (readonly number[])[]} pts The polyline.
23
+ * @param {number} d How far.
24
+ * @returns {number[][]} The offset polyline, a point for each point.
25
+ */
26
+ export const mitreOffset = (pts, d) => pts.map((p, i) => {
27
+ const n1 = i > 0 ? leftNormal(pts[i - 1], p) : null, n2 = i < pts.length - 1 ? leftNormal(p, pts[i + 1]) : null;
28
+ if (!n1) return [p[0] + /** @type {number[]} */ (n2)[0] * d, p[1] + /** @type {number[]} */ (n2)[1] * d];
29
+ if (!n2) return [p[0] + n1[0] * d, p[1] + n1[1] * d];
30
+ const s = [n1[0] + n2[0], n1[1] + n2[1]], l = Math.hypot(...s) || 1, m = [s[0] / l, s[1] / l], k = d / (m[0] * n1[0] + m[1] * n1[1]);
31
+ return [p[0] + m[0] * k, p[1] + m[1] * k];
32
+ });
33
+
34
+ /**
35
+ * A solid band w wide along an open polyline, every corner mitred sharp and both ends cut square.
36
+ *
37
+ * @param {readonly (readonly number[])[]} pts The polyline (its centre line).
38
+ * @param {number} w The width.
39
+ * @returns {import('./types.js').Shape} The band.
40
+ */
41
+ export const mitreBand = (pts, w) => poly([...mitreOffset(pts, w / 2), ...mitreOffset(pts, -w / 2).reverse()]);
42
+
43
+ /**
44
+ * A closed polygon grown (d > 0) or shrunk (d < 0) by d, every corner mitred: each edge moves out along its normal and
45
+ * neighbouring edges meet where their lines cross. Either winding. Shrinking past a corner's reach turns it inside out,
46
+ * so keep |d| under the shortest edge's reach.
47
+ *
48
+ * @param {readonly (readonly number[])[]} pts The polygon's corners, in order (open: no repeated first point).
49
+ * @param {number} d How far.
50
+ * @returns {number[][]} The offset polygon, a corner for each corner.
51
+ */
52
+ export const offsetPolygon = (pts, d) => {
53
+ const n = pts.length;
54
+ let a = 0;
55
+ for (let i = 0; i < n; i++) { const p = pts[i], q = pts[(i + 1) % n]; a += p[0] * q[1] - q[0] * p[1]; }
56
+ const sg = a > 0 ? 1 : -1, L = pts.map((p, i) => {
57
+ const q = pts[(i + 1) % n], dx = q[0] - p[0], dy = q[1] - p[1], l = Math.hypot(dx, dy), nx = sg * dy / l, ny = -sg * dx / l;
58
+ return [p[0] + nx * d, p[1] + ny * d, dx, dy];
59
+ });
60
+ return L.map((b, i) => {
61
+ const a2 = L[(i + n - 1) % n], den = a2[2] * b[3] - a2[3] * b[2], t = ((b[0] - a2[0]) * b[3] - (b[1] - a2[1]) * b[2]) / den;
62
+ return [a2[0] + a2[2] * t, a2[1] + a2[3] * t];
63
+ });
64
+ };
@@ -9,6 +9,6 @@ export function isoPieces(pieces: [Point3, Point3][], u?: number, g?: number, ox
9
9
  /** Blocks [x, y, w, h] on a W × H grid, scaled to 448 and parted by g. */
10
10
  export function blocks(list: [number, number, number, number][], W: number, H: number, g?: number): Shape;
11
11
  /** A nested subdivision for blocks(): n − 1 blocks peeled off the sides named in turn (l t r b), each `cut` of what's left. */
12
- export function nest(W: number, H: number, n: number, sides: string, cut: number | ((i: number) => number)): [number, number, number, number][];
12
+ export function nest(W: number, H: number, n: number, sides: string, cut: (w: number, h: number, side: string) => number): [number, number, number, number][];
13
13
  /** Capsules w wide pinned together, back to front, each cut free by g; pin holes at `pins`. */
14
14
  export function linkage(bars: [Point, Point][], pins?: Point[], o?: { w?: number; g?: number; pin?: number }): Shape;
@@ -13,4 +13,6 @@ export * from './iso.js';
13
13
  export * from './letters.js';
14
14
  export * from './objects.js';
15
15
  export * from './faces.js';
16
+ export * from './span.js';
17
+ export * from './pixels.js';
16
18
  export * from './run.js';
@@ -12,4 +12,6 @@ export * from './iso.js';
12
12
  export * from './letters.js';
13
13
  export * from './objects.js';
14
14
  export * from './faces.js';
15
+ export * from './span.js';
16
+ export * from './pixels.js';
15
17
  export * from './run.js';
@@ -0,0 +1,11 @@
1
+ import type { Geom } from '../geometry/types.js';
2
+
3
+ /** A span of pixels: [from, to, n], or [null, null, n] for n empty pixels. */
4
+ export type PixelSpan = readonly [number | null, number | null, number];
5
+
6
+ /** Whether a point is inside a kit shape (even-odd). */
7
+ export function insideShape(shape: Geom, x: number, y: number): boolean;
8
+ /** A mark as rows of text ('#' a pixel) over spans of pixels on each axis. */
9
+ export function rasterRows(shape: Geom, xs: readonly PixelSpan[], ys: readonly PixelSpan[], options?: { min?: number; k?: number }): string[];
10
+ /** A whole mark as rows of text on square pixels, n along its longest side, phase (fx, fy), cells to toggle. */
11
+ export function rasterMark(shape: Geom, n: number, fx?: number, fy?: number, options?: { min?: number; fix?: readonly (readonly number[])[] }): string[];