@arraypress/covers 1.4.0 → 1.6.1
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 +49 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/recipes/flyers/construction.json +36 -0
- package/recipes/flyers/dissolve-poster.json +72 -0
- package/recipes/flyers/fluted-poster.json +105 -0
- package/recipes/flyers/series-card.json +81 -0
- package/schema/recipe.schema.json +556 -2
- package/src/index.d.ts +5 -5
- package/src/layer-options.js +3 -1
- package/src/layers/construction.d.ts +34 -0
- package/src/layers/construction.js +137 -0
- package/src/layers/focal.d.ts +8 -0
- package/src/layers/focal.js +109 -0
- package/src/layers/guides.d.ts +38 -0
- package/src/layers/guides.js +212 -0
- package/src/layers/index.d.ts +6 -0
- package/src/layers/index.js +3 -0
- package/src/layers/isolines.d.ts +21 -0
- package/src/layers/isolines.js +271 -0
- package/src/layers/mark-draw.d.ts +21 -0
- package/src/layers/mark-draw.js +93 -0
- package/src/layers/outline.d.ts +26 -0
- package/src/layers/outline.js +105 -0
- package/src/layers.js +10 -4
- package/src/version.js +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The `construction` layer.
|
|
2
|
+
import type { Color, Length, BaseLayer } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
export interface ConstructionLayer extends BaseLayer {
|
|
6
|
+
type: 'construction';
|
|
7
|
+
/** The mark's paths, on a 512 grid. */
|
|
8
|
+
markup: string;
|
|
9
|
+
/** The box the sheet fills (default the canvas); the mark is centred in it. */
|
|
10
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
11
|
+
/** The share of the box's shorter side the mark's longest side takes. Default 0.6. */
|
|
12
|
+
size?: number;
|
|
13
|
+
/** Grid cells across the mark's longest side; 0 none. Default 8. */
|
|
14
|
+
grid?: number;
|
|
15
|
+
/** The most circles read from its curves. Default 6. */
|
|
16
|
+
circles?: number;
|
|
17
|
+
/** The most lines read from its straight edges. Default 6. */
|
|
18
|
+
lines?: number;
|
|
19
|
+
/** The guides. Default the palette's accent. */
|
|
20
|
+
color?: Color;
|
|
21
|
+
/** Default `color`. */
|
|
22
|
+
gridColor?: Color;
|
|
23
|
+
/** Default 0.3. */
|
|
24
|
+
gridOpacity?: number;
|
|
25
|
+
/** Default the palette's text; `'none'` leaves the mark out. */
|
|
26
|
+
markColor?: Color | 'none';
|
|
27
|
+
/** The guides' opacity over the mark, 0–1. Default 0.4. */
|
|
28
|
+
over?: number;
|
|
29
|
+
/** Guide width. Default `'0.15vmin'`. */
|
|
30
|
+
stroke?: Length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Draw a mark over its own construction: grid, box, centre cross, its circles and lines. */
|
|
34
|
+
export function construction(layer: ConstructionLayer, ctx: RenderContext): Markup;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `construction` layer.
|
|
3
|
+
*
|
|
4
|
+
* @module layers/construction
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { attrs, num } from '@logomarks/image-effects/svg';
|
|
8
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
9
|
+
import { len } from '@logomarks/image-effects/length';
|
|
10
|
+
import { wrap } from './util.js';
|
|
11
|
+
import { markGuides } from './guides.js';
|
|
12
|
+
import { layerBox, fitMark, markPaths } from './mark-draw.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `construction` — a mark drawn over its own construction, as a
|
|
16
|
+
* construction sheet draws it: a light dashed square grid behind (its
|
|
17
|
+
* cells a fraction of the mark's size, centred on it), the mark's bounding
|
|
18
|
+
* box, a centre cross, the circles inscribed in its box and round its
|
|
19
|
+
* outline, the circles its curves are drawn on (each centre marked) and
|
|
20
|
+
* the lines its straight edges lie on, run across the box, all dashed in
|
|
21
|
+
* one thin accent. The guides are read from the mark's own paths
|
|
22
|
+
* (`markGuides`). Where the mark covers them they show faintly over it.
|
|
23
|
+
*
|
|
24
|
+
* Options: `markup` (512 grid), `x`, `y`, `width`, `height` (the box the
|
|
25
|
+
* sheet fills; default the canvas), `size` (the share of the box's
|
|
26
|
+
* shorter side the mark's longest side takes, 0.6), `grid` (cells across
|
|
27
|
+
* the mark's longest side, 8; 0 none), `circles` and `lines` (the most of
|
|
28
|
+
* each, 6), `color` (the guides; default the accent), `gridColor`
|
|
29
|
+
* (default `color`), `gridOpacity` (0.3), `markColor` (default `text`;
|
|
30
|
+
* `'none'` leaves the mark out), `over` (the guides' opacity over the
|
|
31
|
+
* mark, 0.4), `stroke` (px, `'0.15vmin'`).
|
|
32
|
+
*
|
|
33
|
+
* @param {any} layer
|
|
34
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
35
|
+
* @returns {import('../layers.js').Markup}
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* { type: 'construction', markup: mark, x: 24, y: 60, width: 352, height: 380, color: '#3b82f6', markColor: '#f4f4f2' }
|
|
39
|
+
*/
|
|
40
|
+
export function construction(layer, ctx) {
|
|
41
|
+
const g = markGuides(layer.markup, { circles: layer.circles ?? 6, lines: layer.lines ?? 6 });
|
|
42
|
+
if (!g) return { defs: '', body: '' };
|
|
43
|
+
const area = layerBox(layer, ctx);
|
|
44
|
+
const fit = fitMark(g.box, area, layer.size ?? 0.6);
|
|
45
|
+
const { k } = fit;
|
|
46
|
+
const X = (/** @type {number} */ x) => x * k + fit.dx;
|
|
47
|
+
const Y = (/** @type {number} */ y) => y * k + fit.dy;
|
|
48
|
+
const color = layer.color ? toHex(layer.color) : ctx.palette.accent;
|
|
49
|
+
const sw = len(layer.stroke, ctx.canvas, '0.15vmin');
|
|
50
|
+
const dash = `${num(sw * 6)} ${num(sw * 4)}`;
|
|
51
|
+
const line = (/** @type {Record<string, any>} */ a) => `<path${attrs({ fill: 'none', ...a })}/>`;
|
|
52
|
+
|
|
53
|
+
// The box, its centre and the mark's longest side, canvas units.
|
|
54
|
+
const bx = X(g.box.x), by = Y(g.box.y), bw = g.box.width * k, bh = g.box.height * k;
|
|
55
|
+
const cx = bx + bw / 2, cy = by + bh / 2;
|
|
56
|
+
const side = Math.max(bw, bh);
|
|
57
|
+
const { x: ax, y: ay, width: aw, height: ah } = area;
|
|
58
|
+
|
|
59
|
+
// The grid: cells of side / grid, lines through the mark's centre.
|
|
60
|
+
let grid = '';
|
|
61
|
+
const cells = Math.round(layer.grid ?? 8);
|
|
62
|
+
if (cells > 0) {
|
|
63
|
+
const u = side / cells;
|
|
64
|
+
let d = '';
|
|
65
|
+
for (let x = cx - Math.floor((cx - ax) / u) * u; x <= ax + aw + 1e-6; x += u) d += `M${num(x)} ${num(ay)}V${num(ay + ah)}`;
|
|
66
|
+
for (let y = cy - Math.floor((cy - ay) / u) * u; y <= ay + ah + 1e-6; y += u) d += `M${num(ax)} ${num(y)}H${num(ax + aw)}`;
|
|
67
|
+
grid = line({ d, stroke: layer.gridColor ? toHex(layer.gridColor) : color, 'stroke-width': num(sw * 0.8, 3), 'stroke-dasharray': `${num(sw * 3)} ${num(sw * 3)}`, opacity: num(layer.gridOpacity ?? 0.3, 3) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keylines: the box (solid), the centre cross and the inscribed and circumscribed circles (dashed, lighter).
|
|
71
|
+
let keys = line({ d: `M${num(bx)} ${num(by)}h${num(bw)}v${num(bh)}h${num(-bw)}Z`, stroke: color, 'stroke-width': num(sw, 3) });
|
|
72
|
+
keys += line({ d: `M${num(ax)} ${num(cy)}H${num(ax + aw)}M${num(cx)} ${num(ay)}V${num(ay + ah)}`, stroke: color, 'stroke-width': num(sw, 3), 'stroke-dasharray': dash, opacity: 0.6 });
|
|
73
|
+
keys += line({ d: circlePath(cx, cy, g.inscribed * k) + circlePath(cx, cy, g.circumscribed * k), stroke: color, 'stroke-width': num(sw, 3), 'stroke-dasharray': dash, opacity: 0.6 });
|
|
74
|
+
|
|
75
|
+
// The mark's own circles, each centre marked, and its edges' lines across the box.
|
|
76
|
+
let d = '';
|
|
77
|
+
let ticks = '';
|
|
78
|
+
const t = sw * 4;
|
|
79
|
+
for (const c of g.circles) {
|
|
80
|
+
const [x, y] = [X(c.cx), Y(c.cy)];
|
|
81
|
+
d += circlePath(x, y, c.r * k);
|
|
82
|
+
ticks += `M${num(x - t)} ${num(y)}h${num(2 * t)}M${num(x)} ${num(y - t)}v${num(2 * t)}`;
|
|
83
|
+
}
|
|
84
|
+
let lines = '';
|
|
85
|
+
for (const l of g.lines) {
|
|
86
|
+
const seg = clipLine(X(l.x), Y(l.y), Math.cos(l.angle), Math.sin(l.angle), area);
|
|
87
|
+
if (seg) lines += `M${num(seg[0])} ${num(seg[1])}L${num(seg[2])} ${num(seg[3])}`;
|
|
88
|
+
}
|
|
89
|
+
let guides = keys;
|
|
90
|
+
if (d) guides += line({ d, stroke: color, 'stroke-width': num(sw, 3), 'stroke-dasharray': dash });
|
|
91
|
+
if (lines) guides += line({ d: lines, stroke: color, 'stroke-width': num(sw, 3), 'stroke-dasharray': dash, opacity: 0.75 });
|
|
92
|
+
if (ticks) guides += line({ d: ticks, stroke: color, 'stroke-width': num(sw, 3) });
|
|
93
|
+
|
|
94
|
+
const clip = `${ctx.id}k`;
|
|
95
|
+
const maskId = `${ctx.id}m`;
|
|
96
|
+
let defs = `<clipPath id="${clip}"><rect${attrs({ x: ax, y: ay, width: aw, height: ah })}/></clipPath>`;
|
|
97
|
+
let body = `${grid}${guides}`;
|
|
98
|
+
const markColor = layer.markColor === 'none' ? null : layer.markColor ? toHex(layer.markColor) : ctx.palette.text;
|
|
99
|
+
if (markColor) {
|
|
100
|
+
const paths = markPaths(layer.markup, fit).map((p) => `<path${attrs({ d: p.d, 'fill-rule': p.evenodd ? 'evenodd' : undefined })}/>`).join('');
|
|
101
|
+
body += `<g fill="${markColor}">${paths}</g>`;
|
|
102
|
+
const over = layer.over ?? 0.4;
|
|
103
|
+
if (over > 0) {
|
|
104
|
+
defs += `<mask id="${maskId}" maskUnits="userSpaceOnUse"${attrs({ x: ax, y: ay, width: aw, height: ah })}><g fill="#fff">${paths}</g></mask>`;
|
|
105
|
+
body += `<g mask="url(#${maskId})" opacity="${num(over, 3)}">${guides}</g>`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { defs, body: wrap(layer, `<g clip-path="url(#${clip})">${body}</g>`) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A circle as path data (two arcs), so circles share one path.
|
|
113
|
+
*
|
|
114
|
+
* @param {number} cx @param {number} cy @param {number} r
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function circlePath(cx, cy, r) {
|
|
118
|
+
return `M${num(cx - r)} ${num(cy)}a${num(r)} ${num(r)} 0 1 0 ${num(2 * r)} 0a${num(r)} ${num(r)} 0 1 0 ${num(-2 * r)} 0Z`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The part of the line through (x, y) along (ux, uy) inside a box
|
|
123
|
+
* (Liang–Barsky), or null when it misses.
|
|
124
|
+
*
|
|
125
|
+
* @param {number} x @param {number} y @param {number} ux @param {number} uy
|
|
126
|
+
* @param {{ x: number, y: number, width: number, height: number }} box
|
|
127
|
+
* @returns {number[] | null} `[x1, y1, x2, y2]`.
|
|
128
|
+
*/
|
|
129
|
+
function clipLine(x, y, ux, uy, box) {
|
|
130
|
+
let t0 = -Infinity, t1 = Infinity;
|
|
131
|
+
for (const [p, q] of [[-ux, x - box.x], [ux, box.x + box.width - x], [-uy, y - box.y], [uy, box.y + box.height - y]]) {
|
|
132
|
+
if (Math.abs(p) < 1e-12) { if (q < 0) return null; continue; }
|
|
133
|
+
const r = q / p;
|
|
134
|
+
if (p < 0) t0 = Math.max(t0, r); else t1 = Math.min(t1, r);
|
|
135
|
+
}
|
|
136
|
+
return t0 < t1 ? [x + ux * t0, y + uy * t0, x + ux * t1, y + uy * t1] : null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Placing a photo on a point of it: a focus [x, y], or a cut-out's head.
|
|
2
|
+
|
|
3
|
+
/** A cut-out's head (the alpha-weighted centre of the top 45% of its opaque rows), 0–1 in the photo; null with no transparency. */
|
|
4
|
+
export function subjectFocus(sharp: any, bytes: Uint8Array): Promise<[number, number] | null>;
|
|
5
|
+
/** The drawn box (relative to the box) that centres a point of a `sw` × `sh` photo; `cover` keeps the box filled except sideways when `free` (a cut-out). */
|
|
6
|
+
export function focalBox(box: { width: number; height: number }, sw: number, sh: number, fit: 'cover' | 'contain', point: [number, number], free?: boolean): { x: number; y: number; width: number; height: number };
|
|
7
|
+
/** A `W` × `H` raster with the photo drawn at a box (raster pixels), the rest clear, as a sharp pipeline. */
|
|
8
|
+
export function drawAt(sharp: any, bytes: Uint8Array, W: number, H: number, at: { x: number; y: number; width: number; height: number }): Promise<any>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placing a photo on a point of it: a focus `[x, y]` (0–1 in the photo),
|
|
3
|
+
* or `'subject'`, a cut-out's head, found from its transparency. Used by
|
|
4
|
+
* the `image` layer when sharp is there to draw the pixels.
|
|
5
|
+
*
|
|
6
|
+
* @module layers/focal
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The share of a cut-out's opaque height, from its top, taken as its head. */
|
|
10
|
+
const HEAD = 0.45;
|
|
11
|
+
/** Rows and columns the transparency is read at. */
|
|
12
|
+
const PROBE = 160;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where a cut-out's head is: the alpha-weighted centre of the top 45% of
|
|
16
|
+
* its opaque rows, as `[x, y]` fractions of the photo. Null for a photo
|
|
17
|
+
* with no transparency to read (an ordinary photo: no subject to find).
|
|
18
|
+
*
|
|
19
|
+
* @param {any} sharp - The sharp module.
|
|
20
|
+
* @param {Uint8Array} bytes
|
|
21
|
+
* @returns {Promise<[number, number] | null>}
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* await subjectFocus(sharp, cutoutPng); // [0.56, 0.21]
|
|
25
|
+
*/
|
|
26
|
+
export async function subjectFocus(sharp, bytes) {
|
|
27
|
+
const meta = await sharp(bytes).metadata();
|
|
28
|
+
if (!meta.hasAlpha) return null;
|
|
29
|
+
const { data, info } = await sharp(bytes).rotate().ensureAlpha().resize(PROBE, PROBE, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
|
|
30
|
+
const { width: w, height: h, channels: c } = info;
|
|
31
|
+
const alpha = (/** @type {number} */ x, /** @type {number} */ y) => data[(y * w + x) * c + 3] / 255;
|
|
32
|
+
// The opaque rows: those with a few solid pixels.
|
|
33
|
+
const rows = [];
|
|
34
|
+
for (let y = 0; y < h; y++) {
|
|
35
|
+
let s = 0;
|
|
36
|
+
for (let x = 0; x < w; x++) s += alpha(x, y);
|
|
37
|
+
if (s >= 2) rows.push(y);
|
|
38
|
+
}
|
|
39
|
+
let clear = false;
|
|
40
|
+
for (let i = 3; i < data.length && !clear; i += c) clear = data[i] < 250;
|
|
41
|
+
if (!rows.length || !clear) return null;
|
|
42
|
+
const top = rows[0];
|
|
43
|
+
const bottom = rows[rows.length - 1];
|
|
44
|
+
const end = top + Math.max(1, Math.round((bottom - top + 1) * HEAD));
|
|
45
|
+
let sum = 0, sx = 0, sy = 0;
|
|
46
|
+
for (let y = top; y < end; y++) {
|
|
47
|
+
for (let x = 0; x < w; x++) {
|
|
48
|
+
const a = alpha(x, y);
|
|
49
|
+
sum += a; sx += a * (x + 0.5); sy += a * (y + 0.5);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return sum ? [sx / sum / w, sy / sum / h] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where a photo of `sw` × `sh` is drawn in a box so a point of it is
|
|
57
|
+
* centred: scaled to `fit` (cover or contain), then moved so the point
|
|
58
|
+
* sits on the box's centre. `cover` keeps the box filled (the move is
|
|
59
|
+
* held to the photo's overflow), except sideways for a cut-out
|
|
60
|
+
* (`free`), whose transparent sides show the ground; `contain` keeps it
|
|
61
|
+
* inside the box.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ width: number, height: number }} box - Its size (any units).
|
|
64
|
+
* @param {number} sw @param {number} sh - The photo's size.
|
|
65
|
+
* @param {'cover' | 'contain'} fit
|
|
66
|
+
* @param {[number, number]} point - 0–1 in the photo.
|
|
67
|
+
* @param {boolean} [free=false] - Move freely sideways (a cut-out).
|
|
68
|
+
* @returns {{ x: number, y: number, width: number, height: number }} The photo's drawn box, relative to the box.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* focalBox({ width: 400, height: 500 }, 1000, 1000, 'cover', [0.3, 0]); // { x: 0, y: 0, width: 500, height: 500 }: held at the left edge
|
|
72
|
+
* focalBox({ width: 400, height: 500 }, 1000, 1000, 'cover', [0.3, 0], true); // { x: 50, … }: a cut-out, centred on 0.3
|
|
73
|
+
*/
|
|
74
|
+
export function focalBox(box, sw, sh, fit, [fx, fy], free = false) {
|
|
75
|
+
const k = fit === 'contain' ? Math.min(box.width / sw, box.height / sh) : Math.max(box.width / sw, box.height / sh);
|
|
76
|
+
const width = sw * k;
|
|
77
|
+
const height = sh * k;
|
|
78
|
+
let x = box.width / 2 - fx * width;
|
|
79
|
+
let y = box.height / 2 - fy * height;
|
|
80
|
+
/** Held between a and b (in either order). */
|
|
81
|
+
const hold = (/** @type {number} */ v, /** @type {number} */ a, /** @type {number} */ b) => Math.min(Math.max(a, b), Math.max(Math.min(a, b), v));
|
|
82
|
+
if (!free || fit === 'contain') x = hold(x, 0, box.width - width);
|
|
83
|
+
y = hold(y, 0, box.height - height);
|
|
84
|
+
return { x, y, width, height };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Draw a photo into a `W` × `H` raster at a drawn box (raster pixels), the
|
|
89
|
+
* rest transparent: resized, the visible part cut out and laid on a clear
|
|
90
|
+
* canvas. Returns a sharp pipeline of the result.
|
|
91
|
+
*
|
|
92
|
+
* @param {any} sharp
|
|
93
|
+
* @param {Uint8Array} bytes
|
|
94
|
+
* @param {number} W @param {number} H
|
|
95
|
+
* @param {{ x: number, y: number, width: number, height: number }} at - Raster pixels.
|
|
96
|
+
* @returns {Promise<any>}
|
|
97
|
+
*/
|
|
98
|
+
export async function drawAt(sharp, bytes, W, H, at) {
|
|
99
|
+
const rw = Math.max(1, Math.round(at.width));
|
|
100
|
+
const rh = Math.max(1, Math.round(at.height));
|
|
101
|
+
const ox = Math.round(at.x);
|
|
102
|
+
const oy = Math.round(at.y);
|
|
103
|
+
const left = Math.max(0, -ox), top = Math.max(0, -oy);
|
|
104
|
+
const width = Math.min(rw, W - ox) - left, height = Math.min(rh, H - oy) - top;
|
|
105
|
+
const canvas = sharp({ create: { width: W, height: H, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } });
|
|
106
|
+
if (width <= 0 || height <= 0) return sharp(await canvas.png().toBuffer());
|
|
107
|
+
const part = await sharp(bytes).rotate().ensureAlpha().resize(rw, rh, { fit: 'fill' }).extract({ left, top, width, height }).png().toBuffer();
|
|
108
|
+
return sharp(await canvas.composite([{ input: part, left: Math.max(0, ox), top: Math.max(0, oy) }]).png().toBuffer());
|
|
109
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A mark's construction, read from its paths.
|
|
2
|
+
|
|
3
|
+
/** A circle the mark's curves are drawn on. */
|
|
4
|
+
export interface GuideCircle {
|
|
5
|
+
cx: number;
|
|
6
|
+
cy: number;
|
|
7
|
+
r: number;
|
|
8
|
+
/** How much of it the mark draws, radians. */
|
|
9
|
+
sweep: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A line the mark's straight edges lie on. */
|
|
13
|
+
export interface GuideLine {
|
|
14
|
+
/** Radians, 0–π. */
|
|
15
|
+
angle: number;
|
|
16
|
+
/** A point on it. */
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
/** The mark's edges on it, in total. */
|
|
20
|
+
length: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A mark's construction, in its own (512 grid) units. */
|
|
24
|
+
export interface MarkGuides {
|
|
25
|
+
/** Its real bounds. */
|
|
26
|
+
box: { x: number; y: number; width: number; height: number };
|
|
27
|
+
/** The radius of the circle in its box. */
|
|
28
|
+
inscribed: number;
|
|
29
|
+
/** The radius of the circle round its outline, from the box's centre. */
|
|
30
|
+
circumscribed: number;
|
|
31
|
+
/** Its curves' circles, the most drawn first. */
|
|
32
|
+
circles: GuideCircle[];
|
|
33
|
+
/** Its straight edges' lines, the longest first. */
|
|
34
|
+
lines: GuideLine[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read a mark's construction (null when it draws nothing); at most `circles` and `lines` of each (6). */
|
|
38
|
+
export function markGuides(markup: string, options?: { circles?: number; lines?: number }): MarkGuides | null;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mark's construction, read from its paths: the circles its curves are
|
|
3
|
+
* drawn on and the lines its straight edges lie on, for a construction
|
|
4
|
+
* sheet.
|
|
5
|
+
*
|
|
6
|
+
* @module layers/guides
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { shapes, bounds, outlinePoints, arcOf, bezierAt } from '@logomarks/core/svg';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} GuideCircle
|
|
13
|
+
* @property {number} cx @property {number} cy @property {number} r
|
|
14
|
+
* @property {number} sweep - How much of it the mark draws, radians (2π: all of it).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} GuideLine
|
|
19
|
+
* @property {number} angle - Radians, 0–π.
|
|
20
|
+
* @property {number} x @property {number} y - A point on it (the middle of its longest edge).
|
|
21
|
+
* @property {number} length - The mark's edges on it, in total.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} MarkGuides
|
|
26
|
+
* @property {{ x: number, y: number, width: number, height: number }} box - Its real bounds.
|
|
27
|
+
* @property {number} inscribed - The radius of the circle in its box, round the box's centre.
|
|
28
|
+
* @property {number} circumscribed - The radius of the circle round its outline, from the box's centre.
|
|
29
|
+
* @property {GuideCircle[]} circles - Its curves' circles, the most drawn first.
|
|
30
|
+
* @property {GuideLine[]} lines - Its straight edges' lines, the longest first.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Read marks, by markup and counts. @type {Map<string, MarkGuides>} */
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read a mark's construction. A curve counts as part of a circle when
|
|
38
|
+
* its points lie on the circle through its ends and middle (an arc, or a
|
|
39
|
+
* Bézier drawn round one); curves on the same circle are added up, and
|
|
40
|
+
* a circle is kept when the mark draws a good share of it (50° or more)
|
|
41
|
+
* and it isn't a small corner round. Straight edges on the same line are
|
|
42
|
+
* added up the same way, and lines along the box's own sides left out.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} markup - The mark's paths (512 grid).
|
|
45
|
+
* @param {{ circles?: number, lines?: number }} [options] - The most of each kept (6 and 6).
|
|
46
|
+
* @returns {MarkGuides | null} Null when it draws nothing.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* markGuides('<circle cx="256" cy="256" r="200"/>').circles; // [{ cx: 256, cy: 256, r: 200, sweep: 6.28… }]
|
|
50
|
+
*/
|
|
51
|
+
export function markGuides(markup, { circles = 6, lines = 6 } = {}) {
|
|
52
|
+
const key = `${circles}|${lines}|${markup}`;
|
|
53
|
+
if (cache.has(key)) return /** @type {MarkGuides} */ (cache.get(key));
|
|
54
|
+
const filled = shapes(String(markup ?? ''));
|
|
55
|
+
if (!filled.length) return null;
|
|
56
|
+
const b = bounds(filled);
|
|
57
|
+
if (!(b.w > 0 && b.h > 0)) return null;
|
|
58
|
+
const box = { x: b.x, y: b.y, width: b.w, height: b.h };
|
|
59
|
+
const size = Math.max(b.w, b.h);
|
|
60
|
+
const cx = b.x + b.w / 2;
|
|
61
|
+
const cy = b.y + b.h / 2;
|
|
62
|
+
|
|
63
|
+
/** @type {GuideCircle[]} */
|
|
64
|
+
const arcs = [];
|
|
65
|
+
/** @type {{ x1: number, y1: number, x2: number, y2: number }[]} */
|
|
66
|
+
const edges = [];
|
|
67
|
+
let far = 0;
|
|
68
|
+
for (const s of filled) {
|
|
69
|
+
for (const [x, y] of outlinePoints(s.segs)) far = Math.max(far, Math.hypot(x - cx, y - cy));
|
|
70
|
+
let x = 0, y = 0, sx = 0, sy = 0;
|
|
71
|
+
for (const seg of s.segs) {
|
|
72
|
+
switch (seg[0]) {
|
|
73
|
+
case 'M': x = sx = seg[1]; y = sy = seg[2]; break;
|
|
74
|
+
case 'L': edges.push({ x1: x, y1: y, x2: seg[1], y2: seg[2] }); x = seg[1]; y = seg[2]; break;
|
|
75
|
+
case 'C': fitCurve([[x, y], [seg[1], seg[2]], [seg[3], seg[4]], [seg[5], seg[6]]], arcs); x = seg[5]; y = seg[6]; break;
|
|
76
|
+
case 'Q': fitCurve([[x, y], [seg[1], seg[2]], [seg[3], seg[4]]], arcs); x = seg[3]; y = seg[4]; break;
|
|
77
|
+
case 'A': {
|
|
78
|
+
const a = arcOf(x, y, seg.slice(1));
|
|
79
|
+
if (a && Math.abs(a.rx - a.ry) <= 0.01 * Math.max(a.rx, a.ry)) arcs.push({ cx: a.cx, cy: a.cy, r: (a.rx + a.ry) / 2, sweep: Math.abs(a.sweep) });
|
|
80
|
+
x = seg[6]; y = seg[7];
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case 'Z': edges.push({ x1: x, y1: y, x2: sx, y2: sy }); x = sx; y = sy; break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const guides = {
|
|
89
|
+
box,
|
|
90
|
+
inscribed: Math.min(b.w, b.h) / 2,
|
|
91
|
+
circumscribed: far,
|
|
92
|
+
circles: circleGuides(arcs, size).slice(0, Math.max(0, circles)),
|
|
93
|
+
lines: lineGuides(edges, box).slice(0, Math.max(0, lines)),
|
|
94
|
+
};
|
|
95
|
+
if (cache.size > 200) cache.clear();
|
|
96
|
+
cache.set(key, guides);
|
|
97
|
+
return guides;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The circle a curve is drawn round, added to `arcs` when its quarter
|
|
102
|
+
* points lie on it too (within half a unit, or 1% of its radius).
|
|
103
|
+
*
|
|
104
|
+
* @param {number[][]} P - A quadratic's 3 points or a cubic's 4.
|
|
105
|
+
* @param {GuideCircle[]} arcs
|
|
106
|
+
*/
|
|
107
|
+
function fitCurve(P, arcs) {
|
|
108
|
+
const p0 = P[0];
|
|
109
|
+
const p1 = /** @type {number[]} */ (bezierAt(/** @type {any} */ (P), 0.5));
|
|
110
|
+
const p2 = P[P.length - 1];
|
|
111
|
+
const c = circleThrough(p0, p1, p2);
|
|
112
|
+
if (!c) return;
|
|
113
|
+
const tol = Math.max(0.5, c.r * 0.01);
|
|
114
|
+
for (const u of [0.25, 0.75]) {
|
|
115
|
+
const [x, y] = bezierAt(/** @type {any} */ (P), u);
|
|
116
|
+
if (Math.abs(Math.hypot(x - c.cx, y - c.cy) - c.r) > tol) return;
|
|
117
|
+
}
|
|
118
|
+
const turn = (/** @type {number[]} */ a, /** @type {number[]} */ b) => {
|
|
119
|
+
const ux = a[0] - c.cx, uy = a[1] - c.cy, vx = b[0] - c.cx, vy = b[1] - c.cy;
|
|
120
|
+
return Math.abs(Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy));
|
|
121
|
+
};
|
|
122
|
+
arcs.push({ ...c, sweep: turn(p0, p1) + turn(p1, p2) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The circle through three points, or null when they're (nearly) on a line.
|
|
127
|
+
*
|
|
128
|
+
* @param {number[]} a @param {number[]} b @param {number[]} c
|
|
129
|
+
* @returns {{ cx: number, cy: number, r: number } | null}
|
|
130
|
+
*/
|
|
131
|
+
function circleThrough(a, b, c) {
|
|
132
|
+
const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
|
|
133
|
+
if (Math.abs(d) < 1e-6) return null;
|
|
134
|
+
const A = a[0] ** 2 + a[1] ** 2, B = b[0] ** 2 + b[1] ** 2, C = c[0] ** 2 + c[1] ** 2;
|
|
135
|
+
const cx = (A * (b[1] - c[1]) + B * (c[1] - a[1]) + C * (a[1] - b[1])) / d;
|
|
136
|
+
const cy = (A * (c[0] - b[0]) + B * (a[0] - c[0]) + C * (b[0] - a[0])) / d;
|
|
137
|
+
return { cx, cy, r: Math.hypot(a[0] - cx, a[1] - cy) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Arcs on the same circle added up (centres and radii within 3 units or
|
|
142
|
+
* 3%); kept when the mark draws 50° or more of it and its radius is 4%
|
|
143
|
+
* to 120% of the mark's size; the most drawn (by length) first.
|
|
144
|
+
*
|
|
145
|
+
* @param {GuideCircle[]} arcs
|
|
146
|
+
* @param {number} size - The mark's longest side.
|
|
147
|
+
* @returns {GuideCircle[]}
|
|
148
|
+
*/
|
|
149
|
+
function circleGuides(arcs, size) {
|
|
150
|
+
/** @type {(GuideCircle & { w: number })[]} */
|
|
151
|
+
const groups = [];
|
|
152
|
+
for (const a of arcs) {
|
|
153
|
+
const near = Math.max(3, a.r * 0.03);
|
|
154
|
+
const g = groups.find((o) => Math.hypot(o.cx - a.cx, o.cy - a.cy) <= near && Math.abs(o.r - a.r) <= near);
|
|
155
|
+
if (!g) { groups.push({ ...a, w: a.sweep }); continue; }
|
|
156
|
+
// A sweep-weighted mean, so the long arcs set the circle.
|
|
157
|
+
const w = g.w + a.sweep;
|
|
158
|
+
g.cx = (g.cx * g.w + a.cx * a.sweep) / w;
|
|
159
|
+
g.cy = (g.cy * g.w + a.cy * a.sweep) / w;
|
|
160
|
+
g.r = (g.r * g.w + a.r * a.sweep) / w;
|
|
161
|
+
g.w = w;
|
|
162
|
+
g.sweep = Math.min(2 * Math.PI, g.sweep + a.sweep);
|
|
163
|
+
}
|
|
164
|
+
return groups
|
|
165
|
+
.filter((g) => g.sweep >= (50 * Math.PI) / 180 && g.r >= size * 0.04 && g.r <= size * 1.2)
|
|
166
|
+
.sort((p, q) => q.sweep * q.r - p.sweep * p.r || p.cx - q.cx || p.cy - q.cy)
|
|
167
|
+
.map(({ cx, cy, r, sweep }) => ({ cx, cy, r, sweep }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Edges on the same line added up (within 0.75° and 1.5 units); kept
|
|
172
|
+
* when 8% of the mark's size or longer in total and not along a side of
|
|
173
|
+
* its box; the longest first.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} edges
|
|
176
|
+
* @param {{ x: number, y: number, width: number, height: number }} box
|
|
177
|
+
* @returns {GuideLine[]}
|
|
178
|
+
*/
|
|
179
|
+
function lineGuides(edges, box) {
|
|
180
|
+
const size = Math.max(box.width, box.height);
|
|
181
|
+
/** @type {(GuideLine & { rho: number, best: number })[]} */
|
|
182
|
+
const groups = [];
|
|
183
|
+
for (const e of edges) {
|
|
184
|
+
const length = Math.hypot(e.x2 - e.x1, e.y2 - e.y1);
|
|
185
|
+
if (length < size * 0.02) continue;
|
|
186
|
+
let angle = Math.atan2(e.y2 - e.y1, e.x2 - e.x1);
|
|
187
|
+
if (angle < 0) angle += Math.PI;
|
|
188
|
+
if (angle >= Math.PI - 1e-9) angle -= Math.PI;
|
|
189
|
+
const x = (e.x1 + e.x2) / 2, y = (e.y1 + e.y2) / 2;
|
|
190
|
+
const rho = -Math.sin(angle) * x + Math.cos(angle) * y;
|
|
191
|
+
const g = groups.find((o) => {
|
|
192
|
+
const da = Math.abs(o.angle - angle);
|
|
193
|
+
const parallel = Math.min(da, Math.PI - da) < 0.013;
|
|
194
|
+
// Across 0 and π the line's normal flips, so its offset does too.
|
|
195
|
+
const apart = da < 0.013 ? Math.abs(o.rho - rho) : Math.abs(o.rho + rho);
|
|
196
|
+
return parallel && apart <= 1.5;
|
|
197
|
+
});
|
|
198
|
+
if (!g) { groups.push({ angle, x, y, length, rho, best: length }); continue; }
|
|
199
|
+
g.length += length;
|
|
200
|
+
if (length > g.best) { g.best = length; g.x = x; g.y = y; }
|
|
201
|
+
}
|
|
202
|
+
const onSide = (/** @type {GuideLine} */ l) => {
|
|
203
|
+
const flat = Math.min(l.angle, Math.PI - l.angle) < 0.013;
|
|
204
|
+
const upright = Math.abs(l.angle - Math.PI / 2) < 0.013;
|
|
205
|
+
return (flat && (Math.abs(l.y - box.y) < 1.5 || Math.abs(l.y - box.y - box.height) < 1.5))
|
|
206
|
+
|| (upright && (Math.abs(l.x - box.x) < 1.5 || Math.abs(l.x - box.x - box.width) < 1.5));
|
|
207
|
+
};
|
|
208
|
+
return groups
|
|
209
|
+
.filter((g) => g.length >= size * 0.08 && !onSide(g))
|
|
210
|
+
.sort((p, q) => q.length - p.length || p.angle - q.angle || p.rho - q.rho)
|
|
211
|
+
.map(({ angle, x, y, length }) => ({ angle, x, y, length }));
|
|
212
|
+
}
|
package/src/layers/index.d.ts
CHANGED
|
@@ -20,5 +20,11 @@ export { marks, sheetGrid } from './marks.js';
|
|
|
20
20
|
export type { MarksLayer } from './marks.js';
|
|
21
21
|
export { markGeometry } from './mark-geometry.js';
|
|
22
22
|
export type { MarkGeometry } from './mark-geometry.js';
|
|
23
|
+
export { outline } from './outline.js';
|
|
24
|
+
export type { OutlineLayer } from './outline.js';
|
|
25
|
+
export { construction } from './construction.js';
|
|
26
|
+
export type { ConstructionLayer } from './construction.js';
|
|
27
|
+
export { markGuides } from './guides.js';
|
|
28
|
+
export type { MarkGuides, GuideCircle, GuideLine } from './guides.js';
|
|
23
29
|
export { expandRepeat } from './repeat.js';
|
|
24
30
|
export type { RepeatLayer } from './repeat.js';
|
package/src/layers/index.js
CHANGED
|
@@ -15,4 +15,7 @@ export { texture } from './texture.js';
|
|
|
15
15
|
export { mosaic, mosaicGrid } from './mosaic.js';
|
|
16
16
|
export { marks, sheetGrid } from './marks.js';
|
|
17
17
|
export { markGeometry } from './mark-geometry.js';
|
|
18
|
+
export { outline } from './outline.js';
|
|
19
|
+
export { construction } from './construction.js';
|
|
20
|
+
export { markGuides } from './guides.js';
|
|
18
21
|
export { expandRepeat } from './repeat.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Isolines of a sampled field, and a mark's signed distance field.
|
|
2
|
+
import type { MarkGeometry } from './mark-geometry.js';
|
|
3
|
+
|
|
4
|
+
/** A signed distance field over a mark: positive outside, negative inside, mark units. */
|
|
5
|
+
export interface DistanceField {
|
|
6
|
+
values: Float32Array;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
/** The mark point of the first sample. */
|
|
10
|
+
x0: number;
|
|
11
|
+
y0: number;
|
|
12
|
+
/** Mark units between samples. */
|
|
13
|
+
step: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A mark's signed distance field over its box plus `reach`, `samples` across its longest side (500); exact near `levels`, where lines will be traced. */
|
|
17
|
+
export function distanceField(geo: MarkGeometry, reach: number, levels?: number[], samples?: number): DistanceField;
|
|
18
|
+
/** Where a field (row by row, `w` × `h`) crosses `level`: polylines of `[column, row]`, closed ones ending on their first point. */
|
|
19
|
+
export function isolines(f: ArrayLike<number>, w: number, h: number, level: number): number[][][];
|
|
20
|
+
/** A polyline simplified within `tol` and smoothed (two rounds of Chaikin, corners rounded by at most `round`), as path data. */
|
|
21
|
+
export function smoothLine(pts: number[][], tol?: number, round?: number): string;
|