git-hash-art 0.9.0 → 0.10.1

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,7 +31,7 @@ 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,
@@ -48,7 +48,7 @@ import {
48
48
  pickStyleForShape,
49
49
  SHAPE_PROFILES
50
50
  } from "./canvas/shapes/affinity";
51
- import { createRng, seedFromHash } from "./utils";
51
+ import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils";
52
52
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
53
53
  import { selectArchetype, type BackgroundStyle } from "./archetypes";
54
54
 
@@ -74,7 +74,8 @@ type CompositionMode =
74
74
  | "flow-field"
75
75
  | "spiral"
76
76
  | "grid-subdivision"
77
- | "clustered";
77
+ | "clustered"
78
+ | "golden-spiral";
78
79
 
79
80
  const COMPOSITION_MODES: CompositionMode[] = [
80
81
  "radial",
@@ -82,6 +83,7 @@ const COMPOSITION_MODES: CompositionMode[] = [
82
83
  "spiral",
83
84
  "grid-subdivision",
84
85
  "clustered",
86
+ "golden-spiral",
85
87
  ];
86
88
 
87
89
  // ── Helper: get position based on composition mode ──────────────────
@@ -138,6 +140,17 @@ function getCompositionPosition(
138
140
  default: {
139
141
  return { x: rng() * width, y: rng() * height };
140
142
  }
143
+ case "golden-spiral": {
144
+ // Logarithmic spiral: r = a * e^(b*theta), with golden angle spacing
145
+ const PHI = (1 + Math.sqrt(5)) / 2;
146
+ const goldenAngle = 2 * Math.PI / (PHI * PHI); // ~137.5° in radians
147
+ const t = shapeIndex / totalShapes;
148
+ const angle = shapeIndex * goldenAngle + rng() * 0.3;
149
+ const maxR = Math.min(width, height) * 0.44;
150
+ // Shapes spiral outward with sqrt distribution for even area coverage
151
+ const r = Math.sqrt(t) * maxR + (rng() - 0.5) * maxR * 0.08;
152
+ return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
153
+ }
141
154
  }
142
155
  }
143
156
 
@@ -409,6 +422,9 @@ export function renderHashArt(
409
422
  // ── 0e. Light direction — consistent shadow angle ──────────────
410
423
  const lightAngle = rng() * Math.PI * 2;
411
424
 
425
+ // ── 0f. Palette evolution — hue drift direction across layers ──
426
+ const paletteHueShift = (rng() - 0.5) * 40; // -20° to +20° total drift
427
+
412
428
  const scaleFactor = Math.min(width, height) / 1024;
413
429
  const adjustedMinSize = minShapeSize * scaleFactor;
414
430
  const adjustedMaxSize = maxShapeSize * scaleFactor;
@@ -598,16 +614,68 @@ export function renderHashArt(
598
614
  return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull];
599
615
  }
600
616
 
601
- // ── 4. Flow field seed values ──────────────────────────────────
617
+ // ── 3b. Void zone decoration intentional negative space ────
618
+ for (const zone of voidZones) {
619
+ // Subtle halo ring around void zones
620
+ ctx.globalAlpha = 0.04 + rng() * 0.04;
621
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
622
+ ctx.lineWidth = 1.5 * scaleFactor;
623
+ ctx.beginPath();
624
+ ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
625
+ ctx.stroke();
626
+
627
+ // ~50% chance: scatter tiny dots inside the void
628
+ if (rng() < 0.5) {
629
+ const dotCount = 3 + Math.floor(rng() * 6);
630
+ ctx.globalAlpha = 0.06 + rng() * 0.04;
631
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
632
+ for (let d = 0; d < dotCount; d++) {
633
+ const angle = rng() * Math.PI * 2;
634
+ const dist = rng() * zone.radius * 0.7;
635
+ const dotR = (1 + rng() * 3) * scaleFactor;
636
+ ctx.beginPath();
637
+ ctx.arc(
638
+ zone.x + Math.cos(angle) * dist,
639
+ zone.y + Math.sin(angle) * dist,
640
+ dotR, 0, Math.PI * 2,
641
+ );
642
+ ctx.fill();
643
+ }
644
+ }
645
+
646
+ // ~30% chance: thin concentric ring inside
647
+ if (rng() < 0.3) {
648
+ ctx.globalAlpha = 0.03 + rng() * 0.03;
649
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
650
+ ctx.lineWidth = 0.5 * scaleFactor;
651
+ const innerR = zone.radius * (0.4 + rng() * 0.3);
652
+ ctx.beginPath();
653
+ ctx.arc(zone.x, zone.y, innerR, 0, Math.PI * 2);
654
+ ctx.stroke();
655
+ }
656
+ }
657
+ ctx.globalAlpha = 1;
658
+
659
+ // ── 4. Flow field — simplex noise for organic variation ─────────
660
+ // Create a seeded simplex noise field (unique per hash)
661
+ const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
662
+ const simplexNoise = createSimplexNoise(noiseFieldRng);
663
+ const fbmNoise = createFBM(simplexNoise, 3, 2.0, 0.5);
602
664
  const fieldAngleBase = rng() * Math.PI * 2;
603
- const fieldFreq = 0.5 + rng() * 2;
665
+ const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
604
666
 
605
667
  function flowAngle(x: number, y: number): number {
606
- return (
607
- fieldAngleBase +
608
- Math.sin((x / width) * fieldFreq * Math.PI * 2) * Math.PI * 0.5 +
609
- Math.cos((y / height) * fieldFreq * Math.PI * 2) * Math.PI * 0.5
610
- );
668
+ // Sample FBM noise at the position, scaled by frequency
669
+ const nx = (x / width) * fieldFreq;
670
+ const ny = (y / height) * fieldFreq;
671
+ return fieldAngleBase + fbmNoise(nx, ny) * Math.PI;
672
+ }
673
+
674
+ // Noise-based size modulation — shapes in "high noise" areas get scaled
675
+ function noiseSizeModulation(x: number, y: number): number {
676
+ const n = simplexNoise((x / width) * 3, (y / height) * 3);
677
+ // Map [-1,1] to [0.7, 1.3] — subtle terrain-like size variation
678
+ return 0.7 + (n + 1) * 0.3;
611
679
  }
612
680
 
613
681
  // Track all placed shapes for density checks and connecting curves
@@ -653,6 +721,8 @@ export function renderHashArt(
653
721
  gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
654
722
  renderStyle: heroStyle,
655
723
  rng,
724
+ lightAngle,
725
+ scaleFactor,
656
726
  });
657
727
 
658
728
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
@@ -690,6 +760,20 @@ export function renderHashArt(
690
760
  const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth
691
761
  const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg
692
762
 
763
+ // Color palette evolution — hue-rotate the hierarchy per layer
764
+ const layerHierarchy = evolveHierarchy(colorHierarchy, layerRatio, paletteHueShift);
765
+
766
+ // Focal depth: shapes near focal points get more detail
767
+ const focalDetailBoost = (px: number, py: number): number => {
768
+ let minFocalDist = Infinity;
769
+ for (const fp of focalPoints) {
770
+ const d = Math.hypot(px - fp.x, py - fp.y);
771
+ if (d < minFocalDist) minFocalDist = d;
772
+ }
773
+ const maxDist = Math.hypot(width, height) * 0.5;
774
+ return Math.max(0, 1 - minFocalDist / maxDist); // 1.0 at focal, 0.0 at edges
775
+ };
776
+
693
777
  for (let i = 0; i < numShapes; i++) {
694
778
  // Position from composition mode, then focal bias
695
779
  const rawPos = getCompositionPosition(
@@ -716,7 +800,7 @@ export function renderHashArt(
716
800
  const sizeT = Math.pow(rng(), archetype.sizePower);
717
801
  const size =
718
802
  (adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
719
- layerSizeScale;
803
+ layerSizeScale * noiseSizeModulation(x, y);
720
804
 
721
805
  // Size fraction for affinity-aware shape selection
722
806
  const sizeFraction = size / adjustedMaxSize;
@@ -741,9 +825,9 @@ export function renderHashArt(
741
825
  }
742
826
  }
743
827
 
744
- // Positional color from hierarchy + jitter
745
- let fillBase = getPositionalColor(x, y, width, height, colorHierarchy, rng);
746
- const strokeBase = pickHierarchyColor(colorHierarchy, rng);
828
+ // Positional color from hierarchy + jitter (using evolved layer palette)
829
+ let fillBase = getPositionalColor(x, y, width, height, layerHierarchy, rng);
830
+ const strokeBase = pickHierarchyColor(layerHierarchy, rng);
747
831
 
748
832
  // Desaturate colors on later layers for depth
749
833
  if (atmosphericDesat > 0) {
@@ -841,6 +925,8 @@ export function renderHashArt(
841
925
  gradientFillEnd: gradientEnd,
842
926
  renderStyle: finalRenderStyle,
843
927
  rng,
928
+ lightAngle,
929
+ scaleFactor,
844
930
  };
845
931
 
846
932
  if (shouldMirror) {
@@ -853,6 +939,26 @@ export function renderHashArt(
853
939
  enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
854
940
  }
855
941
 
942
+ // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
943
+ if (rng() < 0.2 && size > adjustedMinSize * 2) {
944
+ const glazePasses = 2 + Math.floor(rng() * 2);
945
+ for (let g = 0; g < glazePasses; g++) {
946
+ const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
947
+ const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
948
+ ctx.globalAlpha = glazeAlpha;
949
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, {
950
+ fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
951
+ strokeColor: "rgba(0,0,0,0)",
952
+ strokeWidth: 0,
953
+ size: size * glazeScale,
954
+ rotation,
955
+ proportionType: "GOLDEN_RATIO",
956
+ renderStyle: "fill-only",
957
+ rng,
958
+ });
959
+ }
960
+ }
961
+
856
962
  shapePositions.push({ x: finalX, y: finalY, size, shape });
857
963
 
858
964
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
@@ -884,7 +990,10 @@ export function renderHashArt(
884
990
  }
885
991
 
886
992
  // ── 5d. Recursive nesting ──────────────────────────────────
887
- if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
993
+ // Focal depth: shapes near focal points get more detail
994
+ const focalProximity = focalDetailBoost(finalX, finalY);
995
+ const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
996
+ if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
888
997
  const innerCount = 1 + Math.floor(rng() * 3);
889
998
  for (let n = 0; n < innerCount; n++) {
890
999
  // Pick inner shape from palette affinities
@@ -920,7 +1029,8 @@ export function renderHashArt(
920
1029
  }
921
1030
 
922
1031
  // ── 5e. Shape constellations — pre-composed groups ─────────
923
- if (size > adjustedMaxSize * 0.35 && rng() < 0.12) {
1032
+ const constellationChance = 0.12 + focalProximity * 0.1; // 12-22% near focal
1033
+ if (size > adjustedMaxSize * 0.35 && rng() < constellationChance) {
924
1034
  const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
925
1035
  const members = constellation.build(rng, size);
926
1036
  const groupRotation = rng() * Math.PI * 2;
@@ -967,6 +1077,72 @@ export function renderHashArt(
967
1077
  // Reset blend mode for post-processing passes
968
1078
  ctx.globalCompositeOperation = "source-over";
969
1079
 
1080
+ // ── 5f. Layered masking / cutout portals ───────────────────────
1081
+ // ~18% of images get 1-3 portal windows that paint over foreground
1082
+ // with a tinted background wash, creating a "peek through" effect.
1083
+ if (rng() < 0.18 && shapePositions.length > 3) {
1084
+ const portalCount = 1 + Math.floor(rng() * 2);
1085
+ for (let p = 0; p < portalCount; p++) {
1086
+ // Pick a position biased toward placed shapes
1087
+ const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
1088
+ const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
1089
+ const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
1090
+ const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
1091
+
1092
+ // Pick a portal shape from the palette
1093
+ const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
1094
+ const portalRotation = rng() * 360;
1095
+ const portalAlpha = 0.6 + rng() * 0.35;
1096
+
1097
+ ctx.save();
1098
+ ctx.translate(portalX, portalY);
1099
+ ctx.rotate((portalRotation * Math.PI) / 180);
1100
+
1101
+ // Step 1: Clip to the portal shape and fill with background wash
1102
+ ctx.beginPath();
1103
+ shapes[portalShape]?.(ctx, portalSize);
1104
+ ctx.clip();
1105
+
1106
+ // Fill the clipped region with a radial gradient from background colors
1107
+ const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
1108
+ const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
1109
+ portalGrad.addColorStop(0, portalColor);
1110
+ portalGrad.addColorStop(1, bgEnd);
1111
+ ctx.globalAlpha = portalAlpha;
1112
+ ctx.fillStyle = portalGrad;
1113
+ ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
1114
+
1115
+ // Optional: subtle inner texture — a few tiny dots inside the portal
1116
+ if (rng() < 0.5) {
1117
+ const dotCount = 3 + Math.floor(rng() * 5);
1118
+ ctx.globalAlpha = portalAlpha * 0.3;
1119
+ ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
1120
+ for (let d = 0; d < dotCount; d++) {
1121
+ const dx = (rng() - 0.5) * portalSize * 1.4;
1122
+ const dy = (rng() - 0.5) * portalSize * 1.4;
1123
+ const dr = (1 + rng() * 3) * scaleFactor;
1124
+ ctx.beginPath();
1125
+ ctx.arc(dx, dy, dr, 0, Math.PI * 2);
1126
+ ctx.fill();
1127
+ }
1128
+ }
1129
+
1130
+ ctx.restore();
1131
+
1132
+ // Step 2: Draw a border ring around the portal (outside the clip)
1133
+ ctx.save();
1134
+ ctx.translate(portalX, portalY);
1135
+ ctx.rotate((portalRotation * Math.PI) / 180);
1136
+ ctx.globalAlpha = 0.15 + rng() * 0.2;
1137
+ ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
1138
+ ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
1139
+ ctx.beginPath();
1140
+ shapes[portalShape]?.(ctx, portalSize * 1.06);
1141
+ ctx.stroke();
1142
+ ctx.restore();
1143
+ }
1144
+ }
1145
+
970
1146
 
971
1147
  // ── 6. Flow-line pass — variable color, branching, pressure ────
972
1148
  const baseFlowLines = 6 + Math.floor(rng() * 10);
@@ -1047,7 +1223,41 @@ export function renderHashArt(
1047
1223
  }
1048
1224
  }
1049
1225
 
1050
- // ── 6b. Apply symmetry mirroring ─────────────────────────────────
1226
+ // ── 6b. Motion/energy lines short directional bursts ─────────
1227
+ const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
1228
+ const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
1229
+ if (hasEnergyLines && shapePositions.length > 0) {
1230
+ const energyCount = 5 + Math.floor(rng() * 10);
1231
+ ctx.lineCap = "round";
1232
+ for (let e = 0; e < energyCount; e++) {
1233
+ // Pick a random shape to radiate from
1234
+ const source = shapePositions[Math.floor(rng() * shapePositions.length)];
1235
+ const burstCount = 2 + Math.floor(rng() * 4);
1236
+ const baseAngle = flowAngle(source.x, source.y);
1237
+
1238
+ for (let b = 0; b < burstCount; b++) {
1239
+ const angle = baseAngle + (rng() - 0.5) * 1.2;
1240
+ const lineLen = (source.size * 0.3 + rng() * source.size * 0.5) * scaleFactor * 0.3;
1241
+ const startDist = source.size * 0.5;
1242
+ const sx = source.x + Math.cos(angle) * startDist;
1243
+ const sy = source.y + Math.sin(angle) * startDist;
1244
+ const ex = sx + Math.cos(angle) * lineLen;
1245
+ const ey = sy + Math.sin(angle) * lineLen;
1246
+
1247
+ ctx.globalAlpha = 0.04 + rng() * 0.06;
1248
+ ctx.strokeStyle = hexWithAlpha(
1249
+ enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
1250
+ );
1251
+ ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
1252
+ ctx.beginPath();
1253
+ ctx.moveTo(sx, sy);
1254
+ ctx.lineTo(ex, ey);
1255
+ ctx.stroke();
1256
+ }
1257
+ }
1258
+ }
1259
+
1260
+ // ── 6c. Apply symmetry mirroring ─────────────────────────────────
1051
1261
  if (symmetryMode !== "none") {
1052
1262
  const canvas = ctx.canvas;
1053
1263
  ctx.save();
@@ -1170,6 +1380,202 @@ export function renderHashArt(
1170
1380
  ctx.globalCompositeOperation = "source-over";
1171
1381
  }
1172
1382
 
1383
+ // 10d. Gradient map — map luminance through a two-color gradient
1384
+ // Uses dominant→accent as the dark→light ramp for a cohesive tonal look
1385
+ if (rng() < 0.35) {
1386
+ const gmDark = colorHierarchy.dominant;
1387
+ const gmLight = colorHierarchy.accent;
1388
+ ctx.globalAlpha = 0.06 + rng() * 0.06; // very subtle: 6-12%
1389
+ ctx.globalCompositeOperation = "color";
1390
+ // Paint a linear gradient from dark color (top) to light color (bottom)
1391
+ const gmGrad = ctx.createLinearGradient(0, 0, 0, height);
1392
+ gmGrad.addColorStop(0, gmDark);
1393
+ gmGrad.addColorStop(1, gmLight);
1394
+ ctx.fillStyle = gmGrad;
1395
+ ctx.fillRect(0, 0, width, height);
1396
+ ctx.globalCompositeOperation = "source-over";
1397
+ }
1398
+
1399
+ // ── 10e. Generative borders — archetype-driven decorative frames ──
1400
+ {
1401
+ ctx.save();
1402
+ ctx.globalAlpha = 1;
1403
+ ctx.globalCompositeOperation = "source-over";
1404
+ const borderRng = createRng(seedFromHash(gitHash, 314));
1405
+ const borderPad = Math.min(width, height) * 0.025;
1406
+ const borderColor = hexWithAlpha(colorHierarchy.accent, 0.2);
1407
+ const borderColorSolid = colorHierarchy.accent;
1408
+ const archName = archetype.name;
1409
+
1410
+ if (archName.includes("geometric") || archName.includes("op-art") || archName.includes("shattered")) {
1411
+ // Clean ruled lines with corner ornaments
1412
+ ctx.strokeStyle = borderColor;
1413
+ ctx.lineWidth = Math.max(1, 1.5 * scaleFactor);
1414
+ ctx.globalAlpha = 0.18 + borderRng() * 0.1;
1415
+
1416
+ // Outer rule
1417
+ ctx.strokeRect(borderPad, borderPad, width - borderPad * 2, height - borderPad * 2);
1418
+ // Inner rule (thinner, offset)
1419
+ const innerPad = borderPad * 1.8;
1420
+ ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
1421
+ ctx.globalAlpha *= 0.7;
1422
+ ctx.strokeRect(innerPad, innerPad, width - innerPad * 2, height - innerPad * 2);
1423
+
1424
+ // Corner ornaments — small squares at each corner
1425
+ const ornSize = borderPad * 0.6;
1426
+ ctx.fillStyle = hexWithAlpha(borderColorSolid, 0.12);
1427
+ const corners = [
1428
+ [borderPad, borderPad],
1429
+ [width - borderPad - ornSize, borderPad],
1430
+ [borderPad, height - borderPad - ornSize],
1431
+ [width - borderPad - ornSize, height - borderPad - ornSize],
1432
+ ];
1433
+ for (const [cx2, cy2] of corners) {
1434
+ ctx.fillRect(cx2, cy2, ornSize, ornSize);
1435
+ // Diagonal cross inside ornament
1436
+ ctx.beginPath();
1437
+ ctx.moveTo(cx2, cy2);
1438
+ ctx.lineTo(cx2 + ornSize, cy2 + ornSize);
1439
+ ctx.moveTo(cx2 + ornSize, cy2);
1440
+ ctx.lineTo(cx2, cy2 + ornSize);
1441
+ ctx.stroke();
1442
+ }
1443
+ } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
1444
+ // Vine tendrils — organic curving lines along edges
1445
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
1446
+ ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
1447
+ ctx.globalAlpha = 0.12 + borderRng() * 0.08;
1448
+ ctx.lineCap = "round";
1449
+
1450
+ const tendrilCount = 8 + Math.floor(borderRng() * 8);
1451
+ for (let t = 0; t < tendrilCount; t++) {
1452
+ // Start from a random edge point
1453
+ const edge = Math.floor(borderRng() * 4);
1454
+ let tx: number, ty: number;
1455
+ if (edge === 0) { tx = borderRng() * width; ty = borderPad; }
1456
+ else if (edge === 1) { tx = borderRng() * width; ty = height - borderPad; }
1457
+ else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
1458
+ else { tx = width - borderPad; ty = borderRng() * height; }
1459
+
1460
+ ctx.beginPath();
1461
+ ctx.moveTo(tx, ty);
1462
+ const segs = 3 + Math.floor(borderRng() * 4);
1463
+ for (let s = 0; s < segs; s++) {
1464
+ const inward = borderPad * (1 + borderRng() * 2);
1465
+ // Curl inward from edge
1466
+ const cpx2 = tx + (borderRng() - 0.5) * borderPad * 4;
1467
+ const cpy2 = ty + (edge < 2 ? (edge === 0 ? inward : -inward) : 0);
1468
+ const cpx3 = tx + (edge >= 2 ? (edge === 2 ? inward : -inward) : (borderRng() - 0.5) * borderPad * 3);
1469
+ const cpy3 = ty + (borderRng() - 0.5) * borderPad * 3;
1470
+ tx = cpx3;
1471
+ ty = cpy3;
1472
+ ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
1473
+ }
1474
+ ctx.stroke();
1475
+
1476
+ // Small leaf/dot at tendril end
1477
+ if (borderRng() < 0.6) {
1478
+ ctx.beginPath();
1479
+ ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
1480
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1481
+ ctx.fill();
1482
+ }
1483
+ }
1484
+ } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
1485
+ // Star-studded arcs along edges
1486
+ ctx.globalAlpha = 0.1 + borderRng() * 0.08;
1487
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
1488
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.12);
1489
+ ctx.lineWidth = Math.max(0.5, 0.7 * scaleFactor);
1490
+
1491
+ // Subtle arc along top and bottom
1492
+ ctx.beginPath();
1493
+ ctx.arc(cx, -height * 0.3, height * 0.6, 0.3, Math.PI - 0.3);
1494
+ ctx.stroke();
1495
+ ctx.beginPath();
1496
+ ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
1497
+ ctx.stroke();
1498
+
1499
+ // Scatter small stars along the border region
1500
+ const starCount = 15 + Math.floor(borderRng() * 15);
1501
+ for (let s = 0; s < starCount; s++) {
1502
+ const edge = Math.floor(borderRng() * 4);
1503
+ let sx: number, sy: number;
1504
+ if (edge === 0) { sx = borderRng() * width; sy = borderPad * (0.5 + borderRng()); }
1505
+ else if (edge === 1) { sx = borderRng() * width; sy = height - borderPad * (0.5 + borderRng()); }
1506
+ else if (edge === 2) { sx = borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
1507
+ else { sx = width - borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
1508
+
1509
+ const starR = (1 + borderRng() * 2.5) * scaleFactor;
1510
+ // 4-point star
1511
+ ctx.beginPath();
1512
+ for (let p = 0; p < 8; p++) {
1513
+ const a = (p / 8) * Math.PI * 2;
1514
+ const r = p % 2 === 0 ? starR : starR * 0.4;
1515
+ const px2 = sx + Math.cos(a) * r;
1516
+ const py2 = sy + Math.sin(a) * r;
1517
+ if (p === 0) ctx.moveTo(px2, py2);
1518
+ else ctx.lineTo(px2, py2);
1519
+ }
1520
+ ctx.closePath();
1521
+ ctx.fill();
1522
+ }
1523
+ } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
1524
+ // Thin single rule — understated elegance
1525
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
1526
+ ctx.lineWidth = Math.max(0.5, 0.6 * scaleFactor);
1527
+ ctx.globalAlpha = 0.1 + borderRng() * 0.06;
1528
+ ctx.strokeRect(borderPad * 1.5, borderPad * 1.5, width - borderPad * 3, height - borderPad * 3);
1529
+ }
1530
+ // Other archetypes: no border (intentional — not every image needs one)
1531
+
1532
+ ctx.restore();
1533
+ }
1534
+
1535
+ // ── 11. Signature mark — unique geometric chop from hash prefix ──
1536
+ {
1537
+ const sigRng = createRng(seedFromHash(gitHash, 42));
1538
+ const sigSize = Math.min(width, height) * 0.025;
1539
+ // Bottom-right corner with padding
1540
+ const sigX = width - sigSize * 2.5;
1541
+ const sigY = height - sigSize * 2.5;
1542
+ const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
1543
+ const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
1544
+
1545
+ ctx.save();
1546
+ ctx.globalAlpha = 0.12 + sigRng() * 0.08;
1547
+ ctx.translate(sigX, sigY);
1548
+ ctx.strokeStyle = sigColor;
1549
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.dominant, 0.06);
1550
+ ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
1551
+
1552
+ // Outer ring
1553
+ ctx.beginPath();
1554
+ ctx.arc(0, 0, sigSize, 0, Math.PI * 2);
1555
+ ctx.stroke();
1556
+ ctx.fill();
1557
+
1558
+ // Inner geometric pattern — unique per hash
1559
+ ctx.beginPath();
1560
+ for (let s = 0; s < sigSegments; s++) {
1561
+ const angle1 = sigRng() * Math.PI * 2;
1562
+ const angle2 = sigRng() * Math.PI * 2;
1563
+ const r1 = sigSize * (0.2 + sigRng() * 0.6);
1564
+ const r2 = sigSize * (0.2 + sigRng() * 0.6);
1565
+ ctx.moveTo(Math.cos(angle1) * r1, Math.sin(angle1) * r1);
1566
+ ctx.lineTo(Math.cos(angle2) * r2, Math.sin(angle2) * r2);
1567
+ }
1568
+ ctx.stroke();
1569
+
1570
+ // Center dot
1571
+ ctx.beginPath();
1572
+ ctx.arc(0, 0, sigSize * 0.12, 0, Math.PI * 2);
1573
+ ctx.fillStyle = sigColor;
1574
+ ctx.fill();
1575
+
1576
+ ctx.restore();
1577
+ }
1578
+
1173
1579
  ctx.globalAlpha = 1;
1174
1580
 
1175
1581
  }
package/src/lib/utils.ts CHANGED
@@ -54,6 +54,115 @@ export const Proportions = {
54
54
 
55
55
  export type ProportionType = keyof typeof Proportions;
56
56
 
57
+ // ── Deterministic 2D Simplex Noise ──────────────────────────────────
58
+ // A compact implementation seeded from the RNG so every hash produces
59
+ // a unique noise field without external dependencies.
60
+
61
+ /**
62
+ * Create a seeded 2D simplex noise function.
63
+ * Returns noise(x, y) → float in approximately [-1, 1].
64
+ */
65
+ export function createSimplexNoise(rng: () => number): (x: number, y: number) => number {
66
+ // Build a deterministic permutation table (256 entries, doubled)
67
+ const perm = new Uint8Array(512);
68
+ const p = new Uint8Array(256);
69
+ for (let i = 0; i < 256; i++) p[i] = i;
70
+ // Fisher-Yates shuffle with our seeded RNG
71
+ for (let i = 255; i > 0; i--) {
72
+ const j = Math.floor(rng() * (i + 1));
73
+ const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
74
+ }
75
+ for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
76
+
77
+ // 12 gradient vectors for 2D simplex
78
+ const GRAD2 = [
79
+ [1,1],[-1,1],[1,-1],[-1,-1],
80
+ [1,0],[-1,0],[0,1],[0,-1],
81
+ [1,1],[-1,1],[1,-1],[-1,-1],
82
+ ];
83
+
84
+ const F2 = 0.5 * (Math.sqrt(3) - 1);
85
+ const G2 = (3 - Math.sqrt(3)) / 6;
86
+
87
+ function dot2(g: number[], x: number, y: number): number {
88
+ return g[0] * x + g[1] * y;
89
+ }
90
+
91
+ return function noise2D(xin: number, yin: number): number {
92
+ const s = (xin + yin) * F2;
93
+ const i = Math.floor(xin + s);
94
+ const j = Math.floor(yin + s);
95
+ const t = (i + j) * G2;
96
+ const X0 = i - t;
97
+ const Y0 = j - t;
98
+ const x0 = xin - X0;
99
+ const y0 = yin - Y0;
100
+
101
+ let i1: number, j1: number;
102
+ if (x0 > y0) { i1 = 1; j1 = 0; }
103
+ else { i1 = 0; j1 = 1; }
104
+
105
+ const x1 = x0 - i1 + G2;
106
+ const y1 = y0 - j1 + G2;
107
+ const x2 = x0 - 1 + 2 * G2;
108
+ const y2 = y0 - 1 + 2 * G2;
109
+
110
+ const ii = i & 255;
111
+ const jj = j & 255;
112
+
113
+ let n0 = 0, n1 = 0, n2 = 0;
114
+
115
+ let t0 = 0.5 - x0 * x0 - y0 * y0;
116
+ if (t0 >= 0) {
117
+ t0 *= t0;
118
+ const gi0 = perm[ii + perm[jj]] % 12;
119
+ n0 = t0 * t0 * dot2(GRAD2[gi0], x0, y0);
120
+ }
121
+
122
+ let t1 = 0.5 - x1 * x1 - y1 * y1;
123
+ if (t1 >= 0) {
124
+ t1 *= t1;
125
+ const gi1 = perm[ii + i1 + perm[jj + j1]] % 12;
126
+ n1 = t1 * t1 * dot2(GRAD2[gi1], x1, y1);
127
+ }
128
+
129
+ let t2 = 0.5 - x2 * x2 - y2 * y2;
130
+ if (t2 >= 0) {
131
+ t2 *= t2;
132
+ const gi2 = perm[ii + 1 + perm[jj + 1]] % 12;
133
+ n2 = t2 * t2 * dot2(GRAD2[gi2], x2, y2);
134
+ }
135
+
136
+ // Scale to approximately [-1, 1]
137
+ return 70 * (n0 + n1 + n2);
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Fractal Brownian Motion — layer multiple octaves of noise for richer fields.
143
+ * Returns a function (x, y) → float in approximately [-1, 1].
144
+ */
145
+ export function createFBM(
146
+ noise: (x: number, y: number) => number,
147
+ octaves = 4,
148
+ lacunarity = 2.0,
149
+ gain = 0.5,
150
+ ): (x: number, y: number) => number {
151
+ return function fbm(x: number, y: number): number {
152
+ let value = 0;
153
+ let amplitude = 1;
154
+ let frequency = 1;
155
+ let maxAmp = 0;
156
+ for (let i = 0; i < octaves; i++) {
157
+ value += noise(x * frequency, y * frequency) * amplitude;
158
+ maxAmp += amplitude;
159
+ amplitude *= gain;
160
+ frequency *= lacunarity;
161
+ }
162
+ return value / maxAmp;
163
+ };
164
+ }
165
+
57
166
  interface Pattern {
58
167
  type: string;
59
168
  config: any;