@arraypress/covers 1.4.0 → 1.5.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 +21 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/recipes/flyers/construction.json +36 -0
- package/recipes/flyers/fluted-poster.json +95 -0
- package/recipes/flyers/series-card.json +81 -0
- package/schema/recipe.schema.json +463 -1
- package/src/index.d.ts +3 -3
- package/src/layer-options.js +2 -0
- package/src/layers/construction.d.ts +34 -0
- package/src/layers/construction.js +137 -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 +2 -2
- package/src/version.js +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolines of a sampled field (marching squares) and a mark's signed
|
|
3
|
+
* distance field, for contour lines drawn round a mark.
|
|
4
|
+
*
|
|
5
|
+
* @module layers/isolines
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fill, distance } from '@logomarks/core/raster';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} DistanceField
|
|
12
|
+
* @property {Float32Array} values - Signed distance in mark units, row by row: positive outside the mark, negative inside.
|
|
13
|
+
* @property {number} width - Samples across.
|
|
14
|
+
* @property {number} height - Samples down.
|
|
15
|
+
* @property {number} x0 - The mark x of the first column's centre.
|
|
16
|
+
* @property {number} y0 - The mark y of the first row's centre.
|
|
17
|
+
* @property {number} step - Mark units between samples.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A mark's signed distance field over its box plus `reach` round it,
|
|
22
|
+
* `samples` across its longest side. Its rings are filled and measured by
|
|
23
|
+
* `@logomarks/core`'s scanline fill and Euclidean distance transform; that
|
|
24
|
+
* measures to pixel centres, so where a line will be traced (samples near
|
|
25
|
+
* one of `levels`) the distance is then measured exactly, to the rings'
|
|
26
|
+
* edges, and the lines come out smooth.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('./mark-geometry.js').MarkGeometry} geo
|
|
29
|
+
* @param {number} reach - Mark units round the box the field must cover.
|
|
30
|
+
* @param {number[]} [levels] - The distances lines will be traced at (mark units).
|
|
31
|
+
* @param {number} [samples=500]
|
|
32
|
+
* @returns {DistanceField}
|
|
33
|
+
*/
|
|
34
|
+
export function distanceField(geo, reach, levels = [], samples = 500) {
|
|
35
|
+
const { box } = geo;
|
|
36
|
+
const scale = samples / Math.max(box.width, box.height);
|
|
37
|
+
const pad = reach + 4 / scale;
|
|
38
|
+
const frame = {
|
|
39
|
+
x0: box.x - pad,
|
|
40
|
+
y0: box.y - pad,
|
|
41
|
+
scale,
|
|
42
|
+
width: Math.ceil((box.width + 2 * pad) * scale),
|
|
43
|
+
height: Math.ceil((box.height + 2 * pad) * scale),
|
|
44
|
+
};
|
|
45
|
+
const bm = fill(geo.paths, frame);
|
|
46
|
+
const out = distance(bm, 0);
|
|
47
|
+
const inside = distance(bm, 1);
|
|
48
|
+
const values = new Float32Array(out.length);
|
|
49
|
+
// Pixel centres: half a pixel either side of the edge between ink and empty.
|
|
50
|
+
for (let i = 0; i < values.length; i++) values[i] = (bm.data[i] ? 0.5 - inside[i] : out[i] - 0.5) / scale;
|
|
51
|
+
const x0 = frame.x0 + 0.5 / scale;
|
|
52
|
+
const y0 = frame.y0 + 0.5 / scale;
|
|
53
|
+
if (levels.length) exactNear(values, frame.width, { x0, y0, step: 1 / scale }, geo, levels);
|
|
54
|
+
return { values, width: frame.width, height: frame.height, x0, y0, step: 1 / scale };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Measure the samples within two samples of a level exactly: the distance
|
|
59
|
+
* to the nearest edge of the mark's rings, its sign kept. Edges are binned
|
|
60
|
+
* so each sample looks only at those within its rough distance.
|
|
61
|
+
*
|
|
62
|
+
* @param {Float32Array} values - Rough signed distances (changed).
|
|
63
|
+
* @param {number} w - Samples across.
|
|
64
|
+
* @param {{ x0: number, y0: number, step: number }} at - Where the samples are.
|
|
65
|
+
* @param {import('./mark-geometry.js').MarkGeometry} geo
|
|
66
|
+
* @param {number[]} levels
|
|
67
|
+
*/
|
|
68
|
+
function exactNear(values, w, { x0, y0, step }, geo, levels) {
|
|
69
|
+
/** @type {number[]} x1, y1, x2, y2 per edge */
|
|
70
|
+
const edges = [];
|
|
71
|
+
for (const p of geo.paths) {
|
|
72
|
+
for (const ring of p.rings) {
|
|
73
|
+
for (let i = 0; i < ring.length; i++) {
|
|
74
|
+
const a = ring[i], b = ring[(i + 1) % ring.length];
|
|
75
|
+
if (a[0] !== b[0] || a[1] !== b[1]) edges.push(a[0], a[1], b[0], b[1]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const { box } = geo;
|
|
80
|
+
const size = Math.max(box.width, box.height);
|
|
81
|
+
const cell = size / 32;
|
|
82
|
+
const bx = box.x - size, by = box.y - size;
|
|
83
|
+
const cols = Math.ceil((box.width + 2 * size) / cell);
|
|
84
|
+
const rows = Math.ceil((box.height + 2 * size) / cell);
|
|
85
|
+
/** @type {number[][]} */
|
|
86
|
+
const bins = Array.from({ length: cols * rows }, () => []);
|
|
87
|
+
for (let e = 0; e < edges.length; e += 4) {
|
|
88
|
+
const c0 = Math.floor((Math.min(edges[e], edges[e + 2]) - bx) / cell), c1 = Math.floor((Math.max(edges[e], edges[e + 2]) - bx) / cell);
|
|
89
|
+
const r0 = Math.floor((Math.min(edges[e + 1], edges[e + 3]) - by) / cell), r1 = Math.floor((Math.max(edges[e + 1], edges[e + 3]) - by) / cell);
|
|
90
|
+
for (let r = Math.max(0, r0); r <= Math.min(rows - 1, r1); r++) for (let c = Math.max(0, c0); c <= Math.min(cols - 1, c1); c++) bins[r * cols + c].push(e);
|
|
91
|
+
}
|
|
92
|
+
const band = 2 * step;
|
|
93
|
+
for (let i = 0; i < values.length; i++) {
|
|
94
|
+
const v = values[i];
|
|
95
|
+
if (!levels.some((l) => Math.abs(v - l) <= band)) continue;
|
|
96
|
+
const x = x0 + (i % w) * step, y = y0 + Math.floor(i / w) * step;
|
|
97
|
+
// The nearest edge is no further than the rough distance and a sample or two.
|
|
98
|
+
const R = Math.abs(v) + band;
|
|
99
|
+
let best = R * R;
|
|
100
|
+
const c0 = Math.max(0, Math.floor((x - R - bx) / cell)), c1 = Math.min(cols - 1, Math.floor((x + R - bx) / cell));
|
|
101
|
+
const r0 = Math.max(0, Math.floor((y - R - by) / cell)), r1 = Math.min(rows - 1, Math.floor((y + R - by) / cell));
|
|
102
|
+
for (let r = r0; r <= r1; r++) {
|
|
103
|
+
for (let c = c0; c <= c1; c++) {
|
|
104
|
+
for (const e of bins[r * cols + c]) {
|
|
105
|
+
const ax = edges[e], ay = edges[e + 1], dx = edges[e + 2] - ax, dy = edges[e + 3] - ay;
|
|
106
|
+
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));
|
|
107
|
+
const ex = ax + t * dx - x, ey = ay + t * dy - y;
|
|
108
|
+
const d2 = ex * ex + ey * ey;
|
|
109
|
+
if (d2 < best) best = d2;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
values[i] = Math.sign(v || 1) * Math.sqrt(best);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The lines where a field crosses `level`, as polylines of sample
|
|
119
|
+
* coordinates (`[column, row]`), closed ones ending on their first point.
|
|
120
|
+
* Marching squares, saddles split by the cell's mean; crossings are
|
|
121
|
+
* chained by the grid edge they sit on, so the joins are exact.
|
|
122
|
+
*
|
|
123
|
+
* @param {ArrayLike<number>} f - Values row by row.
|
|
124
|
+
* @param {number} w - Columns.
|
|
125
|
+
* @param {number} h - Rows.
|
|
126
|
+
* @param {number} level
|
|
127
|
+
* @returns {number[][][]}
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* isolines([0, 0, 0, 0, 1, 0, 0, 0, 0], 3, 3, 0.5); // one closed diamond round the middle sample
|
|
131
|
+
*/
|
|
132
|
+
export function isolines(f, w, h, level) {
|
|
133
|
+
// Edge ids: horizontal (i, j)–(i + 1, j) is j·w + i; vertical (i, j)–(i, j + 1) is w·h + j·w + i.
|
|
134
|
+
const V = w * h;
|
|
135
|
+
/** @type {Map<number, number[]>} */
|
|
136
|
+
const at = new Map();
|
|
137
|
+
/** @type {number[][]} */
|
|
138
|
+
const segs = [];
|
|
139
|
+
const link = (/** @type {number} */ a, /** @type {number} */ b) => {
|
|
140
|
+
const s = segs.push([a, b]) - 1;
|
|
141
|
+
for (const e of [a, b]) {
|
|
142
|
+
const list = at.get(e);
|
|
143
|
+
if (list) list.push(s); else at.set(e, [s]);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (let j = 0; j < h - 1; j++) {
|
|
147
|
+
for (let i = 0; i < w - 1; i++) {
|
|
148
|
+
const a = f[j * w + i] - level;
|
|
149
|
+
const b = f[j * w + i + 1] - level;
|
|
150
|
+
const c = f[(j + 1) * w + i + 1] - level;
|
|
151
|
+
const d = f[(j + 1) * w + i] - level;
|
|
152
|
+
const code = (a > 0 ? 8 : 0) | (b > 0 ? 4 : 0) | (c > 0 ? 2 : 0) | (d > 0 ? 1 : 0);
|
|
153
|
+
if (code === 0 || code === 15) continue;
|
|
154
|
+
const top = j * w + i;
|
|
155
|
+
const bottom = (j + 1) * w + i;
|
|
156
|
+
const left = V + j * w + i;
|
|
157
|
+
const right = V + j * w + i + 1;
|
|
158
|
+
const mid = a + b + c + d > 0;
|
|
159
|
+
switch (code) {
|
|
160
|
+
case 1: case 14: link(left, bottom); break;
|
|
161
|
+
case 2: case 13: link(bottom, right); break;
|
|
162
|
+
case 3: case 12: link(left, right); break;
|
|
163
|
+
case 4: case 11: link(top, right); break;
|
|
164
|
+
case 6: case 9: link(top, bottom); break;
|
|
165
|
+
case 7: case 8: link(left, top); break;
|
|
166
|
+
case 5: if (mid) { link(left, top); link(bottom, right); } else { link(left, bottom); link(top, right); } break;
|
|
167
|
+
case 10: if (mid) { link(left, bottom); link(top, right); } else { link(left, top); link(bottom, right); } break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const point = (/** @type {number} */ e) => {
|
|
172
|
+
if (e < V) {
|
|
173
|
+
const i = e % w, j = Math.floor(e / w);
|
|
174
|
+
const p = f[e] - level, q = f[e + 1] - level;
|
|
175
|
+
return [i + p / (p - q), j];
|
|
176
|
+
}
|
|
177
|
+
const k = e - V, i = k % w, j = Math.floor(k / w);
|
|
178
|
+
const p = f[k] - level, q = f[k + w] - level;
|
|
179
|
+
return [i, j + p / (p - q)];
|
|
180
|
+
};
|
|
181
|
+
const used = new Uint8Array(segs.length);
|
|
182
|
+
/** @type {number[][][]} */
|
|
183
|
+
const out = [];
|
|
184
|
+
for (let s = 0; s < segs.length; s++) {
|
|
185
|
+
if (used[s]) continue;
|
|
186
|
+
used[s] = 1;
|
|
187
|
+
const edges = [segs[s][0], segs[s][1]];
|
|
188
|
+
for (const forward of [true, false]) {
|
|
189
|
+
for (;;) {
|
|
190
|
+
const tip = forward ? edges[edges.length - 1] : edges[0];
|
|
191
|
+
const next = (at.get(tip) ?? []).find((n) => !used[n]);
|
|
192
|
+
if (next === undefined) break;
|
|
193
|
+
used[next] = 1;
|
|
194
|
+
const other = segs[next][0] === tip ? segs[next][1] : segs[next][0];
|
|
195
|
+
if (forward) edges.push(other); else edges.unshift(other);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (edges.length > 2) out.push(edges.map(point));
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A polyline simplified within `tol` (Douglas-Peucker; a closed one split
|
|
205
|
+
* at its farthest point so both halves keep their ends), then smoothed by
|
|
206
|
+
* two rounds of Chaikin (corners rounded by at most `round`), as path data.
|
|
207
|
+
*
|
|
208
|
+
* @param {number[][]} pts - Canvas units; closed when the last is the first.
|
|
209
|
+
* @param {number} [tol=0.25]
|
|
210
|
+
* @param {number} [round=0.6]
|
|
211
|
+
* @returns {string}
|
|
212
|
+
*/
|
|
213
|
+
export function smoothLine(pts, tol = 0.25, round = 0.6) {
|
|
214
|
+
const first = pts[0];
|
|
215
|
+
const last = pts[pts.length - 1];
|
|
216
|
+
const closed = pts.length > 3 && Math.hypot(first[0] - last[0], first[1] - last[1]) < 1e-9;
|
|
217
|
+
/** @type {number[][]} */
|
|
218
|
+
let p;
|
|
219
|
+
if (closed) {
|
|
220
|
+
let far = 0, fd = -1;
|
|
221
|
+
pts.forEach((q, i) => { const d = Math.hypot(q[0] - first[0], q[1] - first[1]); if (d > fd) { fd = d; far = i; } });
|
|
222
|
+
p = [...simplify(pts.slice(0, far + 1), tol).slice(0, -1), ...simplify(pts.slice(far), tol).slice(0, -1)];
|
|
223
|
+
if (p.length < 3) return '';
|
|
224
|
+
} else {
|
|
225
|
+
p = simplify(pts, tol);
|
|
226
|
+
}
|
|
227
|
+
// Chaikin, twice: each edge cut a quarter of the way in from both ends (a
|
|
228
|
+
// closed ring wraps round), but never more than `round` from a corner, so a
|
|
229
|
+
// long straight run meeting a sharp cusp keeps its point instead of a bevel.
|
|
230
|
+
let q = p;
|
|
231
|
+
for (const r of [round, round / 2]) {
|
|
232
|
+
const n = q.length;
|
|
233
|
+
const next = closed ? [] : [q[0]];
|
|
234
|
+
for (let i = 0; i < (closed ? n : n - 1); i++) {
|
|
235
|
+
const a = q[i], b = q[(i + 1) % n];
|
|
236
|
+
const t = Math.min(0.25, r / (Math.hypot(b[0] - a[0], b[1] - a[1]) || 1));
|
|
237
|
+
next.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], [b[0] + (a[0] - b[0]) * t, b[1] + (a[1] - b[1]) * t]);
|
|
238
|
+
}
|
|
239
|
+
if (!closed) next.push(q[n - 1]);
|
|
240
|
+
q = next;
|
|
241
|
+
}
|
|
242
|
+
const f = (/** @type {number} */ v) => (Math.round(v * 100) / 100).toString();
|
|
243
|
+
return `M${q.map((pt) => `${f(pt[0])} ${f(pt[1])}`).join('L')}${closed ? 'Z' : ''}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Douglas-Peucker on an open run: points within `tol` of the line between
|
|
248
|
+
* the kept ones go; the ends stay.
|
|
249
|
+
*
|
|
250
|
+
* @param {number[][]} pts
|
|
251
|
+
* @param {number} tol
|
|
252
|
+
* @returns {number[][]}
|
|
253
|
+
*/
|
|
254
|
+
function simplify(pts, tol) {
|
|
255
|
+
if (pts.length < 3) return pts.slice();
|
|
256
|
+
const keep = new Uint8Array(pts.length);
|
|
257
|
+
keep[0] = keep[pts.length - 1] = 1;
|
|
258
|
+
const stack = [[0, pts.length - 1]];
|
|
259
|
+
while (stack.length) {
|
|
260
|
+
const [i, j] = /** @type {number[]} */ (stack.pop());
|
|
261
|
+
const [ax, ay] = pts[i], [bx, by] = pts[j];
|
|
262
|
+
const L = Math.hypot(bx - ax, by - ay) || 1e-12;
|
|
263
|
+
let far = -1, fd = tol;
|
|
264
|
+
for (let k = i + 1; k < j; k++) {
|
|
265
|
+
const d = L > 1e-9 ? Math.abs((bx - ax) * (ay - pts[k][1]) - (ax - pts[k][0]) * (by - ay)) / L : Math.hypot(pts[k][0] - ax, pts[k][1] - ay);
|
|
266
|
+
if (d > fd) { fd = d; far = k; }
|
|
267
|
+
}
|
|
268
|
+
if (far >= 0) { keep[far] = 1; stack.push([i, far], [far, j]); }
|
|
269
|
+
}
|
|
270
|
+
return pts.filter((_, k) => keep[k]);
|
|
271
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Placing a mark by its real bounds and writing its outline in canvas units.
|
|
2
|
+
import type { RenderContext } from '../render/context.js';
|
|
3
|
+
|
|
4
|
+
/** A box, canvas or mark units. */
|
|
5
|
+
export interface Box { x: number; y: number; width: number; height: number }
|
|
6
|
+
|
|
7
|
+
/** A mark placed in an area: canvas x = mark x × k + dx. */
|
|
8
|
+
export interface MarkFit {
|
|
9
|
+
k: number;
|
|
10
|
+
dx: number;
|
|
11
|
+
dy: number;
|
|
12
|
+
/** The area it was fitted in, canvas units. */
|
|
13
|
+
area: Box;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The layer's box (`x`, `y`, `width`, `height`; default the canvas), canvas units. */
|
|
17
|
+
export function layerBox(layer: { x?: unknown; y?: unknown; width?: unknown; height?: unknown }, ctx: Pick<RenderContext, 'canvas'>): Box;
|
|
18
|
+
/** Fit a mark's bounds (plus `reach` round them) into an area, centred: the longest side takes `share` of the area's shorter side, never past the area. */
|
|
19
|
+
export function fitMark(box: Box, area: Box, share?: number, reach?: number): MarkFit;
|
|
20
|
+
/** A mark's filled shapes as path data in canvas units, each with its fill rule. */
|
|
21
|
+
export function markPaths(markup: string, fit: MarkFit): { d: string; evenodd: boolean }[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placing a mark by its real bounds and writing its outline in canvas
|
|
3
|
+
* units, so strokes drawn with it (keylines, contour lines) keep their
|
|
4
|
+
* width whatever the mark's scale. Shared by the `outline` and
|
|
5
|
+
* `construction` layers.
|
|
6
|
+
*
|
|
7
|
+
* @module layers/mark-draw
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { num } from '@logomarks/image-effects/svg';
|
|
11
|
+
import { shapes } from '@logomarks/core/svg';
|
|
12
|
+
import { pos } from './util.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} MarkFit
|
|
16
|
+
* @property {number} k - Canvas units a mark unit.
|
|
17
|
+
* @property {number} dx - Added to a mark x times k.
|
|
18
|
+
* @property {number} dy - Added to a mark y times k.
|
|
19
|
+
* @property {{ x: number, y: number, width: number, height: number }} area - The layer's box, canvas units.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The layer's box (`x`, `y`, `width`, `height`; default the canvas), in
|
|
24
|
+
* canvas units.
|
|
25
|
+
*
|
|
26
|
+
* @param {any} layer
|
|
27
|
+
* @param {{ canvas: { width: number, height: number } }} ctx
|
|
28
|
+
* @returns {{ x: number, y: number, width: number, height: number }}
|
|
29
|
+
*/
|
|
30
|
+
export function layerBox(layer, ctx) {
|
|
31
|
+
return {
|
|
32
|
+
x: pos(layer.x ?? 0, 'x', ctx.canvas),
|
|
33
|
+
y: pos(layer.y ?? 0, 'y', ctx.canvas),
|
|
34
|
+
width: pos(layer.width ?? '100%', 'x', ctx.canvas),
|
|
35
|
+
height: pos(layer.height ?? '100%', 'y', ctx.canvas),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fit a box of mark units (its real bounds, plus `reach` round it) into an
|
|
41
|
+
* area, centred: the longest side takes `share` of the area's shorter
|
|
42
|
+
* side, and the whole never passes the area.
|
|
43
|
+
*
|
|
44
|
+
* @param {{ x: number, y: number, width: number, height: number }} box - The mark's bounds, mark units.
|
|
45
|
+
* @param {{ x: number, y: number, width: number, height: number }} area - Canvas units.
|
|
46
|
+
* @param {number} [share=1] - 0–1.
|
|
47
|
+
* @param {number} [reach=0] - Mark units drawn round the bounds (contour lines), kept inside too.
|
|
48
|
+
* @returns {MarkFit}
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* fitMark({ x: 96, y: 96, width: 320, height: 320 }, { x: 0, y: 0, width: 400, height: 400 }, 0.5).k; // 0.625
|
|
52
|
+
*/
|
|
53
|
+
export function fitMark(box, area, share = 1, reach = 0) {
|
|
54
|
+
const w = box.width + 2 * reach;
|
|
55
|
+
const h = box.height + 2 * reach;
|
|
56
|
+
const k = Math.min((Math.min(area.width, area.height) * share) / Math.max(w, h), area.width / w, area.height / h);
|
|
57
|
+
const cx = box.x + box.width / 2;
|
|
58
|
+
const cy = box.y + box.height / 2;
|
|
59
|
+
return { k, dx: area.x + area.width / 2 - cx * k, dy: area.y + area.height / 2 - cy * k, area };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A mark's filled shapes as path data in canvas units (each with its fill
|
|
64
|
+
* rule), through a fit.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} markup - The mark's paths (512 grid).
|
|
67
|
+
* @param {MarkFit} fit
|
|
68
|
+
* @returns {{ d: string, evenodd: boolean }[]}
|
|
69
|
+
*/
|
|
70
|
+
export function markPaths(markup, { k, dx, dy }) {
|
|
71
|
+
return shapes(String(markup ?? '')).map((s) => ({ d: segsPath(s.segs, k, dx, dy), evenodd: s.evenodd }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absolute segments scaled by `k` and moved by `dx`, `dy`, as path data.
|
|
76
|
+
*
|
|
77
|
+
* @param {import('@logomarks/core/svg').Segment[]} segs
|
|
78
|
+
* @param {number} k @param {number} dx @param {number} dy
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function segsPath(segs, k, dx, dy) {
|
|
82
|
+
const X = (/** @type {number} */ v) => num(v * k + dx, 2);
|
|
83
|
+
const Y = (/** @type {number} */ v) => num(v * k + dy, 2);
|
|
84
|
+
return segs.map((s) => {
|
|
85
|
+
switch (s[0]) {
|
|
86
|
+
case 'M': case 'L': return `${s[0]}${X(s[1])} ${Y(s[2])}`;
|
|
87
|
+
case 'C': return `C${X(s[1])} ${Y(s[2])} ${X(s[3])} ${Y(s[4])} ${X(s[5])} ${Y(s[6])}`;
|
|
88
|
+
case 'Q': return `Q${X(s[1])} ${Y(s[2])} ${X(s[3])} ${Y(s[4])}`;
|
|
89
|
+
case 'A': return `A${num(s[1] * k, 2)} ${num(s[2] * k, 2)} ${num(s[3])} ${s[4] ? 1 : 0} ${s[5] ? 1 : 0} ${X(s[6])} ${Y(s[7])}`;
|
|
90
|
+
default: return 'Z';
|
|
91
|
+
}
|
|
92
|
+
}).join('');
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The `outline` layer.
|
|
2
|
+
import type { Color, Length, BaseLayer } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
export interface OutlineLayer extends BaseLayer {
|
|
6
|
+
type: 'outline';
|
|
7
|
+
/** The mark's paths, on a 512 grid. */
|
|
8
|
+
markup: string;
|
|
9
|
+
/** The box the lines are fitted and centred in (default the canvas). */
|
|
10
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
11
|
+
/** The share of the box's shorter side the whole set's longest side takes. Default 1. */
|
|
12
|
+
size?: number;
|
|
13
|
+
/** Contour lines, 0–8: the outline and more round it, `gap` apart. 0 draws the mark solid. Default 4. */
|
|
14
|
+
lines?: number;
|
|
15
|
+
/** Between lines, a share of the mark's longest side. Default 0.05. */
|
|
16
|
+
gap?: number;
|
|
17
|
+
/** Lines round the mark (`'out'`, the default) or inside it (`'in'`). */
|
|
18
|
+
direction?: 'out' | 'in';
|
|
19
|
+
/** Line width. Default `'0.4vmin'`. */
|
|
20
|
+
stroke?: Length;
|
|
21
|
+
/** Default the palette's text. */
|
|
22
|
+
color?: Color;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Draw a mark as contour lines (or solid), fitted by its real bounds. */
|
|
26
|
+
export function outline(layer: OutlineLayer, ctx: RenderContext): Markup;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `outline` layer.
|
|
3
|
+
*
|
|
4
|
+
* @module layers/outline
|
|
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 { markGeometry } from './mark-geometry.js';
|
|
12
|
+
import { layerBox, fitMark, markPaths } from './mark-draw.js';
|
|
13
|
+
import { distanceField, isolines, smoothLine } from './isolines.js';
|
|
14
|
+
|
|
15
|
+
/** Contour lines drawn by the field, by markup and spacing (a series of ten draws each mark once). @type {Map<string, number[][][][]>} */
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `outline` — a mark fitted to its box by its real bounds, drawn as
|
|
20
|
+
* contour lines: its own outline, then `lines − 1` more, each `gap`
|
|
21
|
+
* further out (or in), as a topographic map draws a hill. The offsets
|
|
22
|
+
* are isolines of the mark's signed distance field, so parts that grow
|
|
23
|
+
* into each other merge, as contours do, and outer corners round off.
|
|
24
|
+
* `lines: 0` draws the mark solid instead, in the same place.
|
|
25
|
+
*
|
|
26
|
+
* Options: `markup` (512 grid), `x`, `y`, `width`, `height` (the box; the
|
|
27
|
+
* whole set of lines is fitted and centred in it), `size` (the share of
|
|
28
|
+
* the box's shorter side the set's longest side takes, 1), `lines` (0–8,
|
|
29
|
+
* 4), `gap` (a share of the mark's longest side, 0.05), `direction`
|
|
30
|
+
* (`'out'`, `'in'`), `stroke` (px, `'0.4vmin'`), `color` (default `text`).
|
|
31
|
+
*
|
|
32
|
+
* @param {any} layer
|
|
33
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
34
|
+
* @returns {import('../layers.js').Markup}
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* { type: 'outline', markup: mark, x: 140, y: 100, width: 240, height: 280, lines: 4, gap: 0.05, color: '#1b1b1b' }
|
|
38
|
+
*/
|
|
39
|
+
export function outline(layer, ctx) {
|
|
40
|
+
const geo = markGeometry(layer.markup);
|
|
41
|
+
if (!geo) return { defs: '', body: '' };
|
|
42
|
+
const lines = Math.round(Math.min(8, Math.max(0, layer.lines ?? 4)));
|
|
43
|
+
const side = Math.max(geo.box.width, geo.box.height);
|
|
44
|
+
const gap = side * Math.max(0.005, layer.gap ?? 0.05);
|
|
45
|
+
const inward = layer.direction === 'in';
|
|
46
|
+
const reach = lines > 1 && !inward ? gap * (lines - 1) : 0;
|
|
47
|
+
const fit = fitMark(geo.box, layerBox(layer, ctx), layer.size ?? 1, reach);
|
|
48
|
+
const color = layer.color ? toHex(layer.color) : ctx.palette.text;
|
|
49
|
+
const own = markPaths(layer.markup, fit);
|
|
50
|
+
|
|
51
|
+
if (!lines) {
|
|
52
|
+
const body = own.map((p) => `<path${attrs({ d: p.d, 'fill-rule': p.evenodd ? 'evenodd' : undefined })}/>`).join('');
|
|
53
|
+
return { defs: '', body: wrap(layer, `<g fill="${color}">${body}</g>`) };
|
|
54
|
+
}
|
|
55
|
+
const sw = len(layer.stroke, ctx.canvas, '0.4vmin');
|
|
56
|
+
// The outline itself, exactly; the offsets from the field.
|
|
57
|
+
let d = own.map((p) => p.d).join('');
|
|
58
|
+
for (const ring of offsetLines(layer.markup, geo, gap, lines, inward)) {
|
|
59
|
+
d += smoothLine(ring.map(([x, y]) => [x * fit.k + fit.dx, y * fit.k + fit.dy]), 0.1);
|
|
60
|
+
}
|
|
61
|
+
const body = `<path${attrs({ d, fill: 'none', stroke: color, 'stroke-width': num(sw, 3), 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })}/>`;
|
|
62
|
+
return { defs: '', body: wrap(layer, body) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The offset lines (not the outline itself), mark units: one set of
|
|
67
|
+
* polylines for each of `lines − 1` distances, `gap` apart.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} markup
|
|
70
|
+
* @param {import('./mark-geometry.js').MarkGeometry} geo
|
|
71
|
+
* @param {number} gap - Mark units.
|
|
72
|
+
* @param {number} lines
|
|
73
|
+
* @param {boolean} inward
|
|
74
|
+
* @returns {number[][][]}
|
|
75
|
+
*/
|
|
76
|
+
function offsetLines(markup, geo, gap, lines, inward) {
|
|
77
|
+
const key = `${num(gap, 3)}|${lines}|${inward ? 'in' : 'out'}|${markup}`;
|
|
78
|
+
let sets = cache.get(key);
|
|
79
|
+
if (!sets) {
|
|
80
|
+
const levels = Array.from({ length: lines - 1 }, (_, i) => (inward ? -1 : 1) * gap * (i + 1));
|
|
81
|
+
const field = distanceField(geo, inward ? 0 : gap * (lines - 1), levels);
|
|
82
|
+
sets = [];
|
|
83
|
+
for (const level of levels) {
|
|
84
|
+
// Loops much smaller than the gap are where the field peaks at the level (between parts): left out.
|
|
85
|
+
sets.push(isolines(field.values, field.width, field.height, level)
|
|
86
|
+
.map((line) => line.map(([c, r]) => [field.x0 + c * field.step, field.y0 + r * field.step]))
|
|
87
|
+
.filter((line) => extent(line) >= gap * 0.6));
|
|
88
|
+
}
|
|
89
|
+
if (cache.size > 100) cache.clear();
|
|
90
|
+
cache.set(key, sets);
|
|
91
|
+
}
|
|
92
|
+
return sets.flat();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The longer side of a polyline's box.
|
|
97
|
+
*
|
|
98
|
+
* @param {number[][]} line
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
function extent(line) {
|
|
102
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
103
|
+
for (const [x, y] of line) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
|
|
104
|
+
return Math.max(x1 - x0, y1 - y0);
|
|
105
|
+
}
|
package/src/layers.js
CHANGED
|
@@ -29,7 +29,7 @@ import { paper, scribble } from './handmade.js';
|
|
|
29
29
|
import { ink, dither, contour, waveform, tiles, rule } from './print.js';
|
|
30
30
|
import { typeset, wantsType } from './type/index.js';
|
|
31
31
|
import { recordLayout } from './type/layout-ref.js';
|
|
32
|
-
import { shape, svg, table, barcode, perforation, tear, texture, mosaic, marks } from './layers/index.js';
|
|
32
|
+
import { shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, outline, construction } from './layers/index.js';
|
|
33
33
|
import { pos, layerAttrs } from './layers/util.js';
|
|
34
34
|
import { paint } from './paint/index.js';
|
|
35
35
|
import { imageSize } from './lint/image-size.js';
|
|
@@ -1038,4 +1038,4 @@ function group() {
|
|
|
1038
1038
|
throw new TypeError('A group layer is drawn by renderSvg()/renderImage(), not on its own');
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
-
export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, repeat: group, defs: group, use: group });
|
|
1041
|
+
export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, outline, construction, repeat: group, defs: group, use: group });
|
package/src/version.js
CHANGED