@1agh/maude 0.25.0 → 0.26.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.
Files changed (36) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  7. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  8. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  9. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  11. package/plugins/design/dev-server/config.schema.json +10 -0
  12. package/plugins/design/dev-server/context.ts +9 -0
  13. package/plugins/design/dev-server/dist/client.bundle.js +3 -3
  14. package/plugins/design/dev-server/draw/brush.ts +639 -0
  15. package/plugins/design/dev-server/draw/composition.ts +229 -0
  16. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  17. package/plugins/design/dev-server/draw/index.ts +28 -0
  18. package/plugins/design/dev-server/draw/layout.ts +260 -0
  19. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  20. package/plugins/design/dev-server/draw/palette.ts +417 -0
  21. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  22. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  23. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  24. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  25. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  26. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  27. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  28. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  29. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  30. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  31. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  32. package/plugins/design/dev-server/sync/index.ts +73 -17
  33. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  34. package/plugins/design/dev-server/tsconfig.json +8 -1
  35. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  36. 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
+ }