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/.github/workflows/deploy-www.yml +47 -0
- package/ALGORITHM.md +212 -14
- package/CHANGELOG.md +18 -0
- package/dist/browser.js +758 -23
- package/dist/browser.js.map +1 -1
- package/dist/main.js +758 -23
- package/dist/main.js.map +1 -1
- package/dist/module.js +758 -23
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +51 -4
- package/src/lib/canvas/colors.ts +70 -0
- package/src/lib/canvas/draw.ts +115 -6
- package/src/lib/canvas/shapes/affinity.ts +3 -3
- package/src/lib/render.ts +423 -17
- package/src/lib/utils.ts +109 -0
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
|
-
// ──
|
|
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 =
|
|
665
|
+
const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
|
|
604
666
|
|
|
605
667
|
function flowAngle(x: number, y: number): number {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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,
|
|
746
|
-
const strokeBase = pickHierarchyColor(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|