git-hash-art 0.8.0 → 0.10.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.8.0",
3
+ "version": "0.10.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",
@@ -287,13 +287,125 @@ const ARCHETYPES: Archetype[] = [
287
287
  sizePower: 1.8,
288
288
  invertForeground: false,
289
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
+ },
290
358
  ];
291
359
 
360
+ /**
361
+ * Linearly interpolate between two archetype numeric parameters.
362
+ */
363
+ function lerpNum(a: number, b: number, t: number): number {
364
+ return a + (b - a) * t;
365
+ }
366
+
367
+ /**
368
+ * Blend two archetypes by interpolating their numeric parameters
369
+ * and merging their style arrays.
370
+ */
371
+ function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
372
+ // Merge preferred styles — unique union, primary archetype first
373
+ const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
374
+
375
+ return {
376
+ name: `${a.name}+${b.name}`,
377
+ gridSize: Math.round(lerpNum(a.gridSize, b.gridSize, t)),
378
+ layers: Math.round(lerpNum(a.layers, b.layers, t)),
379
+ baseOpacity: lerpNum(a.baseOpacity, b.baseOpacity, t),
380
+ opacityReduction: lerpNum(a.opacityReduction, b.opacityReduction, t),
381
+ minShapeSize: Math.round(lerpNum(a.minShapeSize, b.minShapeSize, t)),
382
+ maxShapeSize: Math.round(lerpNum(a.maxShapeSize, b.maxShapeSize, t)),
383
+ backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
384
+ paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
385
+ preferredStyles: mergedStyles,
386
+ flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
387
+ heroShape: t < 0.5 ? a.heroShape : b.heroShape,
388
+ glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
389
+ sizePower: lerpNum(a.sizePower, b.sizePower, t),
390
+ invertForeground: t < 0.5 ? a.invertForeground : b.invertForeground,
391
+ };
392
+ }
393
+
292
394
  /**
293
395
  * Select an archetype deterministically from the hash.
294
- * The "classic" archetype preserves the original look for backward compat
295
- * but only gets ~10% of hashes.
396
+ * ~15% of hashes produce a blended archetype (interpolation of two).
296
397
  */
297
398
  export function selectArchetype(rng: () => number): Archetype {
298
- return ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
399
+ const primary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
400
+
401
+ // ~15% chance of blending with a second archetype
402
+ if (rng() < 0.15) {
403
+ const secondary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
404
+ if (secondary.name !== primary.name) {
405
+ const blendT = 0.25 + rng() * 0.25; // 25-50% blend toward secondary
406
+ return blendArchetypes(primary, secondary, blendT);
407
+ }
408
+ }
409
+
410
+ return primary;
299
411
  }
@@ -510,3 +510,28 @@ export function pickColorGrade(rng: () => number): { hue: number; intensity: num
510
510
  const intensity = 0.15 + rng() * 0.25;
511
511
  return { hue: (hue + 360) % 360, intensity };
512
512
  }
513
+
514
+ /**
515
+ * Rotate the hue of a hex color by a given number of degrees.
516
+ */
517
+ export function hueRotate(hex: string, degrees: number): string {
518
+ const [h, s, l] = hexToHsl(hex);
519
+ return hslToHex((h + degrees + 360) % 360, s, l);
520
+ }
521
+
522
+ /**
523
+ * Evolve a color hierarchy for a given layer — shifts hue progressively.
524
+ * Creates atmospheric color perspective (like distant mountains shifting blue).
525
+ */
526
+ export function evolveHierarchy(
527
+ base: ColorHierarchy,
528
+ layerRatio: number,
529
+ hueShiftPerLayer: number,
530
+ ): ColorHierarchy {
531
+ const shift = layerRatio * hueShiftPerLayer;
532
+ return {
533
+ dominant: hueRotate(base.dominant, shift),
534
+ secondary: hueRotate(base.secondary, shift * 0.7),
535
+ accent: hueRotate(base.accent, shift * 0.5),
536
+ };
537
+ }
@@ -35,7 +35,14 @@ 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
45
+ | "hand-drawn"; // wobbly hand-drawn edge treatment
39
46
 
40
47
  const RENDER_STYLES: RenderStyle[] = [
41
48
  "fill-and-stroke",
@@ -47,6 +54,13 @@ const RENDER_STYLES: RenderStyle[] = [
47
54
  "watercolor",
48
55
  "hatched",
49
56
  "incomplete",
57
+ "stipple",
58
+ "stencil",
59
+ "noise-grain",
60
+ "wood-grain",
61
+ "marble-vein",
62
+ "fabric-weave",
63
+ "hand-drawn",
50
64
  ];
51
65
 
52
66
  export function pickRenderStyle(rng: () => number): RenderStyle {
@@ -274,6 +288,254 @@ function applyRenderStyle(
274
288
  break;
275
289
  }
276
290
 
291
+ case "stipple": {
292
+ // Dot-fill texture — clip to shape, then scatter dots
293
+ const savedAlphaS = ctx.globalAlpha;
294
+ ctx.globalAlpha = savedAlphaS * 0.15;
295
+ ctx.fill(); // ghost fill
296
+ ctx.globalAlpha = savedAlphaS;
297
+
298
+ ctx.save();
299
+ ctx.clip();
300
+ const dotSpacing = Math.max(2, size * 0.03);
301
+ const extent = size * 0.55;
302
+ ctx.globalAlpha = savedAlphaS * 0.7;
303
+ for (let dx = -extent; dx <= extent; dx += dotSpacing) {
304
+ for (let dy = -extent; dy <= extent; dy += dotSpacing) {
305
+ // Jitter each dot position for organic feel
306
+ const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
307
+ const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
308
+ const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
309
+ ctx.beginPath();
310
+ ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
311
+ ctx.fill();
312
+ }
313
+ }
314
+ ctx.restore();
315
+ ctx.globalAlpha = savedAlphaS;
316
+ // Outline
317
+ ctx.globalAlpha *= 0.4;
318
+ ctx.stroke();
319
+ ctx.globalAlpha /= 0.4;
320
+ break;
321
+ }
322
+
323
+ case "stencil": {
324
+ // Negative-space cutout — fill a rectangle, then erase the shape
325
+ const savedAlphaSt = ctx.globalAlpha;
326
+ // Fill a bounding area with the stroke color
327
+ ctx.globalAlpha = savedAlphaSt * 0.5;
328
+ ctx.fillStyle = strokeColor;
329
+ ctx.fillRect(-size * 0.6, -size * 0.6, size * 1.2, size * 1.2);
330
+ // Cut out the shape using destination-out
331
+ ctx.globalCompositeOperation = "destination-out";
332
+ ctx.globalAlpha = 1;
333
+ ctx.fill();
334
+ ctx.globalCompositeOperation = "source-over";
335
+ ctx.globalAlpha = savedAlphaSt;
336
+ // Subtle outline of the cutout
337
+ ctx.globalAlpha *= 0.3;
338
+ ctx.stroke();
339
+ ctx.globalAlpha /= 0.3;
340
+ break;
341
+ }
342
+
343
+ case "noise-grain": {
344
+ // Procedural noise grain texture clipped to shape boundary
345
+ const savedAlphaN = ctx.globalAlpha;
346
+ ctx.globalAlpha = savedAlphaN * 0.25;
347
+ ctx.fill(); // base tint
348
+ ctx.globalAlpha = savedAlphaN;
349
+
350
+ ctx.save();
351
+ ctx.clip();
352
+ const grainSpacing = Math.max(1.5, size * 0.015);
353
+ const extentN = size * 0.55;
354
+ ctx.globalAlpha = savedAlphaN * 0.6;
355
+ for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
356
+ for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
357
+ if (!rng) break;
358
+ const jx = (rng() - 0.5) * grainSpacing * 1.2;
359
+ const jy = (rng() - 0.5) * grainSpacing * 1.2;
360
+ const brightness = rng() > 0.5 ? 255 : 0;
361
+ const dotAlpha = 0.15 + rng() * 0.35;
362
+ ctx.globalAlpha = savedAlphaN * dotAlpha;
363
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
364
+ const dotSize = grainSpacing * (0.3 + rng() * 0.5);
365
+ ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
366
+ }
367
+ }
368
+ ctx.restore();
369
+ ctx.fillStyle = fillColor;
370
+ ctx.globalAlpha = savedAlphaN;
371
+ ctx.globalAlpha *= 0.4;
372
+ ctx.stroke();
373
+ ctx.globalAlpha /= 0.4;
374
+ break;
375
+ }
376
+
377
+ case "wood-grain": {
378
+ // Parallel wavy lines simulating wood grain, clipped to shape
379
+ const savedAlphaW = ctx.globalAlpha;
380
+ ctx.globalAlpha = savedAlphaW * 0.2;
381
+ ctx.fill(); // base tint
382
+ ctx.globalAlpha = savedAlphaW;
383
+
384
+ ctx.save();
385
+ ctx.clip();
386
+ const grainLineSpacing = Math.max(2, size * 0.035);
387
+ const extentW = size * 0.55;
388
+ const waveFreq = rng ? 3 + rng() * 5 : 5;
389
+ const waveAmp = rng ? size * (0.01 + rng() * 0.03) : size * 0.02;
390
+ const grainAngle = rng ? rng() * Math.PI : Math.PI * 0.25;
391
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.3);
392
+ ctx.globalAlpha = savedAlphaW * 0.5;
393
+
394
+ const cosG = Math.cos(grainAngle);
395
+ const sinG = Math.sin(grainAngle);
396
+ for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
397
+ ctx.beginPath();
398
+ for (let t = -extentW; t <= extentW; t += 2) {
399
+ const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
400
+ const px = t * cosG - (d + wave) * sinG;
401
+ const py = t * sinG + (d + wave) * cosG;
402
+ if (t === -extentW) ctx.moveTo(px, py);
403
+ else ctx.lineTo(px, py);
404
+ }
405
+ ctx.stroke();
406
+ }
407
+ ctx.restore();
408
+ ctx.globalAlpha = savedAlphaW;
409
+ ctx.globalAlpha *= 0.35;
410
+ ctx.stroke();
411
+ ctx.globalAlpha /= 0.35;
412
+ break;
413
+ }
414
+
415
+ case "marble-vein": {
416
+ // Branching vein lines on a soft fill, clipped to shape
417
+ const savedAlphaM = ctx.globalAlpha;
418
+ ctx.globalAlpha = savedAlphaM * 0.35;
419
+ ctx.fill(); // soft base
420
+ ctx.globalAlpha = savedAlphaM;
421
+
422
+ ctx.save();
423
+ ctx.clip();
424
+ const veinCount = rng ? 2 + Math.floor(rng() * 3) : 3;
425
+ const extentM = size * 0.45;
426
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.5);
427
+ ctx.globalAlpha = savedAlphaM * 0.4;
428
+
429
+ for (let v = 0; v < veinCount; v++) {
430
+ const startX = rng ? (rng() - 0.5) * extentM * 2 : 0;
431
+ const startY = rng ? -extentM + rng() * extentM * 0.5 : -extentM;
432
+ let vx = startX;
433
+ let vy = startY;
434
+ const steps = 15 + (rng ? Math.floor(rng() * 15) : 10);
435
+ const stepLen = size * 0.04;
436
+
437
+ ctx.beginPath();
438
+ ctx.moveTo(vx, vy);
439
+ for (let s = 0; s < steps; s++) {
440
+ const drift = rng ? (rng() - 0.5) * stepLen * 1.5 : 0;
441
+ vx += drift;
442
+ vy += stepLen;
443
+ ctx.lineTo(vx, vy);
444
+ // Branch ~20% of the time
445
+ if (rng && rng() < 0.2 && s > 2 && s < steps - 3) {
446
+ const branchDir = rng() < 0.5 ? -1 : 1;
447
+ let bx = vx;
448
+ let by = vy;
449
+ const bSteps = 3 + Math.floor(rng() * 5);
450
+ ctx.moveTo(bx, by);
451
+ for (let bs = 0; bs < bSteps; bs++) {
452
+ bx += branchDir * stepLen * (0.5 + rng() * 0.5);
453
+ by += stepLen * 0.6;
454
+ ctx.lineTo(bx, by);
455
+ }
456
+ ctx.moveTo(vx, vy); // return to main vein
457
+ }
458
+ }
459
+ ctx.stroke();
460
+ }
461
+ ctx.restore();
462
+ ctx.globalAlpha = savedAlphaM;
463
+ ctx.globalAlpha *= 0.3;
464
+ ctx.stroke();
465
+ ctx.globalAlpha /= 0.3;
466
+ break;
467
+ }
468
+
469
+ case "fabric-weave": {
470
+ // Interlocking horizontal/vertical threads clipped to shape
471
+ const savedAlphaF = ctx.globalAlpha;
472
+ ctx.globalAlpha = savedAlphaF * 0.15;
473
+ ctx.fill(); // ghost base
474
+ ctx.globalAlpha = savedAlphaF;
475
+
476
+ ctx.save();
477
+ ctx.clip();
478
+ const threadSpacing = Math.max(2, size * 0.04);
479
+ const extentF = size * 0.55;
480
+ ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
481
+ ctx.globalAlpha = savedAlphaF * 0.55;
482
+
483
+ // Horizontal threads
484
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
485
+ ctx.beginPath();
486
+ ctx.moveTo(-extentF, y);
487
+ ctx.lineTo(extentF, y);
488
+ ctx.stroke();
489
+ }
490
+ // Vertical threads (offset by half spacing for weave effect)
491
+ ctx.globalAlpha = savedAlphaF * 0.45;
492
+ ctx.strokeStyle = fillColor;
493
+ for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
494
+ ctx.beginPath();
495
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
496
+ // Over-under: draw segment, skip segment
497
+ ctx.moveTo(x, y);
498
+ ctx.lineTo(x, y + threadSpacing);
499
+ }
500
+ ctx.stroke();
501
+ }
502
+ ctx.strokeStyle = strokeColor;
503
+ ctx.restore();
504
+ ctx.globalAlpha = savedAlphaF;
505
+ ctx.globalAlpha *= 0.3;
506
+ ctx.stroke();
507
+ ctx.globalAlpha /= 0.3;
508
+ break;
509
+ }
510
+
511
+ case "hand-drawn": {
512
+ // Wobbly hand-drawn edge treatment — fill normally, then redraw
513
+ // the outline with perturbed control points for a sketchy feel
514
+ const savedAlphaHD = ctx.globalAlpha;
515
+ ctx.globalAlpha = savedAlphaHD * 0.85;
516
+ ctx.fill();
517
+ ctx.globalAlpha = savedAlphaHD;
518
+
519
+ // Draw 2-3 slightly offset wobbly strokes for a sketchy look
520
+ const wobblePasses = 2 + (rng ? Math.floor(rng() * 2) : 0);
521
+ ctx.lineWidth = strokeWidth * 0.8;
522
+ for (let wp = 0; wp < wobblePasses; wp++) {
523
+ ctx.globalAlpha = savedAlphaHD * (0.4 - wp * 0.1);
524
+ ctx.save();
525
+ // Slight random offset per pass
526
+ const wobbleX = rng ? (rng() - 0.5) * size * 0.02 : 0;
527
+ const wobbleY = rng ? (rng() - 0.5) * size * 0.02 : 0;
528
+ ctx.translate(wobbleX, wobbleY);
529
+ // Slightly different scale per pass for edge variation
530
+ const wobbleScale = 1 + (rng ? (rng() - 0.5) * 0.03 : 0);
531
+ ctx.scale(wobbleScale, wobbleScale);
532
+ ctx.stroke();
533
+ ctx.restore();
534
+ }
535
+ ctx.globalAlpha = savedAlphaHD;
536
+ break;
537
+ }
538
+
277
539
  case "fill-and-stroke":
278
540
  default:
279
541
  ctx.fill();
@@ -358,3 +620,88 @@ export function enhanceShapeGeneration(
358
620
 
359
621
  ctx.restore();
360
622
  }
623
+
624
+ // ── Shape mirroring effect ──────────────────────────────────────────
625
+ // Draws a shape and its mirror (reflected across an axis) for visual
626
+ // symmetry. Works especially well with basic shapes like triangles,
627
+ // crescents, and penrose tiles.
628
+
629
+ export type MirrorAxis = "horizontal" | "vertical" | "diagonal" | "radial-4";
630
+
631
+ /**
632
+ * Draw a shape with a mirrored reflection.
633
+ * The mirror is drawn at reduced opacity with optional offset.
634
+ */
635
+ export function drawMirroredShape(
636
+ ctx: CanvasRenderingContext2D,
637
+ shape: string,
638
+ x: number,
639
+ y: number,
640
+ config: EnhanceShapeConfig & { mirrorAxis?: MirrorAxis; mirrorGap?: number },
641
+ ): void {
642
+ const { mirrorAxis = "horizontal", mirrorGap = 0 } = config;
643
+
644
+ // Draw the primary shape
645
+ enhanceShapeGeneration(ctx, shape, x, y, config);
646
+
647
+ // Draw the mirrored copy
648
+ ctx.save();
649
+ const savedAlpha = ctx.globalAlpha;
650
+ ctx.globalAlpha = savedAlpha * 0.7; // mirror is slightly softer
651
+
652
+ switch (mirrorAxis) {
653
+ case "horizontal":
654
+ // Reflect across vertical axis at shape position
655
+ enhanceShapeGeneration(ctx, shape, x, y + mirrorGap, {
656
+ ...config,
657
+ rotation: -(config.rotation || 0),
658
+ size: config.size * 0.95,
659
+ });
660
+ break;
661
+ case "vertical":
662
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap, y, {
663
+ ...config,
664
+ rotation: 180 - (config.rotation || 0),
665
+ size: config.size * 0.95,
666
+ });
667
+ break;
668
+ case "diagonal":
669
+ // Reflect across 45° axis
670
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap * 0.7, y + mirrorGap * 0.7, {
671
+ ...config,
672
+ rotation: 90 - (config.rotation || 0),
673
+ size: config.size * 0.9,
674
+ });
675
+ break;
676
+ case "radial-4":
677
+ // Four-way radial mirror
678
+ for (let i = 1; i < 4; i++) {
679
+ const angle = (i / 4) * Math.PI * 2;
680
+ const mx = x + Math.cos(angle) * mirrorGap;
681
+ const my = y + Math.sin(angle) * mirrorGap;
682
+ ctx.globalAlpha = savedAlpha * (0.7 - i * 0.1);
683
+ enhanceShapeGeneration(ctx, shape, mx, my, {
684
+ ...config,
685
+ rotation: (config.rotation || 0) + i * 90,
686
+ size: config.size * (0.95 - i * 0.05),
687
+ });
688
+ }
689
+ break;
690
+ }
691
+
692
+ ctx.globalAlpha = savedAlpha;
693
+ ctx.restore();
694
+ }
695
+
696
+ /**
697
+ * Pick a mirror axis deterministically.
698
+ * Returns null ~60% of the time (no mirroring).
699
+ */
700
+ export function pickMirrorAxis(rng: () => number): MirrorAxis | null {
701
+ const roll = rng();
702
+ if (roll < 0.60) return null;
703
+ if (roll < 0.75) return "horizontal";
704
+ if (roll < 0.87) return "vertical";
705
+ if (roll < 0.95) return "diagonal";
706
+ return "radial-4";
707
+ }