@1agh/maude 0.25.0 → 0.27.0
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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file draw/brush.ts — Phase 25.2 brush layer
|
|
3
|
+
* @scope plugins/design/dev-server/draw/brush.ts
|
|
4
|
+
* @purpose "Brush" expression for a pure-vector engine, three levels:
|
|
5
|
+
* L1 roughenFilter() — feTurbulence → feDisplacementMap edge
|
|
6
|
+
* texture (dry-brush / ink / charcoal roughness) you apply to
|
|
7
|
+
* any stroke or shape via `filter: 'url(#id)'`.
|
|
8
|
+
* L2 brushStroke() — a VARIABLE-WIDTH / tapering stroke. SVG
|
|
9
|
+
* `stroke` is uniform-width, so a real brush/calligraphic mark
|
|
10
|
+
* is built as a FILLED outline: a smooth centerline offset by a
|
|
11
|
+
* width profile on both sides → closed path → fill. Tapered ends
|
|
12
|
+
* give the pointed brush look.
|
|
13
|
+
* L3 scatterAlong() — a scatter/spray brush: stamp a primitive
|
|
14
|
+
* repeatedly along a path with seeded (deterministic) jitter in
|
|
15
|
+
* position / scale / rotation, optionally aligned to the tangent.
|
|
16
|
+
*
|
|
17
|
+
* True raster/bristle brushes are deliberately out of scope (they'd
|
|
18
|
+
* need a raster pipeline; this engine stays crisp-vector, single-source
|
|
19
|
+
* SVG↔JSX). See DDR-074-adjacent note.
|
|
20
|
+
*
|
|
21
|
+
* Pure + deterministic (seeded LCG, no `Math.random`); React-free.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Rect } from './geometry.ts';
|
|
25
|
+
import {
|
|
26
|
+
path,
|
|
27
|
+
type DrawPrimitive,
|
|
28
|
+
type DrawStyle,
|
|
29
|
+
type Point,
|
|
30
|
+
circle,
|
|
31
|
+
fe,
|
|
32
|
+
filter,
|
|
33
|
+
group,
|
|
34
|
+
line,
|
|
35
|
+
} from './primitives.ts';
|
|
36
|
+
|
|
37
|
+
function fmt(n: number): string {
|
|
38
|
+
return Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// L1 — edge-texture filter (dry-brush / ink / charcoal roughness)
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface RoughenOpts {
|
|
46
|
+
/** Displacement magnitude in px — higher = rougher/more broken edge (default 4). */
|
|
47
|
+
scale?: number;
|
|
48
|
+
/** Turbulence base frequency — higher = finer texture (default 0.018). */
|
|
49
|
+
frequency?: number;
|
|
50
|
+
/** Octaves of detail (default 2). */
|
|
51
|
+
detail?: number;
|
|
52
|
+
/** Deterministic turbulence seed (default 0). */
|
|
53
|
+
seed?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A roughening filter: fractal turbulence drives a displacement map that breaks
|
|
58
|
+
* up the edge of whatever it's applied to — the dry-brush / inked / charcoal
|
|
59
|
+
* look. Put it in `defs([...])` and apply with `filter: 'url(#id)'` on a
|
|
60
|
+
* `brushStroke` (or any shape). Pair with a higher `scale` for chalk, a low
|
|
61
|
+
* `frequency` for big rips, a high `frequency` for grain.
|
|
62
|
+
*/
|
|
63
|
+
export function roughenFilter(id: string, opts: RoughenOpts = {}): DrawPrimitive {
|
|
64
|
+
const { scale = 4, frequency = 0.018, detail = 2, seed = 0 } = opts;
|
|
65
|
+
return filter(
|
|
66
|
+
id,
|
|
67
|
+
[
|
|
68
|
+
fe('feTurbulence', {
|
|
69
|
+
type: 'fractalNoise',
|
|
70
|
+
baseFrequency: frequency,
|
|
71
|
+
numOctaves: detail,
|
|
72
|
+
seed,
|
|
73
|
+
result: 'noise',
|
|
74
|
+
}),
|
|
75
|
+
fe('feDisplacementMap', {
|
|
76
|
+
in: 'SourceGraphic',
|
|
77
|
+
in2: 'noise',
|
|
78
|
+
scale,
|
|
79
|
+
xChannelSelector: 'R',
|
|
80
|
+
yChannelSelector: 'G',
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
{ x: '-20%', y: '-20%', width: '140%', height: '140%' }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// L2 — variable-width / tapering brush stroke (centerline → filled outline)
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export type BrushTaper = 'both' | 'start' | 'end' | 'none';
|
|
92
|
+
|
|
93
|
+
export interface BrushStrokeOpts extends DrawStyle {
|
|
94
|
+
/** Max stroke width in px (the fattest point). Default 12. */
|
|
95
|
+
width?: number;
|
|
96
|
+
/** Where the stroke tapers to a point (default 'both' — a calligraphic mark). */
|
|
97
|
+
taper?: BrushTaper;
|
|
98
|
+
/**
|
|
99
|
+
* Custom pressure profile: arc-length `t` (0–1) → half-width multiplier (0–1).
|
|
100
|
+
* Overrides `taper` when given (model real pen pressure).
|
|
101
|
+
*/
|
|
102
|
+
profile?: (t: number) => number;
|
|
103
|
+
/** Catmull-Rom samples per input segment for the smooth centerline (default 16). */
|
|
104
|
+
samples?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sub(a: Point, b: Point): Point {
|
|
108
|
+
return { x: a.x - b.x, y: a.y - b.y };
|
|
109
|
+
}
|
|
110
|
+
function len(p: Point): number {
|
|
111
|
+
return Math.hypot(p.x, p.y) || 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Catmull-Rom resample of an OPEN polyline into a dense smooth point list. */
|
|
115
|
+
function smoothCenterline(pts: Point[], perSeg: number): Point[] {
|
|
116
|
+
if (pts.length < 3) return pts.slice();
|
|
117
|
+
const P = (i: number) => pts[Math.max(0, Math.min(pts.length - 1, i))];
|
|
118
|
+
const out: Point[] = [];
|
|
119
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
120
|
+
const p0 = P(i - 1);
|
|
121
|
+
const p1 = P(i);
|
|
122
|
+
const p2 = P(i + 1);
|
|
123
|
+
const p3 = P(i + 2);
|
|
124
|
+
for (let s = 0; s < perSeg; s++) {
|
|
125
|
+
const t = s / perSeg;
|
|
126
|
+
const t2 = t * t;
|
|
127
|
+
const t3 = t2 * t;
|
|
128
|
+
out.push({
|
|
129
|
+
x:
|
|
130
|
+
0.5 *
|
|
131
|
+
(2 * p1.x +
|
|
132
|
+
(-p0.x + p2.x) * t +
|
|
133
|
+
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
|
|
134
|
+
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
|
|
135
|
+
y:
|
|
136
|
+
0.5 *
|
|
137
|
+
(2 * p1.y +
|
|
138
|
+
(-p0.y + p2.y) * t +
|
|
139
|
+
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
|
|
140
|
+
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
out.push(pts[pts.length - 1]);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function taperProfile(taper: BrushTaper): (t: number) => number {
|
|
149
|
+
switch (taper) {
|
|
150
|
+
case 'start':
|
|
151
|
+
return (t) => t ** 0.8; // thin → thick
|
|
152
|
+
case 'end':
|
|
153
|
+
return (t) => (1 - t) ** 0.8; // thick → thin
|
|
154
|
+
case 'none':
|
|
155
|
+
return () => 1;
|
|
156
|
+
default:
|
|
157
|
+
return (t) => Math.sin(Math.PI * t) ** 0.62; // both — fat middle, pointed ends
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Smooth closed path through a loop of points (Catmull-Rom → cubic Bézier). */
|
|
162
|
+
function closedSmoothPath(pts: Point[]): string {
|
|
163
|
+
const n = pts.length;
|
|
164
|
+
if (n < 3) return '';
|
|
165
|
+
const P = (i: number) => pts[((i % n) + n) % n];
|
|
166
|
+
let d = `M${fmt(P(0).x)} ${fmt(P(0).y)}`;
|
|
167
|
+
for (let i = 0; i < n; i++) {
|
|
168
|
+
const p0 = P(i - 1);
|
|
169
|
+
const p1 = P(i);
|
|
170
|
+
const p2 = P(i + 1);
|
|
171
|
+
const p3 = P(i + 2);
|
|
172
|
+
const c1x = p1.x + (p2.x - p0.x) / 6;
|
|
173
|
+
const c1y = p1.y + (p2.y - p0.y) / 6;
|
|
174
|
+
const c2x = p2.x - (p3.x - p1.x) / 6;
|
|
175
|
+
const c2y = p2.y - (p3.y - p1.y) / 6;
|
|
176
|
+
d += ` C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
|
|
177
|
+
}
|
|
178
|
+
return `${d} Z`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A variable-width brush/calligraphic stroke following `centerline`, rendered as
|
|
183
|
+
* a single FILLED path (not an SVG stroke — SVG stroke can't taper). The
|
|
184
|
+
* centerline is smoothed (Catmull-Rom), offset on both sides by the width
|
|
185
|
+
* profile, and closed into a tapered outline. Fill defaults to `currentColor`.
|
|
186
|
+
* Apply a {@link roughenFilter} via `filter` for a dry-brush edge.
|
|
187
|
+
*/
|
|
188
|
+
export function brushStroke(centerline: Point[], opts: BrushStrokeOpts = {}): DrawPrimitive {
|
|
189
|
+
const { width = 12, taper = 'both', profile, samples = 16, ...style } = opts;
|
|
190
|
+
const prof = profile ?? taperProfile(taper);
|
|
191
|
+
const half = width / 2;
|
|
192
|
+
|
|
193
|
+
const C = smoothCenterline(centerline, samples);
|
|
194
|
+
const m = C.length;
|
|
195
|
+
if (m < 2) return path({ d: '', ...style });
|
|
196
|
+
|
|
197
|
+
// arc-length parameterization → t per vertex
|
|
198
|
+
const cum: number[] = [0];
|
|
199
|
+
for (let i = 1; i < m; i++) cum.push(cum[i - 1] + len(sub(C[i], C[i - 1])));
|
|
200
|
+
const total = cum[m - 1] || 1;
|
|
201
|
+
|
|
202
|
+
const left: Point[] = [];
|
|
203
|
+
const right: Point[] = [];
|
|
204
|
+
for (let i = 0; i < m; i++) {
|
|
205
|
+
// tangent via central difference
|
|
206
|
+
const a = C[Math.max(0, i - 1)];
|
|
207
|
+
const b = C[Math.min(m - 1, i + 1)];
|
|
208
|
+
const tx = b.x - a.x;
|
|
209
|
+
const ty = b.y - a.y;
|
|
210
|
+
const tl = Math.hypot(tx, ty) || 1;
|
|
211
|
+
const nx = -ty / tl;
|
|
212
|
+
const ny = tx / tl;
|
|
213
|
+
const hw = half * Math.max(0, prof(cum[i] / total));
|
|
214
|
+
left.push({ x: C[i].x + nx * hw, y: C[i].y + ny * hw });
|
|
215
|
+
right.push({ x: C[i].x - nx * hw, y: C[i].y - ny * hw });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// outline: left forward + right backward → closed, smoothed
|
|
219
|
+
const outline = left.concat(right.reverse());
|
|
220
|
+
return path({
|
|
221
|
+
d: closedSmoothPath(outline),
|
|
222
|
+
fill: style.fill ?? 'currentColor',
|
|
223
|
+
...stripFill(style),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stripFill(s: DrawStyle): DrawStyle {
|
|
228
|
+
const { fill: _drop, ...rest } = s;
|
|
229
|
+
return rest;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
// L3 — scatter / spray brush (stamp a primitive along a path)
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export interface StampCtx {
|
|
237
|
+
index: number;
|
|
238
|
+
/** Arc-length position 0–1 along the path. */
|
|
239
|
+
t: number;
|
|
240
|
+
/** Tangent angle in degrees (for aligning the stamp). */
|
|
241
|
+
angle: number;
|
|
242
|
+
/** A deterministic [0,1) random for this stamp (seeded). */
|
|
243
|
+
rnd: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ScatterOpts {
|
|
247
|
+
/** Number of stamps along the path (default 24). */
|
|
248
|
+
count?: number;
|
|
249
|
+
/** Max position jitter perpendicular+along the path, px (default 0). */
|
|
250
|
+
jitter?: number;
|
|
251
|
+
/** Scale variance 0–1 (default 0 = uniform). Each stamp scaled in [1−v, 1+v]. */
|
|
252
|
+
scaleVar?: number;
|
|
253
|
+
/** Extra random rotation ± this many degrees (default 0). */
|
|
254
|
+
rotateVar?: number;
|
|
255
|
+
/** Rotate each stamp to the path tangent (default false). */
|
|
256
|
+
align?: boolean;
|
|
257
|
+
/** Deterministic seed (default 1). */
|
|
258
|
+
seed?: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Stamp a primitive repeatedly along `centerline` — a scatter/spray brush. The
|
|
263
|
+
* `makeStamp` factory authors the unit stamp AT THE ORIGIN (0,0); scatter places
|
|
264
|
+
* each via a `translate → rotate → scale` group with seeded jitter. Deterministic
|
|
265
|
+
* for a given seed. Returns one group containing all stamps.
|
|
266
|
+
*/
|
|
267
|
+
export function scatterAlong(
|
|
268
|
+
centerline: Point[],
|
|
269
|
+
makeStamp: (ctx: StampCtx) => DrawPrimitive | DrawPrimitive[],
|
|
270
|
+
opts: ScatterOpts = {}
|
|
271
|
+
): DrawPrimitive {
|
|
272
|
+
const { count = 24, jitter = 0, scaleVar = 0, rotateVar = 0, align = false, seed = 1 } = opts;
|
|
273
|
+
let s = seed >>> 0 || 1;
|
|
274
|
+
const rnd = () => {
|
|
275
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
276
|
+
return s / 4294967296;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const C = smoothCenterline(centerline, 16);
|
|
280
|
+
const m = C.length;
|
|
281
|
+
const cum: number[] = [0];
|
|
282
|
+
for (let i = 1; i < m; i++) cum.push(cum[i - 1] + len(sub(C[i], C[i - 1])));
|
|
283
|
+
const total = cum[m - 1] || 1;
|
|
284
|
+
|
|
285
|
+
// sample point at arc-length fraction f via linear interpolation on C
|
|
286
|
+
const at = (f: number): { p: Point; angle: number } => {
|
|
287
|
+
const target = f * total;
|
|
288
|
+
let i = 1;
|
|
289
|
+
while (i < m && cum[i] < target) i++;
|
|
290
|
+
const i0 = Math.max(0, i - 1);
|
|
291
|
+
const seg = cum[i] - cum[i0] || 1;
|
|
292
|
+
const u = (target - cum[i0]) / seg;
|
|
293
|
+
const p = { x: C[i0].x + (C[i].x - C[i0].x) * u, y: C[i0].y + (C[i].y - C[i0].y) * u };
|
|
294
|
+
const angle = (Math.atan2(C[i].y - C[i0].y, C[i].x - C[i0].x) * 180) / Math.PI;
|
|
295
|
+
return { p, angle };
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const stamps: DrawPrimitive[] = [];
|
|
299
|
+
for (let k = 0; k < count; k++) {
|
|
300
|
+
const f = count === 1 ? 0.5 : k / (count - 1);
|
|
301
|
+
const { p, angle } = at(f);
|
|
302
|
+
const r1 = rnd();
|
|
303
|
+
const r2 = rnd();
|
|
304
|
+
const r3 = rnd();
|
|
305
|
+
const x = p.x + (r1 * 2 - 1) * jitter;
|
|
306
|
+
const y = p.y + (r2 * 2 - 1) * jitter;
|
|
307
|
+
const sc = 1 + (r3 * 2 - 1) * scaleVar;
|
|
308
|
+
const rot = (align ? angle : 0) + (rnd() * 2 - 1) * rotateVar;
|
|
309
|
+
const made = makeStamp({ index: k, t: f, angle, rnd: r1 });
|
|
310
|
+
const kids = Array.isArray(made) ? made : [made];
|
|
311
|
+
const parts: string[] = [`translate(${fmt(x)} ${fmt(y)})`];
|
|
312
|
+
if (rot) parts.push(`rotate(${fmt(rot)})`);
|
|
313
|
+
if (sc !== 1) parts.push(`scale(${fmt(sc)})`);
|
|
314
|
+
stamps.push(group(kids, { transform: parts.join(' ') }));
|
|
315
|
+
}
|
|
316
|
+
return group(stamps, { id: 'scatter' });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
// L4 — engraving hatching (the grave-etcher look: dense directional line shading)
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
export interface HatchOpts {
|
|
324
|
+
/** Line angle in degrees (default 45). */
|
|
325
|
+
angle?: number;
|
|
326
|
+
/** Gap between lines in px — smaller = darker tone (default 6). */
|
|
327
|
+
spacing?: number;
|
|
328
|
+
/** Line weight (default 0.8). Engraving stays fine. */
|
|
329
|
+
weight?: number;
|
|
330
|
+
/** Stroke color (default currentColor). */
|
|
331
|
+
color?: string;
|
|
332
|
+
/**
|
|
333
|
+
* Burin swell: alternate lines thicken by ±this fraction (default 0) — the
|
|
334
|
+
* engine-turned / banknote shimmer. 0.6 ≈ a strong copperplate swell.
|
|
335
|
+
*/
|
|
336
|
+
weightVar?: number;
|
|
337
|
+
/** Round the line ends (default true — softer engraving terminals). */
|
|
338
|
+
round?: boolean;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A field of parallel engraving lines covering `region`'s bounding box at
|
|
343
|
+
* `angle` / `spacing`. Clip it to a shape (`group([hatch(...)], { clipPath:
|
|
344
|
+
* 'url(#id)' })`) to shade that shape; overlay a second hatch at another angle
|
|
345
|
+
* (or {@link crossHatch}) for the darker tones. Spacing IS the tone — tighter
|
|
346
|
+
* spacing reads darker, exactly as in real line engraving.
|
|
347
|
+
*/
|
|
348
|
+
export function hatch(region: Rect, opts: HatchOpts = {}): DrawPrimitive {
|
|
349
|
+
const {
|
|
350
|
+
angle = 45,
|
|
351
|
+
spacing = 6,
|
|
352
|
+
weight = 0.8,
|
|
353
|
+
color = 'currentColor',
|
|
354
|
+
weightVar = 0,
|
|
355
|
+
round = true,
|
|
356
|
+
} = opts;
|
|
357
|
+
const cx = region.x + region.width / 2;
|
|
358
|
+
const cy = region.y + region.height / 2;
|
|
359
|
+
const rad = (angle * Math.PI) / 180;
|
|
360
|
+
const dx = Math.cos(rad);
|
|
361
|
+
const dy = Math.sin(rad);
|
|
362
|
+
const nx = -dy;
|
|
363
|
+
const ny = dx;
|
|
364
|
+
const half = Math.hypot(region.width, region.height) / 2 + spacing;
|
|
365
|
+
const lines: DrawPrimitive[] = [];
|
|
366
|
+
let i = 0;
|
|
367
|
+
for (let p = -half; p <= half; p += spacing) {
|
|
368
|
+
const mx = cx + nx * p;
|
|
369
|
+
const my = cy + ny * p;
|
|
370
|
+
const w = weightVar ? weight * (1 + (i % 2 ? weightVar : -weightVar)) : weight;
|
|
371
|
+
lines.push(
|
|
372
|
+
line({
|
|
373
|
+
x1: mx - dx * half,
|
|
374
|
+
y1: my - dy * half,
|
|
375
|
+
x2: mx + dx * half,
|
|
376
|
+
y2: my + dy * half,
|
|
377
|
+
stroke: color,
|
|
378
|
+
strokeWidth: Math.max(0.15, w),
|
|
379
|
+
strokeLinecap: round ? 'round' : 'butt',
|
|
380
|
+
})
|
|
381
|
+
);
|
|
382
|
+
i++;
|
|
383
|
+
}
|
|
384
|
+
return group(lines, { id: `hatch-${Math.round(angle)}` });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface CrossHatchOpts extends HatchOpts {
|
|
388
|
+
/** Second angle (default angle + 90). */
|
|
389
|
+
angle2?: number;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Two crossed hatch fields → the deepest engraving tone. Clip to a shape. */
|
|
393
|
+
export function crossHatch(region: Rect, opts: CrossHatchOpts = {}): DrawPrimitive {
|
|
394
|
+
const a1 = opts.angle ?? 45;
|
|
395
|
+
const a2 = opts.angle2 ?? a1 + 90;
|
|
396
|
+
return group([hatch(region, { ...opts, angle: a1 }), hatch(region, { ...opts, angle: a2 })], {
|
|
397
|
+
id: 'crosshatch',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
// L5 — contour engraving lines ("multiple engraving lines from one stroke") +
|
|
403
|
+
// graded stipple (the grave-etcher / Sailor-Jerry soul: lines that FOLLOW
|
|
404
|
+
// the form, and stipple that grades into tone)
|
|
405
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
/** Open smooth path (Catmull-Rom → cubic Bézier) through points — no close. */
|
|
408
|
+
function openSmoothPath(pts: Point[]): string {
|
|
409
|
+
if (pts.length < 2) return '';
|
|
410
|
+
const P = (i: number) => pts[Math.max(0, Math.min(pts.length - 1, i))];
|
|
411
|
+
let d = `M${fmt(pts[0].x)} ${fmt(pts[0].y)}`;
|
|
412
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
413
|
+
const p0 = P(i - 1);
|
|
414
|
+
const p1 = P(i);
|
|
415
|
+
const p2 = P(i + 1);
|
|
416
|
+
const p3 = P(i + 2);
|
|
417
|
+
const c1x = p1.x + (p2.x - p0.x) / 6;
|
|
418
|
+
const c1y = p1.y + (p2.y - p0.y) / 6;
|
|
419
|
+
const c2x = p2.x - (p3.x - p1.x) / 6;
|
|
420
|
+
const c2y = p2.y - (p3.y - p1.y) / 6;
|
|
421
|
+
d += ` C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
|
|
422
|
+
}
|
|
423
|
+
return d;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export interface ContourOpts {
|
|
427
|
+
/** Explicit normal offsets per line (caller controls density grading). */
|
|
428
|
+
offsets?: number[];
|
|
429
|
+
/** Or: `count` lines auto-spaced by `spacing` and centered on the stroke. */
|
|
430
|
+
count?: number;
|
|
431
|
+
spacing?: number;
|
|
432
|
+
/** Per-line end trim 0–0.45 (fraction off EACH end) → organic fading lines. */
|
|
433
|
+
lengthProfile?: (k: number, n: number) => number;
|
|
434
|
+
weight?: number;
|
|
435
|
+
/** Burin swell — alternate lines ±this fraction (default 0). */
|
|
436
|
+
weightVar?: number;
|
|
437
|
+
color?: string;
|
|
438
|
+
round?: boolean;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The grave-etcher mechanic: from ONE centerline stroke, emit a family of
|
|
443
|
+
* offset copies that FOLLOW the form (each line is the stroke pushed along its
|
|
444
|
+
* per-point normal). This is what wraps a skull / bottle / muscle in engraving
|
|
445
|
+
* lines and reads as volume — the thing flat bbox-hatch can't do. Grade density
|
|
446
|
+
* (pass `offsets` bunched toward the shadow edge) and trim line lengths
|
|
447
|
+
* (`lengthProfile`) for the hand-engraved fade.
|
|
448
|
+
*/
|
|
449
|
+
export function contourLines(centerline: Point[], opts: ContourOpts = {}): DrawPrimitive {
|
|
450
|
+
const {
|
|
451
|
+
count = 8,
|
|
452
|
+
spacing = 4,
|
|
453
|
+
lengthProfile,
|
|
454
|
+
weight = 0.7,
|
|
455
|
+
weightVar = 0,
|
|
456
|
+
color = 'currentColor',
|
|
457
|
+
round = true,
|
|
458
|
+
} = opts;
|
|
459
|
+
const offsets =
|
|
460
|
+
opts.offsets ?? Array.from({ length: count }, (_, k) => (k - (count - 1) / 2) * spacing);
|
|
461
|
+
const C = smoothCenterline(centerline, 16);
|
|
462
|
+
const m = C.length;
|
|
463
|
+
const N: Point[] = [];
|
|
464
|
+
for (let i = 0; i < m; i++) {
|
|
465
|
+
const a = C[Math.max(0, i - 1)];
|
|
466
|
+
const b = C[Math.min(m - 1, i + 1)];
|
|
467
|
+
const tx = b.x - a.x;
|
|
468
|
+
const ty = b.y - a.y;
|
|
469
|
+
const tl = Math.hypot(tx, ty) || 1;
|
|
470
|
+
N.push({ x: -ty / tl, y: tx / tl });
|
|
471
|
+
}
|
|
472
|
+
const lines: DrawPrimitive[] = [];
|
|
473
|
+
offsets.forEach((off, k) => {
|
|
474
|
+
const trim = lengthProfile ? Math.max(0, Math.min(0.45, lengthProfile(k, offsets.length))) : 0;
|
|
475
|
+
const i0 = Math.round(trim * (m - 1));
|
|
476
|
+
const i1 = m - 1 - i0;
|
|
477
|
+
const pts: Point[] = [];
|
|
478
|
+
for (let i = i0; i <= i1; i++) pts.push({ x: C[i].x + N[i].x * off, y: C[i].y + N[i].y * off });
|
|
479
|
+
if (pts.length >= 2) {
|
|
480
|
+
lines.push(
|
|
481
|
+
path({
|
|
482
|
+
d: openSmoothPath(pts),
|
|
483
|
+
fill: 'none',
|
|
484
|
+
stroke: color,
|
|
485
|
+
strokeWidth: Math.max(
|
|
486
|
+
0.15,
|
|
487
|
+
weightVar ? weight * (1 + (k % 2 ? weightVar : -weightVar)) : weight
|
|
488
|
+
),
|
|
489
|
+
strokeLinecap: round ? 'round' : 'butt',
|
|
490
|
+
strokeLinejoin: 'round',
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
return group(lines, { id: 'contour' });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export interface StippleOpts {
|
|
499
|
+
/** Candidate dot count (default 500). Actual count depends on `density`. */
|
|
500
|
+
dots?: number;
|
|
501
|
+
dotR?: number;
|
|
502
|
+
color?: string;
|
|
503
|
+
seed?: number;
|
|
504
|
+
/**
|
|
505
|
+
* Tonal gradient: normalized (nx, ny) in [0,1]² → keep-probability 0–1. Dense
|
|
506
|
+
* where it returns ~1, sparse where ~0 → graded tone (stipple shading). Clip
|
|
507
|
+
* the result to a shape for stippled volume.
|
|
508
|
+
*/
|
|
509
|
+
density?: (nx: number, ny: number) => number;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Graded stipple fill over a region — the stipple shader. Unlike a flat dot
|
|
514
|
+
* cluster, `density(nx,ny)` makes dots thin out toward the light, giving real
|
|
515
|
+
* tonal volume (the Sailor-Jerry / dotwork look). Clip to a shape via
|
|
516
|
+
* `group([stippleFill(...)], { clipPath })`. Deterministic (seeded).
|
|
517
|
+
*/
|
|
518
|
+
export function stippleFill(region: Rect, opts: StippleOpts = {}): DrawPrimitive {
|
|
519
|
+
const { dots = 500, dotR = 1.2, color = 'currentColor', seed = 1, density } = opts;
|
|
520
|
+
let s = seed >>> 0 || 1;
|
|
521
|
+
const rnd = () => {
|
|
522
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
523
|
+
return s / 4294967296;
|
|
524
|
+
};
|
|
525
|
+
const out: DrawPrimitive[] = [];
|
|
526
|
+
for (let i = 0; i < dots; i++) {
|
|
527
|
+
const nx = rnd();
|
|
528
|
+
const ny = rnd();
|
|
529
|
+
const keep = density ? density(nx, ny) : 1;
|
|
530
|
+
if (rnd() <= keep) {
|
|
531
|
+
out.push(
|
|
532
|
+
circle({
|
|
533
|
+
cx: region.x + nx * region.width,
|
|
534
|
+
cy: region.y + ny * region.height,
|
|
535
|
+
r: dotR * (0.6 + rnd() * 0.8),
|
|
536
|
+
fill: color,
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return group(out, { id: 'stipple' });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
545
|
+
// L6 — organic engrave lines: every line tapers to points (burin lift), carries
|
|
546
|
+
// its own subtle wobble + jittered weight/length/spacing. NO global filter
|
|
547
|
+
// (a uniform displacement reads as "filtered", not hand-drawn). This is what
|
|
548
|
+
// gives engraving its organic, drawn-by-hand soul.
|
|
549
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
export interface EngraveOpts {
|
|
552
|
+
/** Explicit normal offsets per line (caller grades density). */
|
|
553
|
+
offsets?: number[];
|
|
554
|
+
/** Or auto: `count` lines `spacing` apart, centered on the stroke. */
|
|
555
|
+
count?: number;
|
|
556
|
+
spacing?: number;
|
|
557
|
+
/** Random ± fraction added to each offset (default 0.18) — uneven, hand spacing. */
|
|
558
|
+
spacingJitter?: number;
|
|
559
|
+
/** Base line weight at its thickest (default 1.4). Each line tapers to 0 at ends. */
|
|
560
|
+
weight?: number;
|
|
561
|
+
/** ± weight fraction per line (default 0.4) — no two lines identical. */
|
|
562
|
+
weightJitter?: number;
|
|
563
|
+
/** Perpendicular wobble amplitude px (default 1.1) — the hand-waver. */
|
|
564
|
+
wobble?: number;
|
|
565
|
+
/** Wobble cycles along the line (default 1.6). */
|
|
566
|
+
wobbleFreq?: number;
|
|
567
|
+
/** Random end-trim fraction per end, 0–0.4 (default 0.12) — staggered organic ends. */
|
|
568
|
+
lengthJitter?: number;
|
|
569
|
+
color?: string;
|
|
570
|
+
seed?: number;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Organic engraving lines from one centerline stroke. Unlike {@link contourLines}
|
|
575
|
+
* (clean offsets) every line here is an independent hand-drawn mark: a tapered
|
|
576
|
+
* `brushStroke` (thin → thick → thin = burin lift) on a slightly wobbled,
|
|
577
|
+
* length-jittered, weight-jittered, unevenly-spaced offset of the stroke. The
|
|
578
|
+
* result reads as drawn, not computed. Clip to a shape for shaded volume.
|
|
579
|
+
*/
|
|
580
|
+
export function engraveLines(centerline: Point[], opts: EngraveOpts = {}): DrawPrimitive {
|
|
581
|
+
const {
|
|
582
|
+
count = 10,
|
|
583
|
+
spacing = 5,
|
|
584
|
+
spacingJitter = 0.18,
|
|
585
|
+
weight = 1.4,
|
|
586
|
+
weightJitter = 0.4,
|
|
587
|
+
wobble = 1.1,
|
|
588
|
+
wobbleFreq = 1.6,
|
|
589
|
+
lengthJitter = 0.12,
|
|
590
|
+
color = 'currentColor',
|
|
591
|
+
seed = 1,
|
|
592
|
+
} = opts;
|
|
593
|
+
let s = seed >>> 0 || 1;
|
|
594
|
+
const rnd = () => {
|
|
595
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
596
|
+
return s / 4294967296;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const base =
|
|
600
|
+
opts.offsets ?? Array.from({ length: count }, (_, k) => (k - (count - 1) / 2) * spacing);
|
|
601
|
+
const C = smoothCenterline(centerline, 14);
|
|
602
|
+
const m = C.length;
|
|
603
|
+
const N: Point[] = [];
|
|
604
|
+
for (let i = 0; i < m; i++) {
|
|
605
|
+
const a = C[Math.max(0, i - 1)];
|
|
606
|
+
const b = C[Math.min(m - 1, i + 1)];
|
|
607
|
+
const tx = b.x - a.x;
|
|
608
|
+
const ty = b.y - a.y;
|
|
609
|
+
const tl = Math.hypot(tx, ty) || 1;
|
|
610
|
+
N.push({ x: -ty / tl, y: tx / tl });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const lines: DrawPrimitive[] = [];
|
|
614
|
+
for (let k = 0; k < base.length; k++) {
|
|
615
|
+
const off = base[k] + (rnd() * 2 - 1) * spacing * spacingJitter;
|
|
616
|
+
const t0 = rnd() * lengthJitter;
|
|
617
|
+
const t1 = 1 - rnd() * lengthJitter;
|
|
618
|
+
const i0 = Math.round(t0 * (m - 1));
|
|
619
|
+
const i1 = Math.round(t1 * (m - 1));
|
|
620
|
+
if (i1 - i0 < 2) continue;
|
|
621
|
+
const phase = rnd() * Math.PI * 2;
|
|
622
|
+
const pts: Point[] = [];
|
|
623
|
+
for (let i = i0; i <= i1; i++) {
|
|
624
|
+
const lt = (i - i0) / (i1 - i0);
|
|
625
|
+
const env = Math.sin(Math.PI * lt); // wobble fades to 0 at the ends
|
|
626
|
+
const w = wobble * Math.sin(wobbleFreq * 2 * Math.PI * lt + phase) * env;
|
|
627
|
+
const d = off + w;
|
|
628
|
+
pts.push({ x: C[i].x + N[i].x * d, y: C[i].y + N[i].y * d });
|
|
629
|
+
}
|
|
630
|
+
// resample to a handful of control points so brushStroke smooths, not bloats
|
|
631
|
+
const ctrl: Point[] = [];
|
|
632
|
+
const step = Math.max(1, Math.floor(pts.length / 9));
|
|
633
|
+
for (let i = 0; i < pts.length; i += step) ctrl.push(pts[i]);
|
|
634
|
+
ctrl.push(pts[pts.length - 1]);
|
|
635
|
+
const lw = Math.max(0.4, weight * (1 + (rnd() * 2 - 1) * weightJitter));
|
|
636
|
+
lines.push(brushStroke(ctrl, { width: lw, taper: 'both', fill: color, samples: 6 }));
|
|
637
|
+
}
|
|
638
|
+
return group(lines, { id: 'engrave' });
|
|
639
|
+
}
|