@arraypress/covers 1.3.1 → 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 +39 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/recipes/flyers/construction.json +36 -0
- package/recipes/flyers/fluted-poster.json +95 -0
- package/recipes/flyers/grid-poster.json +91 -0
- package/recipes/flyers/logofolio.json +60 -0
- package/recipes/flyers/pixel-mosaic.json +58 -0
- package/recipes/flyers/series-card.json +81 -0
- package/schema/recipe.schema.json +768 -4
- package/src/index.d.ts +9 -7
- package/src/json-recipe.js +5 -0
- package/src/layer-options.js +7 -3
- 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 +12 -0
- package/src/layers/index.js +6 -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/mark-geometry.d.ts +15 -0
- package/src/layers/mark-geometry.js +46 -0
- package/src/layers/marks.d.ts +25 -0
- package/src/layers/marks.js +102 -0
- package/src/layers/mosaic.d.ts +32 -0
- package/src/layers/mosaic.js +155 -0
- package/src/layers/outline.d.ts +26 -0
- package/src/layers/outline.js +105 -0
- package/src/layers.js +7 -6
- package/src/type/fallback.js +1 -1
- package/src/type/spans.d.ts +5 -1
- package/src/type/spans.js +26 -3
- package/src/type/symbols.d.ts +1 -1
- package/src/type/symbols.js +20 -2
- package/src/type/typeset.js +6 -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,15 @@
|
|
|
1
|
+
// A mark's geometry from its markup, read with @logomarks/core.
|
|
2
|
+
import type { FillPath } from '@logomarks/core/raster';
|
|
3
|
+
|
|
4
|
+
/** A mark read from its paths. */
|
|
5
|
+
export interface MarkGeometry {
|
|
6
|
+
/** Its filled paths as rings, with their fill rules. */
|
|
7
|
+
paths: FillPath[];
|
|
8
|
+
/** Its real bounds, grid units. */
|
|
9
|
+
box: { x: number; y: number; width: number; height: number };
|
|
10
|
+
/** The share of its box it inks, 0–1. */
|
|
11
|
+
density: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read a mark (paths on a 512 grid); null when it draws nothing. */
|
|
15
|
+
export function markGeometry(markup: string): MarkGeometry | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mark's geometry from its markup (paths on a 512 grid), read with
|
|
3
|
+
* `@logomarks/core`: its filled paths as rings, its real bounds, and how
|
|
4
|
+
* much of that box it inks. Shared by the `mosaic` and `marks` layers.
|
|
5
|
+
*
|
|
6
|
+
* @module layers/mark-geometry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { shapes, rings, bounds } from '@logomarks/core/svg';
|
|
10
|
+
import { pixelGrid, pixelCoverage } from '@logomarks/core/edit';
|
|
11
|
+
|
|
12
|
+
/** Read marks, by markup (a sheet of seventy reads each once). @type {Map<string, MarkGeometry>} */
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} MarkGeometry
|
|
17
|
+
* @property {import('@logomarks/core/raster').FillPath[]} paths - Its filled paths as rings, with their fill rules.
|
|
18
|
+
* @property {{ x: number, y: number, width: number, height: number }} box - Its real bounds, grid units.
|
|
19
|
+
* @property {number} density - The share of its box it inks, 0–1.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a mark.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} markup - The mark's paths (`<path d="…"/>`, on a 512 grid).
|
|
26
|
+
* @returns {MarkGeometry | null} Null when it draws nothing.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* markGeometry('<path d="M96 96h320v320H96z"/>').box; // { x: 96, y: 96, width: 320, height: 320 }
|
|
30
|
+
*/
|
|
31
|
+
export function markGeometry(markup) {
|
|
32
|
+
if (cache.has(markup)) return /** @type {MarkGeometry} */ (cache.get(markup));
|
|
33
|
+
const filled = shapes(String(markup ?? ''));
|
|
34
|
+
const paths = filled.map((s) => ({ rings: rings(s.segs), evenodd: s.evenodd })).filter((p) => p.rings.length);
|
|
35
|
+
if (!paths.length) return null;
|
|
36
|
+
const b = bounds(filled);
|
|
37
|
+
const box = { x: b.x, y: b.y, width: Math.max(b.w, 1e-6), height: Math.max(b.h, 1e-6) };
|
|
38
|
+
// Ink measured on a 48-cell grid over the box: plenty for a density.
|
|
39
|
+
const grid = pixelGrid(0, box);
|
|
40
|
+
const cov = pixelCoverage(paths, grid);
|
|
41
|
+
const density = cov.reduce((a, v) => a + v, 0) / Math.max(1, cov.length);
|
|
42
|
+
const geo = { paths, box, density };
|
|
43
|
+
if (cache.size > 500) cache.clear();
|
|
44
|
+
cache.set(markup, geo);
|
|
45
|
+
return geo;
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The `marks` layer.
|
|
2
|
+
import type { Color, Length, BaseLayer } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
export interface MarksLayer extends BaseLayer {
|
|
6
|
+
type: 'marks';
|
|
7
|
+
/** The marks: their paths on a 512 grid (or `{ markup }`). */
|
|
8
|
+
marks: (string | { markup: string })[];
|
|
9
|
+
/** Default from the count and the box: 3 × 3 up to 10 × 7. */
|
|
10
|
+
cols?: number;
|
|
11
|
+
rows?: number;
|
|
12
|
+
/** The box the grid is centred in (default the canvas). */
|
|
13
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
14
|
+
/** The share of a cell a mark's area takes. Default 0.5. */
|
|
15
|
+
size?: number;
|
|
16
|
+
/** `false`: each mark's bounds fitted to the same box, not optically sized. Default true. */
|
|
17
|
+
optical?: boolean;
|
|
18
|
+
/** Default the palette's text. */
|
|
19
|
+
color?: Color;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Draw a set of marks on an even grid, optically sized the same. */
|
|
23
|
+
export function marks(layer: MarksLayer, ctx: RenderContext): Markup;
|
|
24
|
+
/** Columns and rows for `n` marks in a box of `aspect` (3 × 3 to 10 × 7; given ones kept). */
|
|
25
|
+
export function sheetGrid(n: number, aspect: number, cols?: number, rows?: number): { cols: number; rows: number };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `marks` layer.
|
|
3
|
+
*
|
|
4
|
+
* @module layers/marks
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { attrs, num } from '@logomarks/image-effects/svg';
|
|
8
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
9
|
+
import { pos, wrap } from './util.js';
|
|
10
|
+
import { shapesOnly } from './svg.js';
|
|
11
|
+
import { markGeometry } from './mark-geometry.js';
|
|
12
|
+
|
|
13
|
+
/** The smallest and largest sheet: 3 × 3 to 10 × 7. */
|
|
14
|
+
const MIN = 3;
|
|
15
|
+
const MAX_COLS = 10;
|
|
16
|
+
const MAX_ROWS = 7;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `marks` — a set of marks on an even grid of square cells, centred in its
|
|
20
|
+
* box so the margins match, each mark optically sized the same: scaled by
|
|
21
|
+
* its real bounds (not its 512 grid), to the same area, nudged up a little
|
|
22
|
+
* where it inks less of that area (an outline looks smaller than a solid
|
|
23
|
+
* of the same size), and never past its cell. The logofolio sheet.
|
|
24
|
+
*
|
|
25
|
+
* Options: `marks` (a list of markup strings, or `{ markup }`), `cols` and
|
|
26
|
+
* `rows` (default from the count and the box: 3 × 3 up to 10 × 7), `x`,
|
|
27
|
+
* `y`, `width`, `height` (the box; default the canvas), `size` (the share
|
|
28
|
+
* of a cell a mark's area takes, 0.5), `optical` (`false` fits each
|
|
29
|
+
* mark's bounds to the same box instead), `color` (default `text`).
|
|
30
|
+
*
|
|
31
|
+
* @param {any} layer
|
|
32
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
33
|
+
* @returns {import('../layers.js').Markup}
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* { type: 'marks', marks: ['<path d="…"/>', '<path d="…"/>'], x: 24, y: 120, width: 352, height: 340, color: '#f2efe8' }
|
|
37
|
+
*/
|
|
38
|
+
export function marks(layer, ctx) {
|
|
39
|
+
const list = (layer.marks ?? []).map((m) => (m && typeof m === 'object' ? m.markup : m)).filter(Boolean);
|
|
40
|
+
if (!list.length) return { defs: '', body: '' };
|
|
41
|
+
const bx = pos(layer.x ?? 0, 'x', ctx.canvas);
|
|
42
|
+
const by = pos(layer.y ?? 0, 'y', ctx.canvas);
|
|
43
|
+
const bw = pos(layer.width ?? '100%', 'x', ctx.canvas);
|
|
44
|
+
const bh = pos(layer.height ?? '100%', 'y', ctx.canvas);
|
|
45
|
+
const { cols, rows } = sheetGrid(list.length, bw / bh, layer.cols, layer.rows);
|
|
46
|
+
const cell = Math.min(bw / cols, bh / rows);
|
|
47
|
+
const ox = bx + (bw - cols * cell) / 2;
|
|
48
|
+
const oy = by + (bh - rows * cell) / 2;
|
|
49
|
+
const share = layer.size ?? 0.5;
|
|
50
|
+
const color = layer.color ? toHex(layer.color) : ctx.palette.text;
|
|
51
|
+
|
|
52
|
+
let body = '';
|
|
53
|
+
list.slice(0, cols * rows).forEach((markup, i) => {
|
|
54
|
+
const geo = markGeometry(markup);
|
|
55
|
+
if (!geo) return;
|
|
56
|
+
const { x, y, width, height } = geo.box;
|
|
57
|
+
// Optical: the same area (density-weighted, gently), capped at the cell; else the same longest side.
|
|
58
|
+
const fit = (cell * 0.86) / Math.max(width, height);
|
|
59
|
+
const k = layer.optical === false ? (cell * Math.sqrt(share)) / Math.max(width, height)
|
|
60
|
+
: Math.min(fit, (cell * Math.sqrt(share)) / (Math.sqrt(width * height) * (geo.density / 0.5) ** 0.15));
|
|
61
|
+
const cx = ox + (i % cols + 0.5) * cell;
|
|
62
|
+
const cy = oy + (Math.floor(i / cols) + 0.5) * cell;
|
|
63
|
+
body += `<svg${attrs({ x: cx - (width * k) / 2, y: cy - (height * k) / 2, width: width * k, height: height * k, viewBox: `${num(x)} ${num(y)} ${num(width)} ${num(height)}`, overflow: 'visible' })}>${shapesOnly(markup)}</svg>`;
|
|
64
|
+
});
|
|
65
|
+
return { defs: '', body: wrap(layer, `<g fill="${color}">${body}</g>`) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Columns and rows for `n` marks in a box of `aspect` (width / height):
|
|
70
|
+
* the biggest cells the box allows, less a cost for empty cells (so 25
|
|
71
|
+
* is 5 × 5, not 6 × 5 with a ragged row), 3 × 3 at the least, 10 × 7 at
|
|
72
|
+
* the most (marks past 70 are left out). Given ones are kept.
|
|
73
|
+
*
|
|
74
|
+
* @param {number} n
|
|
75
|
+
* @param {number} aspect
|
|
76
|
+
* @param {number} [cols]
|
|
77
|
+
* @param {number} [rows]
|
|
78
|
+
* @returns {{ cols: number, rows: number }}
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* sheetGrid(70, 0.8); // { cols: 10, rows: 7 }
|
|
82
|
+
* sheetGrid(25, 1.6); // { cols: 5, rows: 5 }
|
|
83
|
+
*/
|
|
84
|
+
export function sheetGrid(n, aspect, cols, rows) {
|
|
85
|
+
if (cols && rows) return { cols: Math.round(cols), rows: Math.round(rows) };
|
|
86
|
+
if (cols) return { cols: Math.round(cols), rows: Math.max(1, Math.ceil(n / cols)) };
|
|
87
|
+
if (rows) return { cols: Math.max(1, Math.ceil(n / rows)), rows: Math.round(rows) };
|
|
88
|
+
// The biggest cells, less a cost for empty ones (a ragged last row).
|
|
89
|
+
const count = Math.min(n, MAX_COLS * MAX_ROWS);
|
|
90
|
+
let best = { cols: MAX_COLS, rows: MAX_ROWS };
|
|
91
|
+
let score = -Infinity;
|
|
92
|
+
for (let c = MIN; c <= MAX_COLS; c++) {
|
|
93
|
+
const r = clamp(Math.ceil(count / c), MIN, MAX_ROWS);
|
|
94
|
+
if (c * r < count) continue;
|
|
95
|
+
const s = Math.min(aspect / c, 1 / r) * (1 - (1.5 * (c * r - count)) / (c * r));
|
|
96
|
+
if (s > score + 1e-9) [best, score] = [{ cols: c, rows: r }, s];
|
|
97
|
+
}
|
|
98
|
+
return best;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {number} v @param {number} lo @param {number} hi @returns {number} */
|
|
102
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The `mosaic` layer.
|
|
2
|
+
import type { Color, Length, BaseLayer } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
import type { PixelGrid } from '@logomarks/core/edit';
|
|
5
|
+
import type { MarkGeometry } from './mark-geometry.js';
|
|
6
|
+
|
|
7
|
+
export interface MosaicLayer extends BaseLayer {
|
|
8
|
+
type: 'mosaic';
|
|
9
|
+
/** The mark's paths, on a 512 grid. */
|
|
10
|
+
markup: string;
|
|
11
|
+
/** The box the mosaic is fitted and centred in (default the canvas). */
|
|
12
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
13
|
+
/** Cells across the mark's longest side, 4–48. Default 10. */
|
|
14
|
+
cells?: number;
|
|
15
|
+
/** The gap between cells, a share of a cell. Default 0.12. */
|
|
16
|
+
gap?: number;
|
|
17
|
+
/** Cell corner radius, a share of a cell. Default 0. */
|
|
18
|
+
radius?: number;
|
|
19
|
+
/** The share of filled cells left out, by the layer's seed (0–0.9). Default 0.12. */
|
|
20
|
+
drop?: number;
|
|
21
|
+
/** Default the palette's accent. */
|
|
22
|
+
color?: Color;
|
|
23
|
+
/** A small asterisk or dot in one empty cell beside the mark. */
|
|
24
|
+
accent?: 'asterisk' | 'dot' | false;
|
|
25
|
+
/** Default `color`. */
|
|
26
|
+
accentColor?: Color;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Draw a mark as a mosaic of square cells. */
|
|
30
|
+
export function mosaic(layer: MosaicLayer, ctx: RenderContext): Markup;
|
|
31
|
+
/** The mosaic's grid over a mark: `cells` across its longest side, centred on its bounds. */
|
|
32
|
+
export function mosaicGrid(geo: MarkGeometry, cells?: number): PixelGrid;
|