git-hash-art 0.7.0 → 0.9.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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
7
7
  "prebuild": "rm -rf .parcel-cache",
8
8
  "build": "parcel build",
9
9
  "build:examples": "node bin/generateExamples.js",
10
+ "build:versions": "node bin/generateVersionComparison.js",
10
11
  "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
11
12
  "format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
12
13
  "release": "release-it",
@@ -219,6 +219,57 @@ const ARCHETYPES: Archetype[] = [
219
219
  sizePower: 2.5,
220
220
  invertForeground: false,
221
221
  },
222
+ {
223
+ name: "watercolor-wash",
224
+ gridSize: 3,
225
+ layers: 3,
226
+ baseOpacity: 0.25,
227
+ opacityReduction: 0.03,
228
+ minShapeSize: 200,
229
+ maxShapeSize: 700,
230
+ backgroundStyle: "radial-light",
231
+ paletteMode: "harmonious",
232
+ preferredStyles: ["watercolor", "fill-only", "incomplete"],
233
+ flowLineMultiplier: 0.5,
234
+ heroShape: false,
235
+ glowMultiplier: 0.3,
236
+ sizePower: 0.6,
237
+ invertForeground: false,
238
+ },
239
+ {
240
+ name: "op-art",
241
+ gridSize: 8,
242
+ layers: 2,
243
+ baseOpacity: 0.95,
244
+ opacityReduction: 0.05,
245
+ minShapeSize: 20,
246
+ maxShapeSize: 200,
247
+ backgroundStyle: "solid-light",
248
+ paletteMode: "high-contrast",
249
+ preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
250
+ flowLineMultiplier: 0,
251
+ heroShape: false,
252
+ glowMultiplier: 0,
253
+ sizePower: 0.4,
254
+ invertForeground: false,
255
+ },
256
+ {
257
+ name: "collage",
258
+ gridSize: 4,
259
+ layers: 3,
260
+ baseOpacity: 0.9,
261
+ opacityReduction: 0.08,
262
+ minShapeSize: 80,
263
+ maxShapeSize: 500,
264
+ backgroundStyle: "solid-light",
265
+ paletteMode: "duotone",
266
+ preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
267
+ flowLineMultiplier: 0,
268
+ heroShape: true,
269
+ glowMultiplier: 0,
270
+ sizePower: 0.7,
271
+ invertForeground: false,
272
+ },
222
273
  {
223
274
  name: "classic",
224
275
  gridSize: 5,
@@ -236,6 +287,74 @@ const ARCHETYPES: Archetype[] = [
236
287
  sizePower: 1.8,
237
288
  invertForeground: false,
238
289
  },
290
+ {
291
+ name: "shattered-glass",
292
+ gridSize: 8,
293
+ layers: 3,
294
+ baseOpacity: 0.85,
295
+ opacityReduction: 0.1,
296
+ minShapeSize: 15,
297
+ maxShapeSize: 250,
298
+ backgroundStyle: "solid-dark",
299
+ paletteMode: "high-contrast",
300
+ preferredStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
301
+ flowLineMultiplier: 0,
302
+ heroShape: false,
303
+ glowMultiplier: 0.3,
304
+ sizePower: 1.0,
305
+ invertForeground: false,
306
+ },
307
+ {
308
+ name: "botanical",
309
+ gridSize: 4,
310
+ layers: 4,
311
+ baseOpacity: 0.5,
312
+ opacityReduction: 0.06,
313
+ minShapeSize: 30,
314
+ maxShapeSize: 400,
315
+ backgroundStyle: "radial-light",
316
+ paletteMode: "earth",
317
+ preferredStyles: ["watercolor", "fill-only", "incomplete"],
318
+ flowLineMultiplier: 3,
319
+ heroShape: true,
320
+ glowMultiplier: 0.2,
321
+ sizePower: 1.6,
322
+ invertForeground: false,
323
+ },
324
+ {
325
+ name: "stipple-portrait",
326
+ gridSize: 9,
327
+ layers: 2,
328
+ baseOpacity: 0.8,
329
+ opacityReduction: 0.05,
330
+ minShapeSize: 5,
331
+ maxShapeSize: 120,
332
+ backgroundStyle: "solid-light",
333
+ paletteMode: "monochrome",
334
+ preferredStyles: ["stipple", "fill-only", "hatched"],
335
+ flowLineMultiplier: 0,
336
+ heroShape: false,
337
+ glowMultiplier: 0,
338
+ sizePower: 2.8,
339
+ invertForeground: false,
340
+ },
341
+ {
342
+ name: "celestial",
343
+ gridSize: 7,
344
+ layers: 5,
345
+ baseOpacity: 0.45,
346
+ opacityReduction: 0.04,
347
+ minShapeSize: 8,
348
+ maxShapeSize: 450,
349
+ backgroundStyle: "radial-dark",
350
+ paletteMode: "neon",
351
+ preferredStyles: ["fill-only", "watercolor", "stroke-only", "incomplete"],
352
+ flowLineMultiplier: 2,
353
+ heroShape: true,
354
+ glowMultiplier: 2.5,
355
+ sizePower: 2.2,
356
+ invertForeground: false,
357
+ },
239
358
  ];
240
359
 
241
360
  /**
@@ -325,9 +325,87 @@ export function hexWithAlpha(hex: string, alpha: number): string {
325
325
  }
326
326
 
327
327
  /**
328
- * Apply slight hue/saturation/lightness jitter to a hex color.
329
- * `rng` should return a float in [0,1). `amount` controls intensity (0-1, default 0.1).
328
+ * Color hierarchy — assigns dominant/secondary/accent roles to a palette.
329
+ * Dominant gets ~60% of usage, secondary ~25%, accent ~15%.
330
330
  */
331
+ export interface ColorHierarchy {
332
+ dominant: string;
333
+ secondary: string;
334
+ accent: string;
335
+ all: string[];
336
+ }
337
+
338
+ export function buildColorHierarchy(colors: string[], rng: () => number): ColorHierarchy {
339
+ if (colors.length < 3) {
340
+ return {
341
+ dominant: colors[0] || "#888888",
342
+ secondary: colors[1] || colors[0] || "#888888",
343
+ accent: colors[colors.length - 1] || "#888888",
344
+ all: colors,
345
+ };
346
+ }
347
+ // Pick dominant as the color closest to the palette's average hue
348
+ const hsls = colors.map((c) => hexToHsl(c));
349
+ const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
350
+ let dominantIdx = 0;
351
+ let minDist = 360;
352
+ for (let i = 0; i < hsls.length; i++) {
353
+ const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
354
+ if (d < minDist) { minDist = d; dominantIdx = i; }
355
+ }
356
+ // Accent is the color most distant from dominant in hue
357
+ let accentIdx = 0;
358
+ let maxDist = 0;
359
+ for (let i = 0; i < hsls.length; i++) {
360
+ if (i === dominantIdx) continue;
361
+ const d = Math.min(Math.abs(hsls[i][0] - hsls[dominantIdx][0]), 360 - Math.abs(hsls[i][0] - hsls[dominantIdx][0]));
362
+ if (d > maxDist) { maxDist = d; accentIdx = i; }
363
+ }
364
+ // Secondary is the remaining color with highest saturation
365
+ let secondaryIdx = 0;
366
+ let maxSat = -1;
367
+ for (let i = 0; i < hsls.length; i++) {
368
+ if (i === dominantIdx || i === accentIdx) continue;
369
+ if (hsls[i][1] > maxSat) { maxSat = hsls[i][1]; secondaryIdx = i; }
370
+ }
371
+ if (secondaryIdx === dominantIdx) secondaryIdx = accentIdx === 0 ? 1 : 0;
372
+
373
+ return {
374
+ dominant: colors[dominantIdx],
375
+ secondary: colors[secondaryIdx],
376
+ accent: colors[accentIdx],
377
+ all: colors,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Pick a color from the hierarchy with weighted probability.
383
+ * ~60% dominant, ~25% secondary, ~15% accent.
384
+ */
385
+ export function pickHierarchyColor(hierarchy: ColorHierarchy, rng: () => number): string {
386
+ const roll = rng();
387
+ if (roll < 0.60) return hierarchy.dominant;
388
+ if (roll < 0.85) return hierarchy.secondary;
389
+ return hierarchy.accent;
390
+ }
391
+
392
+ /**
393
+ * HSL-space color jitter — preserves vibrancy better than RGB jitter.
394
+ * Applies small hue wobble + saturation/lightness variation.
395
+ */
396
+ export function jitterColorHSL(
397
+ hex: string,
398
+ rng: () => number,
399
+ hueAmount = 8,
400
+ slAmount = 0.06,
401
+ ): string {
402
+ const [h, s, l] = hexToHsl(hex);
403
+ const newH = (h + (rng() - 0.5) * hueAmount * 2 + 360) % 360;
404
+ const newS = Math.max(0, Math.min(1, s + (rng() - 0.5) * slAmount * 2));
405
+ const newL = Math.max(0, Math.min(1, l + (rng() - 0.5) * slAmount * 2));
406
+ return hslToHex(newH, newS, newL);
407
+ }
408
+
331
409
  export function jitterColor(
332
410
  hex: string,
333
411
  rng: () => number,
@@ -402,3 +480,33 @@ export function enforceContrast(
402
480
  return hslToHex(h, targetS, targetL);
403
481
  }
404
482
  }
483
+
484
+ /**
485
+ * Apply a unified color grade to a hex color — shifts the entire image
486
+ * toward a cohesive tone. This is the "Instagram filter" effect.
487
+ */
488
+ export function applyColorGrade(
489
+ hex: string,
490
+ gradeHue: number,
491
+ intensity: number,
492
+ ): string {
493
+ const [h, s, l] = hexToHsl(hex);
494
+ // Blend hue toward the grade hue
495
+ const hueDiff = ((gradeHue - h + 540) % 360) - 180;
496
+ const newH = (h + hueDiff * intensity * 0.3 + 360) % 360;
497
+ // Slightly unify saturation
498
+ const newS = Math.max(0, Math.min(1, s + (0.5 - s) * intensity * 0.15));
499
+ return hslToHex(newH, newS, l);
500
+ }
501
+
502
+ /**
503
+ * Compute a deterministic color grade from the hash.
504
+ * Returns a hue (0-360) and intensity (0.15-0.4).
505
+ */
506
+ export function pickColorGrade(rng: () => number): { hue: number; intensity: number } {
507
+ // Warm golden, cool blue, rosy, teal, amber
508
+ const GRADE_HUES = [40, 220, 340, 175, 30];
509
+ const hue = GRADE_HUES[Math.floor(rng() * GRADE_HUES.length)] + (rng() - 0.5) * 20;
510
+ const intensity = 0.15 + rng() * 0.25;
511
+ return { hue: (hue + 360) % 360, intensity };
512
+ }
@@ -35,7 +35,13 @@ export type RenderStyle =
35
35
  | "dashed" // dashed outline
36
36
  | "watercolor" // multiple offset passes at low opacity
37
37
  | "hatched" // cross-hatch texture fill
38
- | "incomplete"; // draw only 60-85% of the stroke path
38
+ | "incomplete" // draw only 60-85% of the stroke path
39
+ | "stipple" // dot-fill texture
40
+ | "stencil" // negative-space cutout effect
41
+ | "noise-grain" // procedural noise grain texture clipped to shape
42
+ | "wood-grain" // parallel wavy lines simulating wood
43
+ | "marble-vein" // branching vein lines on a soft fill
44
+ | "fabric-weave"; // interlocking horizontal/vertical threads
39
45
 
40
46
  const RENDER_STYLES: RenderStyle[] = [
41
47
  "fill-and-stroke",
@@ -47,6 +53,12 @@ const RENDER_STYLES: RenderStyle[] = [
47
53
  "watercolor",
48
54
  "hatched",
49
55
  "incomplete",
56
+ "stipple",
57
+ "stencil",
58
+ "noise-grain",
59
+ "wood-grain",
60
+ "marble-vein",
61
+ "fabric-weave",
50
62
  ];
51
63
 
52
64
  export function pickRenderStyle(rng: () => number): RenderStyle {
@@ -151,23 +163,56 @@ function applyRenderStyle(
151
163
  break;
152
164
 
153
165
  case "watercolor": {
154
- // Draw 3-4 slightly offset passes at low opacity for a bleed effect
155
- const passes = 3 + (rng ? Math.floor(rng() * 2) : 0);
166
+ // Improved watercolor: edge darkening + radial bleed + layered washes
167
+ const passes = 4 + (rng ? Math.floor(rng() * 2) : 0);
156
168
  const savedAlpha = ctx.globalAlpha;
157
- ctx.globalAlpha = savedAlpha * (0.3 / passes * 2);
169
+
170
+ // Pass 1: Base wash — large, soft fill at low opacity
171
+ ctx.globalAlpha = savedAlpha * 0.15;
172
+ ctx.save();
173
+ const baseScale = 1.08 + (rng ? rng() * 0.04 : 0);
174
+ ctx.scale(baseScale, baseScale);
175
+ ctx.fill();
176
+ ctx.restore();
177
+
178
+ // Pass 2: Multiple offset washes with radial displacement
179
+ ctx.globalAlpha = savedAlpha * (0.25 / passes * 2);
158
180
  for (let p = 0; p < passes; p++) {
159
- const jx = rng ? (rng() - 0.5) * size * 0.06 : 0;
160
- const jy = rng ? (rng() - 0.5) * size * 0.06 : 0;
181
+ // Radial outward displacement (not uniform) for organic bleed
182
+ const angle = rng ? rng() * Math.PI * 2 : p * Math.PI / 2;
183
+ const dist = rng ? rng() * size * 0.05 : size * 0.02;
184
+ const jx = Math.cos(angle) * dist;
185
+ const jy = Math.sin(angle) * dist;
161
186
  ctx.save();
162
187
  ctx.translate(jx, jy);
163
188
  ctx.fill();
164
189
  ctx.restore();
165
190
  }
191
+
192
+ // Pass 3: Edge darkening — draw a slightly smaller shape with lighter fill
193
+ // to simulate pigment pooling at boundaries
194
+ ctx.globalAlpha = savedAlpha * 0.35;
195
+ ctx.save();
196
+ const innerScale = 0.85 + (rng ? rng() * 0.08 : 0);
197
+ ctx.scale(innerScale, innerScale);
198
+ // Lighten the fill for the inner area
199
+ const origFill = ctx.fillStyle;
200
+ if (typeof fillColor === "string") {
201
+ ctx.fillStyle = fillColor.replace(/[\d.]+\)$/, (m) => {
202
+ const v = parseFloat(m);
203
+ return Math.min(1, v * 1.4).toFixed(2) + ")";
204
+ });
205
+ }
206
+ ctx.fill();
207
+ ctx.fillStyle = origFill;
208
+ ctx.restore();
209
+
166
210
  ctx.globalAlpha = savedAlpha;
167
- // Light stroke on top
168
- ctx.globalAlpha *= 0.4;
211
+ // Soft stroke on top — thinner than normal for delicacy
212
+ ctx.globalAlpha *= 0.25;
213
+ ctx.lineWidth = strokeWidth * 0.6;
169
214
  ctx.stroke();
170
- ctx.globalAlpha /= 0.4;
215
+ ctx.globalAlpha /= 0.25;
171
216
  break;
172
217
  }
173
218
 
@@ -241,6 +286,226 @@ function applyRenderStyle(
241
286
  break;
242
287
  }
243
288
 
289
+ case "stipple": {
290
+ // Dot-fill texture — clip to shape, then scatter dots
291
+ const savedAlphaS = ctx.globalAlpha;
292
+ ctx.globalAlpha = savedAlphaS * 0.15;
293
+ ctx.fill(); // ghost fill
294
+ ctx.globalAlpha = savedAlphaS;
295
+
296
+ ctx.save();
297
+ ctx.clip();
298
+ const dotSpacing = Math.max(2, size * 0.03);
299
+ const extent = size * 0.55;
300
+ ctx.globalAlpha = savedAlphaS * 0.7;
301
+ for (let dx = -extent; dx <= extent; dx += dotSpacing) {
302
+ for (let dy = -extent; dy <= extent; dy += dotSpacing) {
303
+ // Jitter each dot position for organic feel
304
+ const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
305
+ const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
306
+ const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
307
+ ctx.beginPath();
308
+ ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
309
+ ctx.fill();
310
+ }
311
+ }
312
+ ctx.restore();
313
+ ctx.globalAlpha = savedAlphaS;
314
+ // Outline
315
+ ctx.globalAlpha *= 0.4;
316
+ ctx.stroke();
317
+ ctx.globalAlpha /= 0.4;
318
+ break;
319
+ }
320
+
321
+ case "stencil": {
322
+ // Negative-space cutout — fill a rectangle, then erase the shape
323
+ const savedAlphaSt = ctx.globalAlpha;
324
+ // Fill a bounding area with the stroke color
325
+ ctx.globalAlpha = savedAlphaSt * 0.5;
326
+ ctx.fillStyle = strokeColor;
327
+ ctx.fillRect(-size * 0.6, -size * 0.6, size * 1.2, size * 1.2);
328
+ // Cut out the shape using destination-out
329
+ ctx.globalCompositeOperation = "destination-out";
330
+ ctx.globalAlpha = 1;
331
+ ctx.fill();
332
+ ctx.globalCompositeOperation = "source-over";
333
+ ctx.globalAlpha = savedAlphaSt;
334
+ // Subtle outline of the cutout
335
+ ctx.globalAlpha *= 0.3;
336
+ ctx.stroke();
337
+ ctx.globalAlpha /= 0.3;
338
+ break;
339
+ }
340
+
341
+ case "noise-grain": {
342
+ // Procedural noise grain texture clipped to shape boundary
343
+ const savedAlphaN = ctx.globalAlpha;
344
+ ctx.globalAlpha = savedAlphaN * 0.25;
345
+ ctx.fill(); // base tint
346
+ ctx.globalAlpha = savedAlphaN;
347
+
348
+ ctx.save();
349
+ ctx.clip();
350
+ const grainSpacing = Math.max(1.5, size * 0.015);
351
+ const extentN = size * 0.55;
352
+ ctx.globalAlpha = savedAlphaN * 0.6;
353
+ for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
354
+ for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
355
+ if (!rng) break;
356
+ const jx = (rng() - 0.5) * grainSpacing * 1.2;
357
+ const jy = (rng() - 0.5) * grainSpacing * 1.2;
358
+ const brightness = rng() > 0.5 ? 255 : 0;
359
+ const dotAlpha = 0.15 + rng() * 0.35;
360
+ ctx.globalAlpha = savedAlphaN * dotAlpha;
361
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
362
+ const dotSize = grainSpacing * (0.3 + rng() * 0.5);
363
+ ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
364
+ }
365
+ }
366
+ ctx.restore();
367
+ ctx.fillStyle = fillColor;
368
+ ctx.globalAlpha = savedAlphaN;
369
+ ctx.globalAlpha *= 0.4;
370
+ ctx.stroke();
371
+ ctx.globalAlpha /= 0.4;
372
+ break;
373
+ }
374
+
375
+ case "wood-grain": {
376
+ // Parallel wavy lines simulating wood grain, clipped to shape
377
+ const savedAlphaW = ctx.globalAlpha;
378
+ ctx.globalAlpha = savedAlphaW * 0.2;
379
+ ctx.fill(); // base tint
380
+ ctx.globalAlpha = savedAlphaW;
381
+
382
+ ctx.save();
383
+ ctx.clip();
384
+ const grainLineSpacing = Math.max(2, size * 0.035);
385
+ const extentW = size * 0.55;
386
+ const waveFreq = rng ? 3 + rng() * 5 : 5;
387
+ const waveAmp = rng ? size * (0.01 + rng() * 0.03) : size * 0.02;
388
+ const grainAngle = rng ? rng() * Math.PI : Math.PI * 0.25;
389
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.3);
390
+ ctx.globalAlpha = savedAlphaW * 0.5;
391
+
392
+ const cosG = Math.cos(grainAngle);
393
+ const sinG = Math.sin(grainAngle);
394
+ for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
395
+ ctx.beginPath();
396
+ for (let t = -extentW; t <= extentW; t += 2) {
397
+ const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
398
+ const px = t * cosG - (d + wave) * sinG;
399
+ const py = t * sinG + (d + wave) * cosG;
400
+ if (t === -extentW) ctx.moveTo(px, py);
401
+ else ctx.lineTo(px, py);
402
+ }
403
+ ctx.stroke();
404
+ }
405
+ ctx.restore();
406
+ ctx.globalAlpha = savedAlphaW;
407
+ ctx.globalAlpha *= 0.35;
408
+ ctx.stroke();
409
+ ctx.globalAlpha /= 0.35;
410
+ break;
411
+ }
412
+
413
+ case "marble-vein": {
414
+ // Branching vein lines on a soft fill, clipped to shape
415
+ const savedAlphaM = ctx.globalAlpha;
416
+ ctx.globalAlpha = savedAlphaM * 0.35;
417
+ ctx.fill(); // soft base
418
+ ctx.globalAlpha = savedAlphaM;
419
+
420
+ ctx.save();
421
+ ctx.clip();
422
+ const veinCount = rng ? 2 + Math.floor(rng() * 3) : 3;
423
+ const extentM = size * 0.45;
424
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.5);
425
+ ctx.globalAlpha = savedAlphaM * 0.4;
426
+
427
+ for (let v = 0; v < veinCount; v++) {
428
+ const startX = rng ? (rng() - 0.5) * extentM * 2 : 0;
429
+ const startY = rng ? -extentM + rng() * extentM * 0.5 : -extentM;
430
+ let vx = startX;
431
+ let vy = startY;
432
+ const steps = 15 + (rng ? Math.floor(rng() * 15) : 10);
433
+ const stepLen = size * 0.04;
434
+
435
+ ctx.beginPath();
436
+ ctx.moveTo(vx, vy);
437
+ for (let s = 0; s < steps; s++) {
438
+ const drift = rng ? (rng() - 0.5) * stepLen * 1.5 : 0;
439
+ vx += drift;
440
+ vy += stepLen;
441
+ ctx.lineTo(vx, vy);
442
+ // Branch ~20% of the time
443
+ if (rng && rng() < 0.2 && s > 2 && s < steps - 3) {
444
+ const branchDir = rng() < 0.5 ? -1 : 1;
445
+ let bx = vx;
446
+ let by = vy;
447
+ const bSteps = 3 + Math.floor(rng() * 5);
448
+ ctx.moveTo(bx, by);
449
+ for (let bs = 0; bs < bSteps; bs++) {
450
+ bx += branchDir * stepLen * (0.5 + rng() * 0.5);
451
+ by += stepLen * 0.6;
452
+ ctx.lineTo(bx, by);
453
+ }
454
+ ctx.moveTo(vx, vy); // return to main vein
455
+ }
456
+ }
457
+ ctx.stroke();
458
+ }
459
+ ctx.restore();
460
+ ctx.globalAlpha = savedAlphaM;
461
+ ctx.globalAlpha *= 0.3;
462
+ ctx.stroke();
463
+ ctx.globalAlpha /= 0.3;
464
+ break;
465
+ }
466
+
467
+ case "fabric-weave": {
468
+ // Interlocking horizontal/vertical threads clipped to shape
469
+ const savedAlphaF = ctx.globalAlpha;
470
+ ctx.globalAlpha = savedAlphaF * 0.15;
471
+ ctx.fill(); // ghost base
472
+ ctx.globalAlpha = savedAlphaF;
473
+
474
+ ctx.save();
475
+ ctx.clip();
476
+ const threadSpacing = Math.max(2, size * 0.04);
477
+ const extentF = size * 0.55;
478
+ ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
479
+ ctx.globalAlpha = savedAlphaF * 0.55;
480
+
481
+ // Horizontal threads
482
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
483
+ ctx.beginPath();
484
+ ctx.moveTo(-extentF, y);
485
+ ctx.lineTo(extentF, y);
486
+ ctx.stroke();
487
+ }
488
+ // Vertical threads (offset by half spacing for weave effect)
489
+ ctx.globalAlpha = savedAlphaF * 0.45;
490
+ ctx.strokeStyle = fillColor;
491
+ for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
492
+ ctx.beginPath();
493
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
494
+ // Over-under: draw segment, skip segment
495
+ ctx.moveTo(x, y);
496
+ ctx.lineTo(x, y + threadSpacing);
497
+ }
498
+ ctx.stroke();
499
+ }
500
+ ctx.strokeStyle = strokeColor;
501
+ ctx.restore();
502
+ ctx.globalAlpha = savedAlphaF;
503
+ ctx.globalAlpha *= 0.3;
504
+ ctx.stroke();
505
+ ctx.globalAlpha /= 0.3;
506
+ break;
507
+ }
508
+
244
509
  case "fill-and-stroke":
245
510
  default:
246
511
  ctx.fill();
@@ -325,3 +590,88 @@ export function enhanceShapeGeneration(
325
590
 
326
591
  ctx.restore();
327
592
  }
593
+
594
+ // ── Shape mirroring effect ──────────────────────────────────────────
595
+ // Draws a shape and its mirror (reflected across an axis) for visual
596
+ // symmetry. Works especially well with basic shapes like triangles,
597
+ // crescents, and penrose tiles.
598
+
599
+ export type MirrorAxis = "horizontal" | "vertical" | "diagonal" | "radial-4";
600
+
601
+ /**
602
+ * Draw a shape with a mirrored reflection.
603
+ * The mirror is drawn at reduced opacity with optional offset.
604
+ */
605
+ export function drawMirroredShape(
606
+ ctx: CanvasRenderingContext2D,
607
+ shape: string,
608
+ x: number,
609
+ y: number,
610
+ config: EnhanceShapeConfig & { mirrorAxis?: MirrorAxis; mirrorGap?: number },
611
+ ): void {
612
+ const { mirrorAxis = "horizontal", mirrorGap = 0 } = config;
613
+
614
+ // Draw the primary shape
615
+ enhanceShapeGeneration(ctx, shape, x, y, config);
616
+
617
+ // Draw the mirrored copy
618
+ ctx.save();
619
+ const savedAlpha = ctx.globalAlpha;
620
+ ctx.globalAlpha = savedAlpha * 0.7; // mirror is slightly softer
621
+
622
+ switch (mirrorAxis) {
623
+ case "horizontal":
624
+ // Reflect across vertical axis at shape position
625
+ enhanceShapeGeneration(ctx, shape, x, y + mirrorGap, {
626
+ ...config,
627
+ rotation: -(config.rotation || 0),
628
+ size: config.size * 0.95,
629
+ });
630
+ break;
631
+ case "vertical":
632
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap, y, {
633
+ ...config,
634
+ rotation: 180 - (config.rotation || 0),
635
+ size: config.size * 0.95,
636
+ });
637
+ break;
638
+ case "diagonal":
639
+ // Reflect across 45° axis
640
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap * 0.7, y + mirrorGap * 0.7, {
641
+ ...config,
642
+ rotation: 90 - (config.rotation || 0),
643
+ size: config.size * 0.9,
644
+ });
645
+ break;
646
+ case "radial-4":
647
+ // Four-way radial mirror
648
+ for (let i = 1; i < 4; i++) {
649
+ const angle = (i / 4) * Math.PI * 2;
650
+ const mx = x + Math.cos(angle) * mirrorGap;
651
+ const my = y + Math.sin(angle) * mirrorGap;
652
+ ctx.globalAlpha = savedAlpha * (0.7 - i * 0.1);
653
+ enhanceShapeGeneration(ctx, shape, mx, my, {
654
+ ...config,
655
+ rotation: (config.rotation || 0) + i * 90,
656
+ size: config.size * (0.95 - i * 0.05),
657
+ });
658
+ }
659
+ break;
660
+ }
661
+
662
+ ctx.globalAlpha = savedAlpha;
663
+ ctx.restore();
664
+ }
665
+
666
+ /**
667
+ * Pick a mirror axis deterministically.
668
+ * Returns null ~60% of the time (no mirroring).
669
+ */
670
+ export function pickMirrorAxis(rng: () => number): MirrorAxis | null {
671
+ const roll = rng();
672
+ if (roll < 0.60) return null;
673
+ if (roll < 0.75) return "horizontal";
674
+ if (roll < 0.87) return "vertical";
675
+ if (roll < 0.95) return "diagonal";
676
+ return "radial-4";
677
+ }