git-hash-art 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-www.yml +57 -0
- package/ALGORITHM.md +108 -8
- package/CHANGELOG.md +20 -0
- package/dist/browser.js +873 -64
- package/dist/browser.js.map +1 -1
- package/dist/main.js +875 -64
- package/dist/main.js.map +1 -1
- package/dist/module.js +875 -64
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +33 -1
- package/src/lib/canvas/colors.ts +52 -5
- package/src/lib/canvas/draw.ts +94 -5
- package/src/lib/render.ts +501 -67
- package/src/lib/utils.ts +109 -0
package/src/lib/render.ts
CHANGED
|
@@ -48,9 +48,9 @@ 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
|
-
import { selectArchetype, type BackgroundStyle } from "./archetypes";
|
|
53
|
+
import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes";
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
// ── Shape categories for weighted selection (legacy fallback) ───────
|
|
@@ -69,19 +69,13 @@ const SACRED_SHAPES = [
|
|
|
69
69
|
|
|
70
70
|
// ── Composition modes ───────────────────────────────────────────────
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
| "radial"
|
|
74
|
-
| "flow-field"
|
|
75
|
-
| "spiral"
|
|
76
|
-
| "grid-subdivision"
|
|
77
|
-
| "clustered";
|
|
78
|
-
|
|
79
|
-
const COMPOSITION_MODES: CompositionMode[] = [
|
|
72
|
+
const ALL_COMPOSITION_MODES: CompositionMode[] = [
|
|
80
73
|
"radial",
|
|
81
74
|
"flow-field",
|
|
82
75
|
"spiral",
|
|
83
76
|
"grid-subdivision",
|
|
84
77
|
"clustered",
|
|
78
|
+
"golden-spiral",
|
|
85
79
|
];
|
|
86
80
|
|
|
87
81
|
// ── Helper: get position based on composition mode ──────────────────
|
|
@@ -138,6 +132,17 @@ function getCompositionPosition(
|
|
|
138
132
|
default: {
|
|
139
133
|
return { x: rng() * width, y: rng() * height };
|
|
140
134
|
}
|
|
135
|
+
case "golden-spiral": {
|
|
136
|
+
// Logarithmic spiral: r = a * e^(b*theta), with golden angle spacing
|
|
137
|
+
const PHI = (1 + Math.sqrt(5)) / 2;
|
|
138
|
+
const goldenAngle = 2 * Math.PI / (PHI * PHI); // ~137.5° in radians
|
|
139
|
+
const t = shapeIndex / totalShapes;
|
|
140
|
+
const angle = shapeIndex * goldenAngle + rng() * 0.3;
|
|
141
|
+
const maxR = Math.min(width, height) * 0.44;
|
|
142
|
+
// Shapes spiral outward with sqrt distribution for even area coverage
|
|
143
|
+
const r = Math.sqrt(t) * maxR + (rng() - 0.5) * maxR * 0.08;
|
|
144
|
+
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
|
145
|
+
}
|
|
141
146
|
}
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -180,7 +185,80 @@ function isInVoidZone(
|
|
|
180
185
|
return false;
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
// ──
|
|
188
|
+
// ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
|
|
189
|
+
|
|
190
|
+
class SpatialGrid {
|
|
191
|
+
private cells: Map<string, Array<{ x: number; y: number; size: number; shape: string }>>;
|
|
192
|
+
private cellSize: number;
|
|
193
|
+
|
|
194
|
+
constructor(cellSize: number) {
|
|
195
|
+
this.cells = new Map();
|
|
196
|
+
this.cellSize = cellSize;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private key(cx: number, cy: number): string {
|
|
200
|
+
return `${cx},${cy}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
insert(item: { x: number; y: number; size: number; shape: string }): void {
|
|
204
|
+
const cx = Math.floor(item.x / this.cellSize);
|
|
205
|
+
const cy = Math.floor(item.y / this.cellSize);
|
|
206
|
+
const k = this.key(cx, cy);
|
|
207
|
+
const cell = this.cells.get(k);
|
|
208
|
+
if (cell) cell.push(item);
|
|
209
|
+
else this.cells.set(k, [item]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Count items within radius of (x, y) */
|
|
213
|
+
countNear(x: number, y: number, radius: number): number {
|
|
214
|
+
const r2 = radius * radius;
|
|
215
|
+
const minCx = Math.floor((x - radius) / this.cellSize);
|
|
216
|
+
const maxCx = Math.floor((x + radius) / this.cellSize);
|
|
217
|
+
const minCy = Math.floor((y - radius) / this.cellSize);
|
|
218
|
+
const maxCy = Math.floor((y + radius) / this.cellSize);
|
|
219
|
+
let count = 0;
|
|
220
|
+
for (let cx = minCx; cx <= maxCx; cx++) {
|
|
221
|
+
for (let cy = minCy; cy <= maxCy; cy++) {
|
|
222
|
+
const cell = this.cells.get(this.key(cx, cy));
|
|
223
|
+
if (!cell) continue;
|
|
224
|
+
for (const p of cell) {
|
|
225
|
+
const dx = x - p.x;
|
|
226
|
+
const dy = y - p.y;
|
|
227
|
+
if (dx * dx + dy * dy < r2) count++;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return count;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Find nearest item to (x, y) */
|
|
235
|
+
findNearest(x: number, y: number, searchRadius: number): { x: number; y: number; size: number } | null {
|
|
236
|
+
const minCx = Math.floor((x - searchRadius) / this.cellSize);
|
|
237
|
+
const maxCx = Math.floor((x + searchRadius) / this.cellSize);
|
|
238
|
+
const minCy = Math.floor((y - searchRadius) / this.cellSize);
|
|
239
|
+
const maxCy = Math.floor((y + searchRadius) / this.cellSize);
|
|
240
|
+
let nearest: { x: number; y: number; size: number } | null = null;
|
|
241
|
+
let bestDist2 = Infinity;
|
|
242
|
+
for (let cx = minCx; cx <= maxCx; cx++) {
|
|
243
|
+
for (let cy = minCy; cy <= maxCy; cy++) {
|
|
244
|
+
const cell = this.cells.get(this.key(cx, cy));
|
|
245
|
+
if (!cell) continue;
|
|
246
|
+
for (const p of cell) {
|
|
247
|
+
const dx = x - p.x;
|
|
248
|
+
const dy = y - p.y;
|
|
249
|
+
const d2 = dx * dx + dy * dy;
|
|
250
|
+
if (d2 > 0 && d2 < bestDist2) {
|
|
251
|
+
bestDist2 = d2;
|
|
252
|
+
nearest = p;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return nearest;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Helper: density check (legacy wrapper) ──────────────────────────
|
|
184
262
|
|
|
185
263
|
function localDensity(
|
|
186
264
|
x: number,
|
|
@@ -485,44 +563,45 @@ export function renderHashArt(
|
|
|
485
563
|
const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
|
|
486
564
|
|
|
487
565
|
if (bgPatternRoll < 0.2) {
|
|
488
|
-
// Dot grid
|
|
566
|
+
// Dot grid — batched into a single path
|
|
489
567
|
const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
|
|
490
568
|
const dotR = dotSpacing * 0.08;
|
|
491
569
|
ctx.globalAlpha = patternOpacity;
|
|
492
570
|
ctx.fillStyle = patternColor;
|
|
571
|
+
ctx.beginPath();
|
|
493
572
|
for (let px = 0; px < width; px += dotSpacing) {
|
|
494
573
|
for (let py = 0; py < height; py += dotSpacing) {
|
|
495
|
-
ctx.
|
|
574
|
+
ctx.moveTo(px + dotR, py);
|
|
496
575
|
ctx.arc(px, py, dotR, 0, Math.PI * 2);
|
|
497
|
-
ctx.fill();
|
|
498
576
|
}
|
|
499
577
|
}
|
|
578
|
+
ctx.fill();
|
|
500
579
|
} else if (bgPatternRoll < 0.4) {
|
|
501
|
-
// Diagonal lines
|
|
580
|
+
// Diagonal lines — batched into a single path
|
|
502
581
|
const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
|
|
503
582
|
ctx.globalAlpha = patternOpacity;
|
|
504
583
|
ctx.strokeStyle = patternColor;
|
|
505
584
|
ctx.lineWidth = 0.5 * scaleFactor;
|
|
506
585
|
const diag = Math.hypot(width, height);
|
|
586
|
+
ctx.beginPath();
|
|
507
587
|
for (let d = -diag; d < diag; d += lineSpacing) {
|
|
508
|
-
ctx.beginPath();
|
|
509
588
|
ctx.moveTo(d, 0);
|
|
510
589
|
ctx.lineTo(d + height, height);
|
|
511
|
-
ctx.stroke();
|
|
512
590
|
}
|
|
591
|
+
ctx.stroke();
|
|
513
592
|
} else {
|
|
514
|
-
// Tessellation — hexagonal grid
|
|
593
|
+
// Tessellation — hexagonal grid, batched into a single path
|
|
515
594
|
const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
|
|
516
595
|
const tessH = tessSize * Math.sqrt(3);
|
|
517
596
|
ctx.globalAlpha = patternOpacity * 0.7;
|
|
518
597
|
ctx.strokeStyle = patternColor;
|
|
519
598
|
ctx.lineWidth = 0.4 * scaleFactor;
|
|
599
|
+
ctx.beginPath();
|
|
520
600
|
for (let row = 0; row * tessH < height + tessH; row++) {
|
|
521
601
|
const offsetX = (row % 2) * tessSize * 0.75;
|
|
522
602
|
for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
|
|
523
603
|
const hx = col * tessSize * 1.5 + offsetX;
|
|
524
604
|
const hy = row * tessH;
|
|
525
|
-
ctx.beginPath();
|
|
526
605
|
for (let s = 0; s < 6; s++) {
|
|
527
606
|
const angle = (Math.PI / 3) * s - Math.PI / 6;
|
|
528
607
|
const vx = hx + Math.cos(angle) * tessSize * 0.5;
|
|
@@ -531,17 +610,18 @@ export function renderHashArt(
|
|
|
531
610
|
else ctx.lineTo(vx, vy);
|
|
532
611
|
}
|
|
533
612
|
ctx.closePath();
|
|
534
|
-
ctx.stroke();
|
|
535
613
|
}
|
|
536
614
|
}
|
|
615
|
+
ctx.stroke();
|
|
537
616
|
}
|
|
538
617
|
ctx.restore();
|
|
539
618
|
}
|
|
540
619
|
ctx.globalCompositeOperation = "source-over";
|
|
541
620
|
|
|
542
|
-
// ── 2. Composition mode
|
|
543
|
-
const compositionMode =
|
|
544
|
-
|
|
621
|
+
// ── 2. Composition mode — archetype-aware selection ──────────────
|
|
622
|
+
const compositionMode: CompositionMode = rng() < 0.7
|
|
623
|
+
? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
|
|
624
|
+
: ALL_COMPOSITION_MODES[Math.floor(rng() * ALL_COMPOSITION_MODES.length)];
|
|
545
625
|
|
|
546
626
|
// ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ──────
|
|
547
627
|
type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad";
|
|
@@ -551,7 +631,7 @@ export function renderHashArt(
|
|
|
551
631
|
symRoll < 0.20 ? "bilateral-y" :
|
|
552
632
|
symRoll < 0.25 ? "quad" : "none";
|
|
553
633
|
|
|
554
|
-
// ── 3. Focal points + void zones
|
|
634
|
+
// ── 3. Focal points + void zones (archetype-aware) ───────────────
|
|
555
635
|
const THIRDS_POINTS = [
|
|
556
636
|
{ x: 1 / 3, y: 1 / 3 },
|
|
557
637
|
{ x: 2 / 3, y: 1 / 3 },
|
|
@@ -577,14 +657,30 @@ export function renderHashArt(
|
|
|
577
657
|
}
|
|
578
658
|
}
|
|
579
659
|
|
|
580
|
-
|
|
660
|
+
// Archetype-aware void zones: dense archetypes get fewer/no voids,
|
|
661
|
+
// minimal archetypes get golden-ratio positioned voids
|
|
662
|
+
const PHI = (1 + Math.sqrt(5)) / 2;
|
|
663
|
+
const isMinimalArchetype = archetype.gridSize <= 3;
|
|
664
|
+
const isDenseArchetype = archetype.gridSize >= 8;
|
|
665
|
+
const numVoids = isDenseArchetype ? 0 : (Math.floor(rng() * 2) + 1);
|
|
581
666
|
const voidZones: Array<{ x: number; y: number; radius: number }> = [];
|
|
582
667
|
for (let v = 0; v < numVoids; v++) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
668
|
+
if (isMinimalArchetype) {
|
|
669
|
+
// Place voids at golden-ratio positions for intentional negative space
|
|
670
|
+
const gx = (v === 0) ? 1 / PHI : 1 - 1 / PHI;
|
|
671
|
+
const gy = (v === 0) ? 1 - 1 / PHI : 1 / PHI;
|
|
672
|
+
voidZones.push({
|
|
673
|
+
x: width * (gx + (rng() - 0.5) * 0.05),
|
|
674
|
+
y: height * (gy + (rng() - 0.5) * 0.05),
|
|
675
|
+
radius: Math.min(width, height) * (0.08 + rng() * 0.08),
|
|
676
|
+
});
|
|
677
|
+
} else {
|
|
678
|
+
voidZones.push({
|
|
679
|
+
x: width * (0.15 + rng() * 0.7),
|
|
680
|
+
y: height * (0.15 + rng() * 0.7),
|
|
681
|
+
radius: Math.min(width, height) * (0.06 + rng() * 0.1),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
588
684
|
}
|
|
589
685
|
|
|
590
686
|
function applyFocalBias(rx: number, ry: number): [number, number] {
|
|
@@ -643,21 +739,35 @@ export function renderHashArt(
|
|
|
643
739
|
}
|
|
644
740
|
ctx.globalAlpha = 1;
|
|
645
741
|
|
|
646
|
-
// ── 4. Flow field
|
|
742
|
+
// ── 4. Flow field — simplex noise for organic variation ─────────
|
|
743
|
+
// Create a seeded simplex noise field (unique per hash)
|
|
744
|
+
const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
|
|
745
|
+
const simplexNoise = createSimplexNoise(noiseFieldRng);
|
|
746
|
+
const fbmNoise = createFBM(simplexNoise, 3, 2.0, 0.5);
|
|
647
747
|
const fieldAngleBase = rng() * Math.PI * 2;
|
|
648
|
-
const fieldFreq =
|
|
748
|
+
const fieldFreq = 1.5 + rng() * 2.5; // noise sampling frequency
|
|
649
749
|
|
|
650
750
|
function flowAngle(x: number, y: number): number {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
751
|
+
// Sample FBM noise at the position, scaled by frequency
|
|
752
|
+
const nx = (x / width) * fieldFreq;
|
|
753
|
+
const ny = (y / height) * fieldFreq;
|
|
754
|
+
return fieldAngleBase + fbmNoise(nx, ny) * Math.PI;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Noise-based size modulation — shapes in "high noise" areas get scaled
|
|
758
|
+
function noiseSizeModulation(x: number, y: number): number {
|
|
759
|
+
const n = simplexNoise((x / width) * 3, (y / height) * 3);
|
|
760
|
+
// Map [-1,1] to [0.7, 1.3] — subtle terrain-like size variation
|
|
761
|
+
return 0.7 + (n + 1) * 0.3;
|
|
656
762
|
}
|
|
657
763
|
|
|
658
764
|
// Track all placed shapes for density checks and connecting curves
|
|
659
765
|
const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
|
|
660
766
|
|
|
767
|
+
// Spatial grid for O(1) density and nearest-neighbor lookups
|
|
768
|
+
const densityCheckRadius = Math.min(width, height) * 0.08;
|
|
769
|
+
const spatialGrid = new SpatialGrid(densityCheckRadius);
|
|
770
|
+
|
|
661
771
|
// Hero avoidance radius — shapes near the hero orient toward it
|
|
662
772
|
let heroCenter: { x: number; y: number; size: number } | null = null;
|
|
663
773
|
|
|
@@ -698,15 +808,17 @@ export function renderHashArt(
|
|
|
698
808
|
gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
|
|
699
809
|
renderStyle: heroStyle,
|
|
700
810
|
rng,
|
|
811
|
+
lightAngle,
|
|
812
|
+
scaleFactor,
|
|
701
813
|
});
|
|
702
814
|
|
|
703
815
|
heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
|
|
704
816
|
shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
|
|
817
|
+
spatialGrid.insert({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
|
|
705
818
|
}
|
|
706
819
|
|
|
707
820
|
|
|
708
821
|
// ── 5. Shape layers ────────────────────────────────────────────
|
|
709
|
-
const densityCheckRadius = Math.min(width, height) * 0.08;
|
|
710
822
|
const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
|
|
711
823
|
|
|
712
824
|
for (let layer = 0; layer < layers; layer++) {
|
|
@@ -767,7 +879,7 @@ export function renderHashArt(
|
|
|
767
879
|
if (isInVoidZone(x, y, voidZones)) {
|
|
768
880
|
if (rng() < 0.85) continue;
|
|
769
881
|
}
|
|
770
|
-
if (
|
|
882
|
+
if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
|
|
771
883
|
if (rng() < 0.6) continue;
|
|
772
884
|
}
|
|
773
885
|
|
|
@@ -775,7 +887,7 @@ export function renderHashArt(
|
|
|
775
887
|
const sizeT = Math.pow(rng(), archetype.sizePower);
|
|
776
888
|
const size =
|
|
777
889
|
(adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
|
|
778
|
-
layerSizeScale;
|
|
890
|
+
layerSizeScale * noiseSizeModulation(x, y);
|
|
779
891
|
|
|
780
892
|
// Size fraction for affinity-aware shape selection
|
|
781
893
|
const sizeFraction = size / adjustedMaxSize;
|
|
@@ -856,17 +968,11 @@ export function renderHashArt(
|
|
|
856
968
|
let finalX = x;
|
|
857
969
|
let finalY = y;
|
|
858
970
|
if (shapePositions.length > 0 && rng() < 0.25) {
|
|
859
|
-
//
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
for (const sp of shapePositions) {
|
|
863
|
-
const d = Math.hypot(x - sp.x, y - sp.y);
|
|
864
|
-
if (d < nearestDist && d > 0) {
|
|
865
|
-
nearestDist = d;
|
|
866
|
-
nearestPos = sp;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
971
|
+
// Use spatial grid for O(1) nearest-neighbor lookup
|
|
972
|
+
const searchRadius = adjustedMaxSize * 3;
|
|
973
|
+
const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
|
|
869
974
|
if (nearestPos) {
|
|
975
|
+
const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
|
|
870
976
|
// Target distance: edges kissing (sum of half-sizes)
|
|
871
977
|
const targetDist = (size + nearestPos.size) * 0.5;
|
|
872
978
|
if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
|
|
@@ -900,6 +1006,8 @@ export function renderHashArt(
|
|
|
900
1006
|
gradientFillEnd: gradientEnd,
|
|
901
1007
|
renderStyle: finalRenderStyle,
|
|
902
1008
|
rng,
|
|
1009
|
+
lightAngle,
|
|
1010
|
+
scaleFactor,
|
|
903
1011
|
};
|
|
904
1012
|
|
|
905
1013
|
if (shouldMirror) {
|
|
@@ -933,6 +1041,7 @@ export function renderHashArt(
|
|
|
933
1041
|
}
|
|
934
1042
|
|
|
935
1043
|
shapePositions.push({ x: finalX, y: finalY, size, shape });
|
|
1044
|
+
spatialGrid.insert({ x: finalX, y: finalY, size, shape });
|
|
936
1045
|
|
|
937
1046
|
// ── 5c. Size echo — large shapes spawn trailing smaller copies ──
|
|
938
1047
|
if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
|
|
@@ -959,6 +1068,7 @@ export function renderHashArt(
|
|
|
959
1068
|
rng,
|
|
960
1069
|
});
|
|
961
1070
|
shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
|
|
1071
|
+
spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
|
|
962
1072
|
}
|
|
963
1073
|
}
|
|
964
1074
|
|
|
@@ -1042,6 +1152,50 @@ export function renderHashArt(
|
|
|
1042
1152
|
rng,
|
|
1043
1153
|
});
|
|
1044
1154
|
shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1155
|
+
spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ── 5f. Rhythm placement — deliberate geometric progressions ──
|
|
1160
|
+
// ~12% of medium-large shapes spawn a rhythmic sequence
|
|
1161
|
+
if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
|
|
1162
|
+
const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
|
|
1163
|
+
const rhythmAngle = rng() * Math.PI * 2;
|
|
1164
|
+
const rhythmSpacing = size * (0.8 + rng() * 0.6);
|
|
1165
|
+
const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
|
|
1166
|
+
const rhythmShape = shape; // same shape for visual rhythm
|
|
1167
|
+
|
|
1168
|
+
let rhythmSize = size * 0.6;
|
|
1169
|
+
for (let r = 0; r < rhythmCount; r++) {
|
|
1170
|
+
const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
|
|
1171
|
+
const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
|
|
1172
|
+
|
|
1173
|
+
if (rx < 0 || rx > width || ry < 0 || ry > height) break;
|
|
1174
|
+
if (isInVoidZone(rx, ry, voidZones)) break;
|
|
1175
|
+
|
|
1176
|
+
rhythmSize *= rhythmDecay;
|
|
1177
|
+
if (rhythmSize < adjustedMinSize) break;
|
|
1178
|
+
|
|
1179
|
+
const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
|
|
1180
|
+
ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
|
|
1181
|
+
|
|
1182
|
+
const rhythmFill = hexWithAlpha(
|
|
1183
|
+
jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
|
|
1184
|
+
fillAlpha * 0.7,
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
|
|
1188
|
+
fillColor: rhythmFill,
|
|
1189
|
+
strokeColor: hexWithAlpha(strokeColor, 0.5),
|
|
1190
|
+
strokeWidth: strokeWidth * 0.7,
|
|
1191
|
+
size: rhythmSize,
|
|
1192
|
+
rotation: rotation + (r + 1) * 12,
|
|
1193
|
+
proportionType: "GOLDEN_RATIO",
|
|
1194
|
+
renderStyle: finalRenderStyle,
|
|
1195
|
+
rng,
|
|
1196
|
+
});
|
|
1197
|
+
shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1198
|
+
spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1045
1199
|
}
|
|
1046
1200
|
}
|
|
1047
1201
|
}
|
|
@@ -1050,6 +1204,72 @@ export function renderHashArt(
|
|
|
1050
1204
|
// Reset blend mode for post-processing passes
|
|
1051
1205
|
ctx.globalCompositeOperation = "source-over";
|
|
1052
1206
|
|
|
1207
|
+
// ── 5g. Layered masking / cutout portals ───────────────────────
|
|
1208
|
+
// ~18% of images get 1-3 portal windows that paint over foreground
|
|
1209
|
+
// with a tinted background wash, creating a "peek through" effect.
|
|
1210
|
+
if (rng() < 0.18 && shapePositions.length > 3) {
|
|
1211
|
+
const portalCount = 1 + Math.floor(rng() * 2);
|
|
1212
|
+
for (let p = 0; p < portalCount; p++) {
|
|
1213
|
+
// Pick a position biased toward placed shapes
|
|
1214
|
+
const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
|
|
1215
|
+
const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1216
|
+
const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1217
|
+
const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
|
|
1218
|
+
|
|
1219
|
+
// Pick a portal shape from the palette
|
|
1220
|
+
const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
|
|
1221
|
+
const portalRotation = rng() * 360;
|
|
1222
|
+
const portalAlpha = 0.6 + rng() * 0.35;
|
|
1223
|
+
|
|
1224
|
+
ctx.save();
|
|
1225
|
+
ctx.translate(portalX, portalY);
|
|
1226
|
+
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1227
|
+
|
|
1228
|
+
// Step 1: Clip to the portal shape and fill with background wash
|
|
1229
|
+
ctx.beginPath();
|
|
1230
|
+
shapes[portalShape]?.(ctx, portalSize);
|
|
1231
|
+
ctx.clip();
|
|
1232
|
+
|
|
1233
|
+
// Fill the clipped region with a radial gradient from background colors
|
|
1234
|
+
const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
|
|
1235
|
+
const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
|
|
1236
|
+
portalGrad.addColorStop(0, portalColor);
|
|
1237
|
+
portalGrad.addColorStop(1, bgEnd);
|
|
1238
|
+
ctx.globalAlpha = portalAlpha;
|
|
1239
|
+
ctx.fillStyle = portalGrad;
|
|
1240
|
+
ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
|
|
1241
|
+
|
|
1242
|
+
// Optional: subtle inner texture — a few tiny dots inside the portal
|
|
1243
|
+
if (rng() < 0.5) {
|
|
1244
|
+
const dotCount = 3 + Math.floor(rng() * 5);
|
|
1245
|
+
ctx.globalAlpha = portalAlpha * 0.3;
|
|
1246
|
+
ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
|
|
1247
|
+
for (let d = 0; d < dotCount; d++) {
|
|
1248
|
+
const dx = (rng() - 0.5) * portalSize * 1.4;
|
|
1249
|
+
const dy = (rng() - 0.5) * portalSize * 1.4;
|
|
1250
|
+
const dr = (1 + rng() * 3) * scaleFactor;
|
|
1251
|
+
ctx.beginPath();
|
|
1252
|
+
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
|
|
1253
|
+
ctx.fill();
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
ctx.restore();
|
|
1258
|
+
|
|
1259
|
+
// Step 2: Draw a border ring around the portal (outside the clip)
|
|
1260
|
+
ctx.save();
|
|
1261
|
+
ctx.translate(portalX, portalY);
|
|
1262
|
+
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1263
|
+
ctx.globalAlpha = 0.15 + rng() * 0.2;
|
|
1264
|
+
ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
|
|
1265
|
+
ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
|
|
1266
|
+
ctx.beginPath();
|
|
1267
|
+
shapes[portalShape]?.(ctx, portalSize * 1.06);
|
|
1268
|
+
ctx.stroke();
|
|
1269
|
+
ctx.restore();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1053
1273
|
|
|
1054
1274
|
// ── 6. Flow-line pass — variable color, branching, pressure ────
|
|
1055
1275
|
const baseFlowLines = 6 + Math.floor(rng() * 10);
|
|
@@ -1080,6 +1300,13 @@ export function renderHashArt(
|
|
|
1080
1300
|
|
|
1081
1301
|
if (fx < 0 || fx > width || fy < 0 || fy > height) break;
|
|
1082
1302
|
|
|
1303
|
+
// Skip segments that pass through void zones
|
|
1304
|
+
if (isInVoidZone(fx, fy, voidZones)) {
|
|
1305
|
+
prevX = fx;
|
|
1306
|
+
prevY = fy;
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1083
1310
|
const t = s / steps;
|
|
1084
1311
|
// Taper + pressure
|
|
1085
1312
|
const taper = 1 - t * 0.8;
|
|
@@ -1186,32 +1413,65 @@ export function renderHashArt(
|
|
|
1186
1413
|
}
|
|
1187
1414
|
|
|
1188
1415
|
|
|
1189
|
-
// ── 7. Noise texture overlay
|
|
1416
|
+
// ── 7. Noise texture overlay — batched via ImageData ─────────────
|
|
1190
1417
|
const noiseRng = createRng(seedFromHash(gitHash, 777));
|
|
1191
1418
|
const noiseDensity = Math.floor((width * height) / 800);
|
|
1192
|
-
|
|
1193
|
-
const
|
|
1194
|
-
const
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1419
|
+
try {
|
|
1420
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
1421
|
+
const data = imageData.data;
|
|
1422
|
+
const pixelScale = Math.max(1, Math.round(scaleFactor));
|
|
1423
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1424
|
+
const nx = Math.floor(noiseRng() * width);
|
|
1425
|
+
const ny = Math.floor(noiseRng() * height);
|
|
1426
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1427
|
+
const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
|
|
1428
|
+
// Write a small block of pixels for scale
|
|
1429
|
+
for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
|
|
1430
|
+
for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
|
|
1431
|
+
const idx = ((ny + dy) * width + (nx + dx)) * 4;
|
|
1432
|
+
// Alpha-blend the noise dot onto existing pixel data
|
|
1433
|
+
const srcA = alpha / 255;
|
|
1434
|
+
const invA = 1 - srcA;
|
|
1435
|
+
data[idx] = Math.round(data[idx] * invA + brightness * srcA);
|
|
1436
|
+
data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
|
|
1437
|
+
data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
|
|
1438
|
+
// Keep existing alpha
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
ctx.putImageData(imageData, 0, 0);
|
|
1443
|
+
} catch {
|
|
1444
|
+
// Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
|
|
1445
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1446
|
+
const nx = noiseRng() * width;
|
|
1447
|
+
const ny = noiseRng() * height;
|
|
1448
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1449
|
+
const alpha = 0.01 + noiseRng() * 0.03;
|
|
1450
|
+
ctx.globalAlpha = alpha;
|
|
1451
|
+
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
1452
|
+
ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
|
|
1453
|
+
}
|
|
1200
1454
|
}
|
|
1201
1455
|
|
|
1202
1456
|
// ── 8. Vignette — darken edges to draw the eye inward ───────────
|
|
1203
1457
|
ctx.globalAlpha = 1;
|
|
1204
1458
|
const vignetteStrength = 0.25 + rng() * 0.2;
|
|
1205
1459
|
const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
|
|
1460
|
+
// Tint vignette based on background: warm sepia for light, cool blue for dark
|
|
1461
|
+
const isLightBg = bgLum > 0.5;
|
|
1462
|
+
const vignetteColor = isLightBg
|
|
1463
|
+
? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
|
|
1464
|
+
: `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
|
|
1206
1465
|
vigGrad.addColorStop(0, "rgba(0,0,0,0)");
|
|
1207
1466
|
vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
|
|
1208
|
-
vigGrad.addColorStop(1,
|
|
1467
|
+
vigGrad.addColorStop(1, vignetteColor);
|
|
1209
1468
|
ctx.fillStyle = vigGrad;
|
|
1210
1469
|
ctx.fillRect(0, 0, width, height);
|
|
1211
1470
|
|
|
1212
|
-
// ── 9. Organic connecting curves
|
|
1471
|
+
// ── 9. Organic connecting curves — proximity-aware ───────────────
|
|
1213
1472
|
if (shapePositions.length > 1) {
|
|
1214
1473
|
const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
|
|
1474
|
+
const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
|
|
1215
1475
|
ctx.lineWidth = 0.8 * scaleFactor;
|
|
1216
1476
|
|
|
1217
1477
|
for (let i = 0; i < numCurves; i++) {
|
|
@@ -1223,11 +1483,15 @@ export function renderHashArt(
|
|
|
1223
1483
|
const a = shapePositions[idxA];
|
|
1224
1484
|
const b = shapePositions[idxB];
|
|
1225
1485
|
|
|
1226
|
-
const mx = (a.x + b.x) / 2;
|
|
1227
|
-
const my = (a.y + b.y) / 2;
|
|
1228
1486
|
const dx = b.x - a.x;
|
|
1229
1487
|
const dy = b.y - a.y;
|
|
1230
1488
|
const dist = Math.hypot(dx, dy);
|
|
1489
|
+
|
|
1490
|
+
// Skip connections between distant shapes
|
|
1491
|
+
if (dist > maxCurveDist) continue;
|
|
1492
|
+
|
|
1493
|
+
const mx = (a.x + b.x) / 2;
|
|
1494
|
+
const my = (a.y + b.y) / 2;
|
|
1231
1495
|
const bulge = (rng() - 0.5) * dist * 0.4;
|
|
1232
1496
|
|
|
1233
1497
|
const cpx = mx + (-dy / (dist || 1)) * bulge;
|
|
@@ -1287,13 +1551,183 @@ export function renderHashArt(
|
|
|
1287
1551
|
ctx.globalCompositeOperation = "source-over";
|
|
1288
1552
|
}
|
|
1289
1553
|
|
|
1290
|
-
//
|
|
1554
|
+
// 10d. Gradient map — map luminance through a two-color gradient
|
|
1555
|
+
// Uses dominant→accent as the dark→light ramp for a cohesive tonal look
|
|
1556
|
+
if (rng() < 0.35) {
|
|
1557
|
+
const gmDark = colorHierarchy.dominant;
|
|
1558
|
+
const gmLight = colorHierarchy.accent;
|
|
1559
|
+
ctx.globalAlpha = 0.06 + rng() * 0.06; // very subtle: 6-12%
|
|
1560
|
+
ctx.globalCompositeOperation = "color";
|
|
1561
|
+
// Paint a linear gradient from dark color (top) to light color (bottom)
|
|
1562
|
+
const gmGrad = ctx.createLinearGradient(0, 0, 0, height);
|
|
1563
|
+
gmGrad.addColorStop(0, gmDark);
|
|
1564
|
+
gmGrad.addColorStop(1, gmLight);
|
|
1565
|
+
ctx.fillStyle = gmGrad;
|
|
1566
|
+
ctx.fillRect(0, 0, width, height);
|
|
1567
|
+
ctx.globalCompositeOperation = "source-over";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// ── 10e. Generative borders — archetype-driven decorative frames ──
|
|
1571
|
+
{
|
|
1572
|
+
ctx.save();
|
|
1573
|
+
ctx.globalAlpha = 1;
|
|
1574
|
+
ctx.globalCompositeOperation = "source-over";
|
|
1575
|
+
const borderRng = createRng(seedFromHash(gitHash, 314));
|
|
1576
|
+
const borderPad = Math.min(width, height) * 0.025;
|
|
1577
|
+
const borderColor = hexWithAlpha(colorHierarchy.accent, 0.2);
|
|
1578
|
+
const borderColorSolid = colorHierarchy.accent;
|
|
1579
|
+
const archName = archetype.name;
|
|
1580
|
+
|
|
1581
|
+
if (archName.includes("geometric") || archName.includes("op-art") || archName.includes("shattered")) {
|
|
1582
|
+
// Clean ruled lines with corner ornaments
|
|
1583
|
+
ctx.strokeStyle = borderColor;
|
|
1584
|
+
ctx.lineWidth = Math.max(1, 1.5 * scaleFactor);
|
|
1585
|
+
ctx.globalAlpha = 0.18 + borderRng() * 0.1;
|
|
1586
|
+
|
|
1587
|
+
// Outer rule
|
|
1588
|
+
ctx.strokeRect(borderPad, borderPad, width - borderPad * 2, height - borderPad * 2);
|
|
1589
|
+
// Inner rule (thinner, offset)
|
|
1590
|
+
const innerPad = borderPad * 1.8;
|
|
1591
|
+
ctx.lineWidth = Math.max(0.5, 0.8 * scaleFactor);
|
|
1592
|
+
ctx.globalAlpha *= 0.7;
|
|
1593
|
+
ctx.strokeRect(innerPad, innerPad, width - innerPad * 2, height - innerPad * 2);
|
|
1594
|
+
|
|
1595
|
+
// Corner ornaments — small squares at each corner
|
|
1596
|
+
const ornSize = borderPad * 0.6;
|
|
1597
|
+
ctx.fillStyle = hexWithAlpha(borderColorSolid, 0.12);
|
|
1598
|
+
const corners = [
|
|
1599
|
+
[borderPad, borderPad],
|
|
1600
|
+
[width - borderPad - ornSize, borderPad],
|
|
1601
|
+
[borderPad, height - borderPad - ornSize],
|
|
1602
|
+
[width - borderPad - ornSize, height - borderPad - ornSize],
|
|
1603
|
+
];
|
|
1604
|
+
for (const [cx2, cy2] of corners) {
|
|
1605
|
+
ctx.fillRect(cx2, cy2, ornSize, ornSize);
|
|
1606
|
+
// Diagonal cross inside ornament
|
|
1607
|
+
ctx.beginPath();
|
|
1608
|
+
ctx.moveTo(cx2, cy2);
|
|
1609
|
+
ctx.lineTo(cx2 + ornSize, cy2 + ornSize);
|
|
1610
|
+
ctx.moveTo(cx2 + ornSize, cy2);
|
|
1611
|
+
ctx.lineTo(cx2, cy2 + ornSize);
|
|
1612
|
+
ctx.stroke();
|
|
1613
|
+
}
|
|
1614
|
+
} else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
|
|
1615
|
+
// Vine tendrils — organic curving lines along edges
|
|
1616
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
|
|
1617
|
+
ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
|
|
1618
|
+
ctx.globalAlpha = 0.12 + borderRng() * 0.08;
|
|
1619
|
+
ctx.lineCap = "round";
|
|
1620
|
+
|
|
1621
|
+
const tendrilCount = 8 + Math.floor(borderRng() * 8);
|
|
1622
|
+
for (let t = 0; t < tendrilCount; t++) {
|
|
1623
|
+
// Start from a random edge point
|
|
1624
|
+
const edge = Math.floor(borderRng() * 4);
|
|
1625
|
+
let tx: number, ty: number;
|
|
1626
|
+
if (edge === 0) { tx = borderRng() * width; ty = borderPad; }
|
|
1627
|
+
else if (edge === 1) { tx = borderRng() * width; ty = height - borderPad; }
|
|
1628
|
+
else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
|
|
1629
|
+
else { tx = width - borderPad; ty = borderRng() * height; }
|
|
1630
|
+
|
|
1631
|
+
ctx.beginPath();
|
|
1632
|
+
ctx.moveTo(tx, ty);
|
|
1633
|
+
const segs = 3 + Math.floor(borderRng() * 4);
|
|
1634
|
+
for (let s = 0; s < segs; s++) {
|
|
1635
|
+
const inward = borderPad * (1 + borderRng() * 2);
|
|
1636
|
+
// Curl inward from edge
|
|
1637
|
+
const cpx2 = tx + (borderRng() - 0.5) * borderPad * 4;
|
|
1638
|
+
const cpy2 = ty + (edge < 2 ? (edge === 0 ? inward : -inward) : 0);
|
|
1639
|
+
const cpx3 = tx + (edge >= 2 ? (edge === 2 ? inward : -inward) : (borderRng() - 0.5) * borderPad * 3);
|
|
1640
|
+
const cpy3 = ty + (borderRng() - 0.5) * borderPad * 3;
|
|
1641
|
+
tx = cpx3;
|
|
1642
|
+
ty = cpy3;
|
|
1643
|
+
ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
|
|
1644
|
+
}
|
|
1645
|
+
ctx.stroke();
|
|
1646
|
+
|
|
1647
|
+
// Small leaf/dot at tendril end
|
|
1648
|
+
if (borderRng() < 0.6) {
|
|
1649
|
+
ctx.beginPath();
|
|
1650
|
+
ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
|
|
1651
|
+
ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
|
|
1652
|
+
ctx.fill();
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
} else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
|
|
1656
|
+
// Star-studded arcs along edges
|
|
1657
|
+
ctx.globalAlpha = 0.1 + borderRng() * 0.08;
|
|
1658
|
+
ctx.fillStyle = hexWithAlpha(colorHierarchy.accent, 0.2);
|
|
1659
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.accent, 0.12);
|
|
1660
|
+
ctx.lineWidth = Math.max(0.5, 0.7 * scaleFactor);
|
|
1661
|
+
|
|
1662
|
+
// Subtle arc along top and bottom
|
|
1663
|
+
ctx.beginPath();
|
|
1664
|
+
ctx.arc(cx, -height * 0.3, height * 0.6, 0.3, Math.PI - 0.3);
|
|
1665
|
+
ctx.stroke();
|
|
1666
|
+
ctx.beginPath();
|
|
1667
|
+
ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
|
|
1668
|
+
ctx.stroke();
|
|
1669
|
+
|
|
1670
|
+
// Scatter small stars along the border region
|
|
1671
|
+
const starCount = 15 + Math.floor(borderRng() * 15);
|
|
1672
|
+
for (let s = 0; s < starCount; s++) {
|
|
1673
|
+
const edge = Math.floor(borderRng() * 4);
|
|
1674
|
+
let sx: number, sy: number;
|
|
1675
|
+
if (edge === 0) { sx = borderRng() * width; sy = borderPad * (0.5 + borderRng()); }
|
|
1676
|
+
else if (edge === 1) { sx = borderRng() * width; sy = height - borderPad * (0.5 + borderRng()); }
|
|
1677
|
+
else if (edge === 2) { sx = borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
|
|
1678
|
+
else { sx = width - borderPad * (0.5 + borderRng()); sy = borderRng() * height; }
|
|
1679
|
+
|
|
1680
|
+
const starR = (1 + borderRng() * 2.5) * scaleFactor;
|
|
1681
|
+
// 4-point star
|
|
1682
|
+
ctx.beginPath();
|
|
1683
|
+
for (let p = 0; p < 8; p++) {
|
|
1684
|
+
const a = (p / 8) * Math.PI * 2;
|
|
1685
|
+
const r = p % 2 === 0 ? starR : starR * 0.4;
|
|
1686
|
+
const px2 = sx + Math.cos(a) * r;
|
|
1687
|
+
const py2 = sy + Math.sin(a) * r;
|
|
1688
|
+
if (p === 0) ctx.moveTo(px2, py2);
|
|
1689
|
+
else ctx.lineTo(px2, py2);
|
|
1690
|
+
}
|
|
1691
|
+
ctx.closePath();
|
|
1692
|
+
ctx.fill();
|
|
1693
|
+
}
|
|
1694
|
+
} else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
|
|
1695
|
+
// Thin single rule — understated elegance
|
|
1696
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
|
|
1697
|
+
ctx.lineWidth = Math.max(0.5, 0.6 * scaleFactor);
|
|
1698
|
+
ctx.globalAlpha = 0.1 + borderRng() * 0.06;
|
|
1699
|
+
ctx.strokeRect(borderPad * 1.5, borderPad * 1.5, width - borderPad * 3, height - borderPad * 3);
|
|
1700
|
+
}
|
|
1701
|
+
// Other archetypes: no border (intentional — not every image needs one)
|
|
1702
|
+
|
|
1703
|
+
ctx.restore();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ── 11. Signature mark — placed in the least-dense corner ──────
|
|
1291
1707
|
{
|
|
1292
1708
|
const sigRng = createRng(seedFromHash(gitHash, 42));
|
|
1293
1709
|
const sigSize = Math.min(width, height) * 0.025;
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1710
|
+
const sigMargin = sigSize * 2.5;
|
|
1711
|
+
|
|
1712
|
+
// Find the corner with the lowest local density
|
|
1713
|
+
const cornerCandidates = [
|
|
1714
|
+
{ x: sigMargin, y: sigMargin }, // top-left
|
|
1715
|
+
{ x: width - sigMargin, y: sigMargin }, // top-right
|
|
1716
|
+
{ x: sigMargin, y: height - sigMargin }, // bottom-left
|
|
1717
|
+
{ x: width - sigMargin, y: height - sigMargin }, // bottom-right
|
|
1718
|
+
];
|
|
1719
|
+
let bestCorner = cornerCandidates[3]; // default: bottom-right
|
|
1720
|
+
let minDensity = Infinity;
|
|
1721
|
+
for (const corner of cornerCandidates) {
|
|
1722
|
+
const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
|
|
1723
|
+
if (density < minDensity) {
|
|
1724
|
+
minDensity = density;
|
|
1725
|
+
bestCorner = corner;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const sigX = bestCorner.x;
|
|
1730
|
+
const sigY = bestCorner.y;
|
|
1297
1731
|
const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
|
|
1298
1732
|
const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
|
|
1299
1733
|
|