git-hash-art 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
@@ -27,7 +27,10 @@ export type PaletteMode =
27
27
  | "neon" // high saturation on dark
28
28
  | "pastel-light" // soft pastels on light background
29
29
  | "earth" // muted warm naturals
30
- | "high-contrast"; // black + white + one accent
30
+ | "high-contrast" // black + white + one accent
31
+ | "split-complementary" // base hue + two flanking complements
32
+ | "analogous-accent" // tight analogous cluster + one distant accent
33
+ | "limited-palette"; // 3 colors only, risograph-print feel
31
34
 
32
35
  // ── Archetype definition ────────────────────────────────────────────
33
36
 
@@ -194,6 +194,45 @@ export class SacredColorScheme {
194
194
  const accent = hslToHex(baseHue, 0.9, 0.5);
195
195
  return ["#111111", "#eeeeee", accent, hslToHex(baseHue, 0.7, 0.35)];
196
196
  }
197
+ case "split-complementary": {
198
+ // Base hue + two colors flanking the complement (±30°)
199
+ const comp = (baseHue + 180) % 360;
200
+ const split1 = (comp - 30 + 360) % 360;
201
+ const split2 = (comp + 30) % 360;
202
+ const sat = 0.55 + this.rng() * 0.25;
203
+ return [
204
+ hslToHex(baseHue, sat, 0.5),
205
+ hslToHex(baseHue, sat * 0.8, 0.65),
206
+ hslToHex(split1, sat, 0.5),
207
+ hslToHex(split2, sat, 0.5),
208
+ hslToHex(split1, sat * 0.7, 0.7),
209
+ ];
210
+ }
211
+ case "analogous-accent": {
212
+ // Tight cluster of 3 analogous hues + 1 distant accent
213
+ const step = 15 + this.rng() * 20; // 15-35° apart
214
+ const h1 = (baseHue - step + 360) % 360;
215
+ const h2 = (baseHue + step) % 360;
216
+ const accentHue = (baseHue + 150 + this.rng() * 60) % 360;
217
+ const sat = 0.5 + this.rng() * 0.3;
218
+ return [
219
+ hslToHex(baseHue, sat, 0.5),
220
+ hslToHex(h1, sat, 0.55),
221
+ hslToHex(h2, sat, 0.45),
222
+ hslToHex(accentHue, sat + 0.15, 0.5),
223
+ ];
224
+ }
225
+ case "limited-palette": {
226
+ // Only 3 colors — like a risograph print
227
+ const h2 = (baseHue + 120 + this.rng() * 40) % 360;
228
+ const h3 = (baseHue + 220 + this.rng() * 40) % 360;
229
+ const sat = 0.6 + this.rng() * 0.2;
230
+ return [
231
+ hslToHex(baseHue, sat, 0.5),
232
+ hslToHex(h2, sat, 0.5),
233
+ hslToHex(h3, sat * 0.9, 0.55),
234
+ ];
235
+ }
197
236
  case "harmonious":
198
237
  default:
199
238
  return this.getColors();
@@ -210,6 +249,11 @@ export class SacredColorScheme {
210
249
  case "high-contrast":
211
250
  case "monochrome-ink":
212
251
  return ["#f5f5f0", "#e8e8e0"];
252
+ case "split-complementary":
253
+ case "analogous-accent":
254
+ return this.getBackgroundColors();
255
+ case "limited-palette":
256
+ return [hslToHex(this.seed % 360, 0.08, 0.94), hslToHex((this.seed + 20) % 360, 0.06, 0.90)];
213
257
  case "neon":
214
258
  return ["#0a0a12", "#050510"];
215
259
  case "earth":
@@ -533,5 +577,6 @@ export function evolveHierarchy(
533
577
  dominant: hueRotate(base.dominant, shift),
534
578
  secondary: hueRotate(base.secondary, shift * 0.7),
535
579
  accent: hueRotate(base.accent, shift * 0.5),
580
+ all: base.all.map(c => hueRotate(c, shift * 0.6)),
536
581
  };
537
582
  }
@@ -91,6 +91,10 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
91
91
  renderStyle?: RenderStyle;
92
92
  /** RNG for watercolor jitter (required for "watercolor" style). */
93
93
  rng?: () => number;
94
+ /** Light direction angle in radians — used for shadow & highlight. */
95
+ lightAngle?: number;
96
+ /** Scale factor for resolution-independent sizing. */
97
+ scaleFactor?: number;
94
98
  }
95
99
 
96
100
  export function drawShape(
@@ -209,6 +213,28 @@ function applyRenderStyle(
209
213
  ctx.fillStyle = origFill;
210
214
  ctx.restore();
211
215
 
216
+ // Pass 4: Organic edge erosion — irregular bites along the boundary
217
+ if (rng && size > 20) {
218
+ const erosionBites = 6 + Math.floor(rng() * 8);
219
+ const edgeRadius = size * 0.45;
220
+ ctx.save();
221
+ ctx.globalCompositeOperation = "destination-out";
222
+ ctx.globalAlpha = 0.6 + rng() * 0.3;
223
+ for (let eb = 0; eb < erosionBites; eb++) {
224
+ const biteAngle = rng() * Math.PI * 2;
225
+ const biteDist = edgeRadius * (0.85 + rng() * 0.25);
226
+ const biteR = size * (0.02 + rng() * 0.04);
227
+ ctx.beginPath();
228
+ ctx.arc(
229
+ Math.cos(biteAngle) * biteDist,
230
+ Math.sin(biteAngle) * biteDist,
231
+ biteR, 0, Math.PI * 2,
232
+ );
233
+ ctx.fill();
234
+ }
235
+ ctx.restore();
236
+ }
237
+
212
238
  ctx.globalAlpha = savedAlpha;
213
239
  // Soft stroke on top — thinner than normal for delicacy
214
240
  ctx.globalAlpha *= 0.25;
@@ -532,6 +558,29 @@ function applyRenderStyle(
532
558
  ctx.stroke();
533
559
  ctx.restore();
534
560
  }
561
+
562
+ // Organic edge erosion — small irregular bites for rough paper feel
563
+ if (rng && size > 20) {
564
+ const erosionBites = 4 + Math.floor(rng() * 6);
565
+ const edgeRadius = size * 0.42;
566
+ ctx.save();
567
+ ctx.globalCompositeOperation = "destination-out";
568
+ ctx.globalAlpha = 0.5 + rng() * 0.3;
569
+ for (let eb = 0; eb < erosionBites; eb++) {
570
+ const biteAngle = rng() * Math.PI * 2;
571
+ const biteDist = edgeRadius * (0.9 + rng() * 0.2);
572
+ const biteR = size * (0.015 + rng() * 0.03);
573
+ ctx.beginPath();
574
+ ctx.arc(
575
+ Math.cos(biteAngle) * biteDist,
576
+ Math.sin(biteAngle) * biteDist,
577
+ biteR, 0, Math.PI * 2,
578
+ );
579
+ ctx.fill();
580
+ }
581
+ ctx.restore();
582
+ }
583
+
535
584
  ctx.globalAlpha = savedAlphaHD;
536
585
  break;
537
586
  }
@@ -570,14 +619,24 @@ export function enhanceShapeGeneration(
570
619
  gradientFillEnd,
571
620
  renderStyle = "fill-and-stroke",
572
621
  rng,
622
+ lightAngle,
623
+ scaleFactor = 1,
573
624
  } = config;
574
625
 
575
626
  ctx.save();
576
627
  ctx.translate(x, y);
577
628
  ctx.rotate((rotation * Math.PI) / 180);
578
629
 
579
- // Glow / shadow effect
580
- if (glowRadius > 0) {
630
+ // ── Drop shadow — soft colored shadow offset along light direction ──
631
+ if (lightAngle !== undefined && size > 10) {
632
+ const shadowDist = size * 0.035;
633
+ const shadowBlurR = size * 0.06;
634
+ ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
635
+ ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
636
+ ctx.shadowBlur = shadowBlurR;
637
+ ctx.shadowColor = "rgba(0,0,0,0.12)";
638
+ } else if (glowRadius > 0) {
639
+ // Glow / shadow effect (legacy path)
581
640
  ctx.shadowBlur = glowRadius;
582
641
  ctx.shadowColor = glowColor || fillColor;
583
642
  ctx.shadowOffsetX = 0;
@@ -603,9 +662,29 @@ export function enhanceShapeGeneration(
603
662
  applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
604
663
  }
605
664
 
606
- // Reset shadow so patterns aren't double-glowed
607
- if (glowRadius > 0) {
608
- ctx.shadowBlur = 0;
665
+ // Reset shadow so patterns and highlight aren't double-shadowed
666
+ ctx.shadowBlur = 0;
667
+ ctx.shadowOffsetX = 0;
668
+ ctx.shadowOffsetY = 0;
669
+ ctx.shadowColor = "transparent";
670
+
671
+ // ── Specular highlight — bright arc on the light-facing side ──
672
+ if (lightAngle !== undefined && size > 15 && rng) {
673
+ const hlRadius = size * 0.35;
674
+ const hlDist = size * 0.15;
675
+ const hlX = Math.cos(lightAngle) * hlDist;
676
+ const hlY = Math.sin(lightAngle) * hlDist;
677
+ const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
678
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
679
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
680
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
681
+ const savedOp = ctx.globalCompositeOperation;
682
+ ctx.globalCompositeOperation = "soft-light";
683
+ ctx.fillStyle = hlGrad;
684
+ ctx.beginPath();
685
+ ctx.arc(hlX, hlY, hlRadius, 0, Math.PI * 2);
686
+ ctx.fill();
687
+ ctx.globalCompositeOperation = savedOp;
609
688
  }
610
689
 
611
690
  // Layer additional patterns if specified
package/src/lib/render.ts CHANGED
@@ -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
 
@@ -643,16 +656,26 @@ export function renderHashArt(
643
656
  }
644
657
  ctx.globalAlpha = 1;
645
658
 
646
- // ── 4. Flow field seed values ──────────────────────────────────
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);
647
664
  const fieldAngleBase = rng() * Math.PI * 2;
648
- const fieldFreq = 0.5 + rng() * 2;
665
+ const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
649
666
 
650
667
  function flowAngle(x: number, y: number): number {
651
- return (
652
- fieldAngleBase +
653
- Math.sin((x / width) * fieldFreq * Math.PI * 2) * Math.PI * 0.5 +
654
- Math.cos((y / height) * fieldFreq * Math.PI * 2) * Math.PI * 0.5
655
- );
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;
656
679
  }
657
680
 
658
681
  // Track all placed shapes for density checks and connecting curves
@@ -698,6 +721,8 @@ export function renderHashArt(
698
721
  gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
699
722
  renderStyle: heroStyle,
700
723
  rng,
724
+ lightAngle,
725
+ scaleFactor,
701
726
  });
702
727
 
703
728
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
@@ -775,7 +800,7 @@ export function renderHashArt(
775
800
  const sizeT = Math.pow(rng(), archetype.sizePower);
776
801
  const size =
777
802
  (adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
778
- layerSizeScale;
803
+ layerSizeScale * noiseSizeModulation(x, y);
779
804
 
780
805
  // Size fraction for affinity-aware shape selection
781
806
  const sizeFraction = size / adjustedMaxSize;
@@ -900,6 +925,8 @@ export function renderHashArt(
900
925
  gradientFillEnd: gradientEnd,
901
926
  renderStyle: finalRenderStyle,
902
927
  rng,
928
+ lightAngle,
929
+ scaleFactor,
903
930
  };
904
931
 
905
932
  if (shouldMirror) {
@@ -1050,6 +1077,72 @@ export function renderHashArt(
1050
1077
  // Reset blend mode for post-processing passes
1051
1078
  ctx.globalCompositeOperation = "source-over";
1052
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
+
1053
1146
 
1054
1147
  // ── 6. Flow-line pass — variable color, branching, pressure ────
1055
1148
  const baseFlowLines = 6 + Math.floor(rng() * 10);
@@ -1287,6 +1380,158 @@ export function renderHashArt(
1287
1380
  ctx.globalCompositeOperation = "source-over";
1288
1381
  }
1289
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
+
1290
1535
  // ── 11. Signature mark — unique geometric chop from hash prefix ──
1291
1536
  {
1292
1537
  const sigRng = createRng(seedFromHash(gitHash, 42));
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;