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/.github/workflows/deploy-www.yml +47 -0
- package/ALGORITHM.md +108 -8
- package/CHANGELOG.md +10 -0
- package/dist/browser.js +518 -13
- package/dist/browser.js.map +1 -1
- package/dist/main.js +518 -13
- package/dist/main.js.map +1 -1
- package/dist/module.js +518 -13
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +4 -1
- package/src/lib/canvas/colors.ts +45 -0
- package/src/lib/canvas/draw.ts +84 -5
- package/src/lib/render.ts +255 -10
- package/src/lib/utils.ts +109 -0
package/package.json
CHANGED
package/src/lib/archetypes.ts
CHANGED
|
@@ -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"
|
|
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
|
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -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
|
-
//
|
|
580
|
-
if (
|
|
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-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
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 =
|
|
665
|
+
const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
|
|
649
666
|
|
|
650
667
|
function flowAngle(x: number, y: number): number {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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;
|