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/src/lib/render.ts CHANGED
@@ -31,13 +31,15 @@ import {
31
31
  enforceContrast,
32
32
  buildColorHierarchy,
33
33
  pickHierarchyColor, pickColorGrade,
34
- type ColorHierarchy
34
+ evolveHierarchy, type ColorHierarchy
35
35
  } from "./canvas/colors";
36
36
  import {
37
37
  enhanceShapeGeneration,
38
+ drawMirroredShape,
39
+ pickMirrorAxis,
38
40
  pickBlendMode,
39
41
  pickRenderStyle,
40
- type RenderStyle,
42
+ type RenderStyle
41
43
  } from "./canvas/draw";
42
44
  import { shapes } from "./canvas/shapes";
43
45
  import {
@@ -272,6 +274,92 @@ function drawBackground(
272
274
  }
273
275
  }
274
276
 
277
+ // ── Shape constellations — pre-composed groups of shapes ────────
278
+
279
+ interface ConstellationDef {
280
+ name: string;
281
+ /** Generate member positions/shapes relative to center */
282
+ build: (rng: () => number, baseSize: number) => Array<{
283
+ dx: number; dy: number; shape: string; size: number; rotation: number;
284
+ }>;
285
+ }
286
+
287
+ const CONSTELLATIONS: ConstellationDef[] = [
288
+ {
289
+ name: "flanked-triangle",
290
+ build: (rng, baseSize) => {
291
+ const gap = baseSize * (0.6 + rng() * 0.3);
292
+ return [
293
+ { dx: 0, dy: 0, shape: "triangle", size: baseSize, rotation: rng() * 360 },
294
+ { dx: -gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
295
+ { dx: gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
296
+ ];
297
+ },
298
+ },
299
+ {
300
+ name: "hexagon-ring",
301
+ build: (rng, baseSize) => {
302
+ const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
303
+ const count = 5 + Math.floor(rng() * 2);
304
+ const ringR = baseSize * 0.6;
305
+ for (let i = 0; i < count; i++) {
306
+ const angle = (i / count) * Math.PI * 2;
307
+ members.push({
308
+ dx: Math.cos(angle) * ringR,
309
+ dy: Math.sin(angle) * ringR,
310
+ shape: "hexagon",
311
+ size: baseSize * (0.25 + rng() * 0.1),
312
+ rotation: (angle * 180) / Math.PI,
313
+ });
314
+ }
315
+ return members;
316
+ },
317
+ },
318
+ {
319
+ name: "spiral-dots",
320
+ build: (rng, baseSize) => {
321
+ const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
322
+ const count = 7 + Math.floor(rng() * 5);
323
+ const turns = 1.5 + rng();
324
+ for (let i = 0; i < count; i++) {
325
+ const t = i / count;
326
+ const angle = t * Math.PI * 2 * turns;
327
+ const r = t * baseSize * 0.7;
328
+ members.push({
329
+ dx: Math.cos(angle) * r,
330
+ dy: Math.sin(angle) * r,
331
+ shape: "circle",
332
+ size: baseSize * (0.08 + (1 - t) * 0.12),
333
+ rotation: 0,
334
+ });
335
+ }
336
+ return members;
337
+ },
338
+ },
339
+ {
340
+ name: "diamond-cluster",
341
+ build: (rng, baseSize) => {
342
+ const gap = baseSize * 0.45;
343
+ return [
344
+ { dx: 0, dy: -gap, shape: "diamond", size: baseSize * 0.4, rotation: 0 },
345
+ { dx: gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: 15 },
346
+ { dx: 0, dy: gap, shape: "diamond", size: baseSize * 0.3, rotation: 30 },
347
+ { dx: -gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: -15 },
348
+ ];
349
+ },
350
+ },
351
+ {
352
+ name: "crescent-pair",
353
+ build: (rng, baseSize) => {
354
+ const gap = baseSize * 0.5;
355
+ return [
356
+ { dx: -gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.5, rotation: rng() * 30 },
357
+ { dx: gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.45, rotation: 180 + rng() * 30 },
358
+ ];
359
+ },
360
+ },
361
+ ];
362
+
275
363
  // ── Main render function ────────────────────────────────────────────
276
364
 
277
365
  export function renderHashArt(
@@ -321,6 +409,9 @@ export function renderHashArt(
321
409
  // ── 0e. Light direction — consistent shadow angle ──────────────
322
410
  const lightAngle = rng() * Math.PI * 2;
323
411
 
412
+ // ── 0f. Palette evolution — hue drift direction across layers ──
413
+ const paletteHueShift = (rng() - 0.5) * 40; // -20° to +20° total drift
414
+
324
415
  const scaleFactor = Math.min(width, height) / 1024;
325
416
  const adjustedMinSize = minShapeSize * scaleFactor;
326
417
  const adjustedMaxSize = maxShapeSize * scaleFactor;
@@ -385,6 +476,69 @@ export function renderHashArt(
385
476
  }
386
477
  ctx.globalCompositeOperation = "source-over";
387
478
 
479
+ // ── 1c. Background pattern layer — subtle textured paper ───────
480
+ const bgPatternRoll = rng();
481
+ if (bgPatternRoll < 0.6) {
482
+ ctx.save();
483
+ ctx.globalCompositeOperation = "soft-light";
484
+ const patternOpacity = 0.02 + rng() * 0.04;
485
+ const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
486
+
487
+ if (bgPatternRoll < 0.2) {
488
+ // Dot grid
489
+ const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
490
+ const dotR = dotSpacing * 0.08;
491
+ ctx.globalAlpha = patternOpacity;
492
+ ctx.fillStyle = patternColor;
493
+ for (let px = 0; px < width; px += dotSpacing) {
494
+ for (let py = 0; py < height; py += dotSpacing) {
495
+ ctx.beginPath();
496
+ ctx.arc(px, py, dotR, 0, Math.PI * 2);
497
+ ctx.fill();
498
+ }
499
+ }
500
+ } else if (bgPatternRoll < 0.4) {
501
+ // Diagonal lines
502
+ const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
503
+ ctx.globalAlpha = patternOpacity;
504
+ ctx.strokeStyle = patternColor;
505
+ ctx.lineWidth = 0.5 * scaleFactor;
506
+ const diag = Math.hypot(width, height);
507
+ for (let d = -diag; d < diag; d += lineSpacing) {
508
+ ctx.beginPath();
509
+ ctx.moveTo(d, 0);
510
+ ctx.lineTo(d + height, height);
511
+ ctx.stroke();
512
+ }
513
+ } else {
514
+ // Tessellation — hexagonal grid of tiny shapes
515
+ const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
516
+ const tessH = tessSize * Math.sqrt(3);
517
+ ctx.globalAlpha = patternOpacity * 0.7;
518
+ ctx.strokeStyle = patternColor;
519
+ ctx.lineWidth = 0.4 * scaleFactor;
520
+ for (let row = 0; row * tessH < height + tessH; row++) {
521
+ const offsetX = (row % 2) * tessSize * 0.75;
522
+ for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
523
+ const hx = col * tessSize * 1.5 + offsetX;
524
+ const hy = row * tessH;
525
+ ctx.beginPath();
526
+ for (let s = 0; s < 6; s++) {
527
+ const angle = (Math.PI / 3) * s - Math.PI / 6;
528
+ const vx = hx + Math.cos(angle) * tessSize * 0.5;
529
+ const vy = hy + Math.sin(angle) * tessSize * 0.5;
530
+ if (s === 0) ctx.moveTo(vx, vy);
531
+ else ctx.lineTo(vx, vy);
532
+ }
533
+ ctx.closePath();
534
+ ctx.stroke();
535
+ }
536
+ }
537
+ }
538
+ ctx.restore();
539
+ }
540
+ ctx.globalCompositeOperation = "source-over";
541
+
388
542
  // ── 2. Composition mode ────────────────────────────────────────
389
543
  const compositionMode =
390
544
  COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
@@ -447,6 +601,48 @@ export function renderHashArt(
447
601
  return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull];
448
602
  }
449
603
 
604
+ // ── 3b. Void zone decoration — intentional negative space ────
605
+ for (const zone of voidZones) {
606
+ // Subtle halo ring around void zones
607
+ ctx.globalAlpha = 0.04 + rng() * 0.04;
608
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
609
+ ctx.lineWidth = 1.5 * scaleFactor;
610
+ ctx.beginPath();
611
+ ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
612
+ ctx.stroke();
613
+
614
+ // ~50% chance: scatter tiny dots inside the void
615
+ if (rng() < 0.5) {
616
+ const dotCount = 3 + Math.floor(rng() * 6);
617
+ ctx.globalAlpha = 0.06 + rng() * 0.04;
618
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
619
+ for (let d = 0; d < dotCount; d++) {
620
+ const angle = rng() * Math.PI * 2;
621
+ const dist = rng() * zone.radius * 0.7;
622
+ const dotR = (1 + rng() * 3) * scaleFactor;
623
+ ctx.beginPath();
624
+ ctx.arc(
625
+ zone.x + Math.cos(angle) * dist,
626
+ zone.y + Math.sin(angle) * dist,
627
+ dotR, 0, Math.PI * 2,
628
+ );
629
+ ctx.fill();
630
+ }
631
+ }
632
+
633
+ // ~30% chance: thin concentric ring inside
634
+ if (rng() < 0.3) {
635
+ ctx.globalAlpha = 0.03 + rng() * 0.03;
636
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
637
+ ctx.lineWidth = 0.5 * scaleFactor;
638
+ const innerR = zone.radius * (0.4 + rng() * 0.3);
639
+ ctx.beginPath();
640
+ ctx.arc(zone.x, zone.y, innerR, 0, Math.PI * 2);
641
+ ctx.stroke();
642
+ }
643
+ }
644
+ ctx.globalAlpha = 1;
645
+
450
646
  // ── 4. Flow field seed values ──────────────────────────────────
451
647
  const fieldAngleBase = rng() * Math.PI * 2;
452
648
  const fieldFreq = 0.5 + rng() * 2;
@@ -533,6 +729,26 @@ export function renderHashArt(
533
729
  // Atmospheric desaturation for later layers
534
730
  const atmosphericDesat = layerRatio * 0.3;
535
731
 
732
+ // Depth-of-field simulation — later layers are "further away"
733
+ // Reduce stroke widths and shift colors toward the background
734
+ const dofFactor = 1 - layerRatio * 0.5; // 1.0 for front layer, 0.5 for back
735
+ const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth
736
+ const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg
737
+
738
+ // Color palette evolution — hue-rotate the hierarchy per layer
739
+ const layerHierarchy = evolveHierarchy(colorHierarchy, layerRatio, paletteHueShift);
740
+
741
+ // Focal depth: shapes near focal points get more detail
742
+ const focalDetailBoost = (px: number, py: number): number => {
743
+ let minFocalDist = Infinity;
744
+ for (const fp of focalPoints) {
745
+ const d = Math.hypot(px - fp.x, py - fp.y);
746
+ if (d < minFocalDist) minFocalDist = d;
747
+ }
748
+ const maxDist = Math.hypot(width, height) * 0.5;
749
+ return Math.max(0, 1 - minFocalDist / maxDist); // 1.0 at focal, 0.0 at edges
750
+ };
751
+
536
752
  for (let i = 0; i < numShapes; i++) {
537
753
  // Position from composition mode, then focal bias
538
754
  const rawPos = getCompositionPosition(
@@ -584,9 +800,9 @@ export function renderHashArt(
584
800
  }
585
801
  }
586
802
 
587
- // Positional color from hierarchy + jitter
588
- let fillBase = getPositionalColor(x, y, width, height, colorHierarchy, rng);
589
- const strokeBase = pickHierarchyColor(colorHierarchy, rng);
803
+ // Positional color from hierarchy + jitter (using evolved layer palette)
804
+ let fillBase = getPositionalColor(x, y, width, height, layerHierarchy, rng);
805
+ const strokeBase = pickHierarchyColor(layerHierarchy, rng);
590
806
 
591
807
  // Desaturate colors on later layers for depth
592
808
  if (atmosphericDesat > 0) {
@@ -605,9 +821,11 @@ export function renderHashArt(
605
821
  const fillAlpha = 0.2 + rng() * 0.5;
606
822
  const transparentFill = hexWithAlpha(fillColor, fillAlpha);
607
823
 
608
- const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor;
824
+ const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor * dofStrokeScale;
609
825
 
610
- ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5);
826
+ // Depth-of-field: reduce opacity slightly for distant layers
827
+ const dofOpacityScale = 1 - dofContrastReduction;
828
+ ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5) * dofOpacityScale;
611
829
 
612
830
  // Glow on sacred shapes more often — scaled by archetype
613
831
  const isSacred = SACRED_SHAPES.includes(shape);
@@ -634,13 +852,47 @@ export function renderHashArt(
634
852
  const shadowOffX = shadowDist * Math.cos(lightAngle);
635
853
  const shadowOffY = shadowDist * Math.sin(lightAngle);
636
854
 
637
- enhanceShapeGeneration(ctx, shape, x, y, {
855
+ // ── 5a. Tangent placement — nudge toward nearest shape edge ──
856
+ let finalX = x;
857
+ let finalY = y;
858
+ if (shapePositions.length > 0 && rng() < 0.25) {
859
+ // Find nearest placed shape
860
+ let nearestDist = Infinity;
861
+ let nearestPos: { x: number; y: number; size: number } | null = null;
862
+ for (const sp of shapePositions) {
863
+ const d = Math.hypot(x - sp.x, y - sp.y);
864
+ if (d < nearestDist && d > 0) {
865
+ nearestDist = d;
866
+ nearestPos = sp;
867
+ }
868
+ }
869
+ if (nearestPos) {
870
+ // Target distance: edges kissing (sum of half-sizes)
871
+ const targetDist = (size + nearestPos.size) * 0.5;
872
+ if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
873
+ const angle = Math.atan2(y - nearestPos.y, x - nearestPos.x);
874
+ finalX = nearestPos.x + Math.cos(angle) * targetDist;
875
+ finalY = nearestPos.y + Math.sin(angle) * targetDist;
876
+ // Keep in bounds
877
+ finalX = Math.max(0, Math.min(width, finalX));
878
+ finalY = Math.max(0, Math.min(height, finalY));
879
+ }
880
+ }
881
+ }
882
+
883
+ // ── 5b. Shape mirroring — basic shapes get reflected copies ──
884
+ const mirrorAxis = pickMirrorAxis(rng);
885
+ const isBasicShape = ["circle", "triangle", "square", "hexagon", "star",
886
+ "diamond", "crescent", "penroseTile", "reuleauxTriangle"].includes(shape);
887
+ const shouldMirror = mirrorAxis !== null && isBasicShape && size > adjustedMaxSize * 0.2;
888
+
889
+ const shapeConfig = {
638
890
  fillColor: transparentFill,
639
891
  strokeColor,
640
892
  strokeWidth,
641
893
  size,
642
894
  rotation,
643
- proportionType: "GOLDEN_RATIO",
895
+ proportionType: "GOLDEN_RATIO" as const,
644
896
  glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0),
645
897
  glowColor: hasGlow
646
898
  ? hexWithAlpha(fillColor, 0.6)
@@ -648,19 +900,49 @@ export function renderHashArt(
648
900
  gradientFillEnd: gradientEnd,
649
901
  renderStyle: finalRenderStyle,
650
902
  rng,
651
- });
903
+ };
904
+
905
+ if (shouldMirror) {
906
+ drawMirroredShape(ctx, shape, finalX, finalY, {
907
+ ...shapeConfig,
908
+ mirrorAxis: mirrorAxis!,
909
+ mirrorGap: size * (0.1 + rng() * 0.3),
910
+ });
911
+ } else {
912
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
913
+ }
914
+
915
+ // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
916
+ if (rng() < 0.2 && size > adjustedMinSize * 2) {
917
+ const glazePasses = 2 + Math.floor(rng() * 2);
918
+ for (let g = 0; g < glazePasses; g++) {
919
+ const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
920
+ const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
921
+ ctx.globalAlpha = glazeAlpha;
922
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, {
923
+ fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
924
+ strokeColor: "rgba(0,0,0,0)",
925
+ strokeWidth: 0,
926
+ size: size * glazeScale,
927
+ rotation,
928
+ proportionType: "GOLDEN_RATIO",
929
+ renderStyle: "fill-only",
930
+ rng,
931
+ });
932
+ }
933
+ }
652
934
 
653
- shapePositions.push({ x, y, size, shape });
935
+ shapePositions.push({ x: finalX, y: finalY, size, shape });
654
936
 
655
- // ── 5b. Size echo — large shapes spawn trailing smaller copies ──
937
+ // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
656
938
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
657
939
  const echoCount = 2 + Math.floor(rng() * 2);
658
940
  const echoAngle = rng() * Math.PI * 2;
659
941
  for (let e = 0; e < echoCount; e++) {
660
942
  const echoScale = 0.3 - e * 0.08;
661
943
  const echoDist = size * (0.6 + e * 0.4);
662
- const echoX = x + Math.cos(echoAngle) * echoDist;
663
- const echoY = y + Math.sin(echoAngle) * echoDist;
944
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
945
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
664
946
  const echoSize = size * Math.max(0.1, echoScale);
665
947
 
666
948
  if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
@@ -680,8 +962,11 @@ export function renderHashArt(
680
962
  }
681
963
  }
682
964
 
683
- // ── 5c. Recursive nesting ──────────────────────────────────
684
- if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
965
+ // ── 5d. Recursive nesting ──────────────────────────────────
966
+ // Focal depth: shapes near focal points get more detail
967
+ const focalProximity = focalDetailBoost(finalX, finalY);
968
+ const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
969
+ if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
685
970
  const innerCount = 1 + Math.floor(rng() * 3);
686
971
  for (let n = 0; n < innerCount; n++) {
687
972
  // Pick inner shape from palette affinities
@@ -700,8 +985,8 @@ export function renderHashArt(
700
985
  enhanceShapeGeneration(
701
986
  ctx,
702
987
  innerShape,
703
- x + innerOffX,
704
- y + innerOffY,
988
+ finalX + innerOffX,
989
+ finalY + innerOffY,
705
990
  {
706
991
  fillColor: innerFill,
707
992
  strokeColor: hexWithAlpha(strokeColor, 0.5),
@@ -715,6 +1000,50 @@ export function renderHashArt(
715
1000
  );
716
1001
  }
717
1002
  }
1003
+
1004
+ // ── 5e. Shape constellations — pre-composed groups ─────────
1005
+ const constellationChance = 0.12 + focalProximity * 0.1; // 12-22% near focal
1006
+ if (size > adjustedMaxSize * 0.35 && rng() < constellationChance) {
1007
+ const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
1008
+ const members = constellation.build(rng, size);
1009
+ const groupRotation = rng() * Math.PI * 2;
1010
+ const cosR = Math.cos(groupRotation);
1011
+ const sinR = Math.sin(groupRotation);
1012
+
1013
+ for (const member of members) {
1014
+ // Rotate the group offset by the group rotation
1015
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
1016
+ const my = finalY + member.dx * sinR + member.dy * cosR;
1017
+
1018
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
1019
+
1020
+ const memberFill = hexWithAlpha(
1021
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
1022
+ fillAlpha * 0.8,
1023
+ );
1024
+ const memberStroke = enforceContrast(
1025
+ jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
1026
+ );
1027
+
1028
+ ctx.globalAlpha = layerOpacity * 0.6;
1029
+ // Use the member's shape if available, otherwise fall back to palette
1030
+ const memberShape = shapeNames.includes(member.shape)
1031
+ ? member.shape
1032
+ : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
1033
+
1034
+ enhanceShapeGeneration(ctx, memberShape, mx, my, {
1035
+ fillColor: memberFill,
1036
+ strokeColor: memberStroke,
1037
+ strokeWidth: strokeWidth * 0.7,
1038
+ size: member.size,
1039
+ rotation: member.rotation + (groupRotation * 180) / Math.PI,
1040
+ proportionType: "GOLDEN_RATIO",
1041
+ renderStyle: pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle,
1042
+ rng,
1043
+ });
1044
+ shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1045
+ }
1046
+ }
718
1047
  }
719
1048
  }
720
1049
 
@@ -801,7 +1130,41 @@ export function renderHashArt(
801
1130
  }
802
1131
  }
803
1132
 
804
- // ── 6b. Apply symmetry mirroring ─────────────────────────────────
1133
+ // ── 6b. Motion/energy lines short directional bursts ─────────
1134
+ const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
1135
+ const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
1136
+ if (hasEnergyLines && shapePositions.length > 0) {
1137
+ const energyCount = 5 + Math.floor(rng() * 10);
1138
+ ctx.lineCap = "round";
1139
+ for (let e = 0; e < energyCount; e++) {
1140
+ // Pick a random shape to radiate from
1141
+ const source = shapePositions[Math.floor(rng() * shapePositions.length)];
1142
+ const burstCount = 2 + Math.floor(rng() * 4);
1143
+ const baseAngle = flowAngle(source.x, source.y);
1144
+
1145
+ for (let b = 0; b < burstCount; b++) {
1146
+ const angle = baseAngle + (rng() - 0.5) * 1.2;
1147
+ const lineLen = (source.size * 0.3 + rng() * source.size * 0.5) * scaleFactor * 0.3;
1148
+ const startDist = source.size * 0.5;
1149
+ const sx = source.x + Math.cos(angle) * startDist;
1150
+ const sy = source.y + Math.sin(angle) * startDist;
1151
+ const ex = sx + Math.cos(angle) * lineLen;
1152
+ const ey = sy + Math.sin(angle) * lineLen;
1153
+
1154
+ ctx.globalAlpha = 0.04 + rng() * 0.06;
1155
+ ctx.strokeStyle = hexWithAlpha(
1156
+ enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
1157
+ );
1158
+ ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
1159
+ ctx.beginPath();
1160
+ ctx.moveTo(sx, sy);
1161
+ ctx.lineTo(ex, ey);
1162
+ ctx.stroke();
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ // ── 6c. Apply symmetry mirroring ─────────────────────────────────
805
1168
  if (symmetryMode !== "none") {
806
1169
  const canvas = ctx.canvas;
807
1170
  ctx.save();
@@ -924,6 +1287,50 @@ export function renderHashArt(
924
1287
  ctx.globalCompositeOperation = "source-over";
925
1288
  }
926
1289
 
1290
+ // ── 11. Signature mark — unique geometric chop from hash prefix ──
1291
+ {
1292
+ const sigRng = createRng(seedFromHash(gitHash, 42));
1293
+ const sigSize = Math.min(width, height) * 0.025;
1294
+ // Bottom-right corner with padding
1295
+ const sigX = width - sigSize * 2.5;
1296
+ const sigY = height - sigSize * 2.5;
1297
+ const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
1298
+ const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
1299
+
1300
+ ctx.save();
1301
+ ctx.globalAlpha = 0.12 + sigRng() * 0.08;
1302
+ ctx.translate(sigX, sigY);
1303
+ ctx.strokeStyle = sigColor;
1304
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.dominant, 0.06);
1305
+ ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
1306
+
1307
+ // Outer ring
1308
+ ctx.beginPath();
1309
+ ctx.arc(0, 0, sigSize, 0, Math.PI * 2);
1310
+ ctx.stroke();
1311
+ ctx.fill();
1312
+
1313
+ // Inner geometric pattern — unique per hash
1314
+ ctx.beginPath();
1315
+ for (let s = 0; s < sigSegments; s++) {
1316
+ const angle1 = sigRng() * Math.PI * 2;
1317
+ const angle2 = sigRng() * Math.PI * 2;
1318
+ const r1 = sigSize * (0.2 + sigRng() * 0.6);
1319
+ const r2 = sigSize * (0.2 + sigRng() * 0.6);
1320
+ ctx.moveTo(Math.cos(angle1) * r1, Math.sin(angle1) * r1);
1321
+ ctx.lineTo(Math.cos(angle2) * r2, Math.sin(angle2) * r2);
1322
+ }
1323
+ ctx.stroke();
1324
+
1325
+ // Center dot
1326
+ ctx.beginPath();
1327
+ ctx.arc(0, 0, sigSize * 0.12, 0, Math.PI * 2);
1328
+ ctx.fillStyle = sigColor;
1329
+ ctx.fill();
1330
+
1331
+ ctx.restore();
1332
+ }
1333
+
927
1334
  ctx.globalAlpha = 1;
928
1335
 
929
1336
  }