git-hash-art 0.10.1 → 0.12.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 +13 -3
- package/ALGORITHM.md +76 -24
- package/CHANGELOG.md +18 -0
- package/dist/browser.js +938 -251
- package/dist/browser.js.map +1 -1
- package/dist/main.js +940 -251
- package/dist/main.js.map +1 -1
- package/dist/module.js +940 -251
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/performance.test.ts +243 -0
- package/src/__tests__/phase-timing.test.ts +260 -0
- package/src/__tests__/profile-pipeline.test.ts +160 -0
- package/src/lib/archetypes.ts +29 -0
- package/src/lib/canvas/colors.ts +30 -11
- package/src/lib/canvas/draw.ts +147 -50
- package/src/lib/canvas/shapes/complex.ts +19 -10
- package/src/lib/canvas/shapes/sacred.ts +16 -17
- package/src/lib/render.ts +663 -204
package/src/lib/render.ts
CHANGED
|
@@ -38,8 +38,7 @@ import {
|
|
|
38
38
|
drawMirroredShape,
|
|
39
39
|
pickMirrorAxis,
|
|
40
40
|
pickBlendMode,
|
|
41
|
-
pickRenderStyle,
|
|
42
|
-
type RenderStyle
|
|
41
|
+
pickRenderStyle, type RenderStyle
|
|
43
42
|
} from "./canvas/draw";
|
|
44
43
|
import { shapes } from "./canvas/shapes";
|
|
45
44
|
import {
|
|
@@ -50,7 +49,41 @@ import {
|
|
|
50
49
|
} from "./canvas/shapes/affinity";
|
|
51
50
|
import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils";
|
|
52
51
|
import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
|
|
53
|
-
import { selectArchetype, type BackgroundStyle } from "./archetypes";
|
|
52
|
+
import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes";
|
|
53
|
+
|
|
54
|
+
// ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
|
|
55
|
+
// Based on benchmark measurements. Used by the complexity budget to
|
|
56
|
+
// cap total rendering work and downgrade expensive styles when needed.
|
|
57
|
+
const RENDER_STYLE_COST: Record<RenderStyle, number> = {
|
|
58
|
+
"fill-and-stroke": 1,
|
|
59
|
+
"fill-only": 0.5,
|
|
60
|
+
"stroke-only": 1,
|
|
61
|
+
"double-stroke": 1.5,
|
|
62
|
+
"dashed": 1,
|
|
63
|
+
"watercolor": 7,
|
|
64
|
+
"hatched": 3,
|
|
65
|
+
"incomplete": 1,
|
|
66
|
+
"stipple": 90,
|
|
67
|
+
"stencil": 2,
|
|
68
|
+
"noise-grain": 400,
|
|
69
|
+
"wood-grain": 10,
|
|
70
|
+
"marble-vein": 4,
|
|
71
|
+
"fabric-weave": 6,
|
|
72
|
+
"hand-drawn": 5,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function downgradeRenderStyle(style: RenderStyle): RenderStyle {
|
|
76
|
+
switch (style) {
|
|
77
|
+
case "noise-grain": return "hatched";
|
|
78
|
+
case "stipple": return "dashed";
|
|
79
|
+
case "wood-grain": return "hatched";
|
|
80
|
+
case "watercolor": return "fill-and-stroke";
|
|
81
|
+
case "fabric-weave": return "hatched";
|
|
82
|
+
case "hand-drawn": return "fill-and-stroke";
|
|
83
|
+
case "marble-vein": return "stroke-only";
|
|
84
|
+
default: return style;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
54
87
|
|
|
55
88
|
|
|
56
89
|
// ── Shape categories for weighted selection (legacy fallback) ───────
|
|
@@ -69,15 +102,7 @@ const SACRED_SHAPES = [
|
|
|
69
102
|
|
|
70
103
|
// ── Composition modes ───────────────────────────────────────────────
|
|
71
104
|
|
|
72
|
-
|
|
73
|
-
| "radial"
|
|
74
|
-
| "flow-field"
|
|
75
|
-
| "spiral"
|
|
76
|
-
| "grid-subdivision"
|
|
77
|
-
| "clustered"
|
|
78
|
-
| "golden-spiral";
|
|
79
|
-
|
|
80
|
-
const COMPOSITION_MODES: CompositionMode[] = [
|
|
105
|
+
const ALL_COMPOSITION_MODES: CompositionMode[] = [
|
|
81
106
|
"radial",
|
|
82
107
|
"flow-field",
|
|
83
108
|
"spiral",
|
|
@@ -193,7 +218,80 @@ function isInVoidZone(
|
|
|
193
218
|
return false;
|
|
194
219
|
}
|
|
195
220
|
|
|
196
|
-
// ──
|
|
221
|
+
// ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
|
|
222
|
+
|
|
223
|
+
class SpatialGrid {
|
|
224
|
+
private cells: Map<string, Array<{ x: number; y: number; size: number; shape: string }>>;
|
|
225
|
+
private cellSize: number;
|
|
226
|
+
|
|
227
|
+
constructor(cellSize: number) {
|
|
228
|
+
this.cells = new Map();
|
|
229
|
+
this.cellSize = cellSize;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private key(cx: number, cy: number): string {
|
|
233
|
+
return `${cx},${cy}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
insert(item: { x: number; y: number; size: number; shape: string }): void {
|
|
237
|
+
const cx = Math.floor(item.x / this.cellSize);
|
|
238
|
+
const cy = Math.floor(item.y / this.cellSize);
|
|
239
|
+
const k = this.key(cx, cy);
|
|
240
|
+
const cell = this.cells.get(k);
|
|
241
|
+
if (cell) cell.push(item);
|
|
242
|
+
else this.cells.set(k, [item]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Count items within radius of (x, y) */
|
|
246
|
+
countNear(x: number, y: number, radius: number): number {
|
|
247
|
+
const r2 = radius * radius;
|
|
248
|
+
const minCx = Math.floor((x - radius) / this.cellSize);
|
|
249
|
+
const maxCx = Math.floor((x + radius) / this.cellSize);
|
|
250
|
+
const minCy = Math.floor((y - radius) / this.cellSize);
|
|
251
|
+
const maxCy = Math.floor((y + radius) / this.cellSize);
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (let cx = minCx; cx <= maxCx; cx++) {
|
|
254
|
+
for (let cy = minCy; cy <= maxCy; cy++) {
|
|
255
|
+
const cell = this.cells.get(this.key(cx, cy));
|
|
256
|
+
if (!cell) continue;
|
|
257
|
+
for (const p of cell) {
|
|
258
|
+
const dx = x - p.x;
|
|
259
|
+
const dy = y - p.y;
|
|
260
|
+
if (dx * dx + dy * dy < r2) count++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return count;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Find nearest item to (x, y) */
|
|
268
|
+
findNearest(x: number, y: number, searchRadius: number): { x: number; y: number; size: number } | null {
|
|
269
|
+
const minCx = Math.floor((x - searchRadius) / this.cellSize);
|
|
270
|
+
const maxCx = Math.floor((x + searchRadius) / this.cellSize);
|
|
271
|
+
const minCy = Math.floor((y - searchRadius) / this.cellSize);
|
|
272
|
+
const maxCy = Math.floor((y + searchRadius) / this.cellSize);
|
|
273
|
+
let nearest: { x: number; y: number; size: number } | null = null;
|
|
274
|
+
let bestDist2 = Infinity;
|
|
275
|
+
for (let cx = minCx; cx <= maxCx; cx++) {
|
|
276
|
+
for (let cy = minCy; cy <= maxCy; cy++) {
|
|
277
|
+
const cell = this.cells.get(this.key(cx, cy));
|
|
278
|
+
if (!cell) continue;
|
|
279
|
+
for (const p of cell) {
|
|
280
|
+
const dx = x - p.x;
|
|
281
|
+
const dy = y - p.y;
|
|
282
|
+
const d2 = dx * dx + dy * dy;
|
|
283
|
+
if (d2 > 0 && d2 < bestDist2) {
|
|
284
|
+
bestDist2 = d2;
|
|
285
|
+
nearest = p;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return nearest;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Helper: density check (legacy wrapper) ──────────────────────────
|
|
197
295
|
|
|
198
296
|
function localDensity(
|
|
199
297
|
x: number,
|
|
@@ -498,44 +596,45 @@ export function renderHashArt(
|
|
|
498
596
|
const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
|
|
499
597
|
|
|
500
598
|
if (bgPatternRoll < 0.2) {
|
|
501
|
-
// Dot grid
|
|
599
|
+
// Dot grid — batched into a single path
|
|
502
600
|
const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
|
|
503
601
|
const dotR = dotSpacing * 0.08;
|
|
504
602
|
ctx.globalAlpha = patternOpacity;
|
|
505
603
|
ctx.fillStyle = patternColor;
|
|
604
|
+
ctx.beginPath();
|
|
506
605
|
for (let px = 0; px < width; px += dotSpacing) {
|
|
507
606
|
for (let py = 0; py < height; py += dotSpacing) {
|
|
508
|
-
ctx.
|
|
607
|
+
ctx.moveTo(px + dotR, py);
|
|
509
608
|
ctx.arc(px, py, dotR, 0, Math.PI * 2);
|
|
510
|
-
ctx.fill();
|
|
511
609
|
}
|
|
512
610
|
}
|
|
611
|
+
ctx.fill();
|
|
513
612
|
} else if (bgPatternRoll < 0.4) {
|
|
514
|
-
// Diagonal lines
|
|
613
|
+
// Diagonal lines — batched into a single path
|
|
515
614
|
const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
|
|
516
615
|
ctx.globalAlpha = patternOpacity;
|
|
517
616
|
ctx.strokeStyle = patternColor;
|
|
518
617
|
ctx.lineWidth = 0.5 * scaleFactor;
|
|
519
618
|
const diag = Math.hypot(width, height);
|
|
619
|
+
ctx.beginPath();
|
|
520
620
|
for (let d = -diag; d < diag; d += lineSpacing) {
|
|
521
|
-
ctx.beginPath();
|
|
522
621
|
ctx.moveTo(d, 0);
|
|
523
622
|
ctx.lineTo(d + height, height);
|
|
524
|
-
ctx.stroke();
|
|
525
623
|
}
|
|
624
|
+
ctx.stroke();
|
|
526
625
|
} else {
|
|
527
|
-
// Tessellation — hexagonal grid
|
|
626
|
+
// Tessellation — hexagonal grid, batched into a single path
|
|
528
627
|
const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
|
|
529
628
|
const tessH = tessSize * Math.sqrt(3);
|
|
530
629
|
ctx.globalAlpha = patternOpacity * 0.7;
|
|
531
630
|
ctx.strokeStyle = patternColor;
|
|
532
631
|
ctx.lineWidth = 0.4 * scaleFactor;
|
|
632
|
+
ctx.beginPath();
|
|
533
633
|
for (let row = 0; row * tessH < height + tessH; row++) {
|
|
534
634
|
const offsetX = (row % 2) * tessSize * 0.75;
|
|
535
635
|
for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
|
|
536
636
|
const hx = col * tessSize * 1.5 + offsetX;
|
|
537
637
|
const hy = row * tessH;
|
|
538
|
-
ctx.beginPath();
|
|
539
638
|
for (let s = 0; s < 6; s++) {
|
|
540
639
|
const angle = (Math.PI / 3) * s - Math.PI / 6;
|
|
541
640
|
const vx = hx + Math.cos(angle) * tessSize * 0.5;
|
|
@@ -544,17 +643,18 @@ export function renderHashArt(
|
|
|
544
643
|
else ctx.lineTo(vx, vy);
|
|
545
644
|
}
|
|
546
645
|
ctx.closePath();
|
|
547
|
-
ctx.stroke();
|
|
548
646
|
}
|
|
549
647
|
}
|
|
648
|
+
ctx.stroke();
|
|
550
649
|
}
|
|
551
650
|
ctx.restore();
|
|
552
651
|
}
|
|
553
652
|
ctx.globalCompositeOperation = "source-over";
|
|
554
653
|
|
|
555
|
-
// ── 2. Composition mode
|
|
556
|
-
const compositionMode =
|
|
557
|
-
|
|
654
|
+
// ── 2. Composition mode — archetype-aware selection ──────────────
|
|
655
|
+
const compositionMode: CompositionMode = rng() < 0.7
|
|
656
|
+
? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
|
|
657
|
+
: ALL_COMPOSITION_MODES[Math.floor(rng() * ALL_COMPOSITION_MODES.length)];
|
|
558
658
|
|
|
559
659
|
// ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ──────
|
|
560
660
|
type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad";
|
|
@@ -564,7 +664,7 @@ export function renderHashArt(
|
|
|
564
664
|
symRoll < 0.20 ? "bilateral-y" :
|
|
565
665
|
symRoll < 0.25 ? "quad" : "none";
|
|
566
666
|
|
|
567
|
-
// ── 3. Focal points + void zones
|
|
667
|
+
// ── 3. Focal points + void zones (archetype-aware) ───────────────
|
|
568
668
|
const THIRDS_POINTS = [
|
|
569
669
|
{ x: 1 / 3, y: 1 / 3 },
|
|
570
670
|
{ x: 2 / 3, y: 1 / 3 },
|
|
@@ -590,14 +690,30 @@ export function renderHashArt(
|
|
|
590
690
|
}
|
|
591
691
|
}
|
|
592
692
|
|
|
593
|
-
|
|
693
|
+
// Archetype-aware void zones: dense archetypes get fewer/no voids,
|
|
694
|
+
// minimal archetypes get golden-ratio positioned voids
|
|
695
|
+
const PHI = (1 + Math.sqrt(5)) / 2;
|
|
696
|
+
const isMinimalArchetype = archetype.gridSize <= 3;
|
|
697
|
+
const isDenseArchetype = archetype.gridSize >= 8;
|
|
698
|
+
const numVoids = isDenseArchetype ? 0 : (Math.floor(rng() * 2) + 1);
|
|
594
699
|
const voidZones: Array<{ x: number; y: number; radius: number }> = [];
|
|
595
700
|
for (let v = 0; v < numVoids; v++) {
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
701
|
+
if (isMinimalArchetype) {
|
|
702
|
+
// Place voids at golden-ratio positions for intentional negative space
|
|
703
|
+
const gx = (v === 0) ? 1 / PHI : 1 - 1 / PHI;
|
|
704
|
+
const gy = (v === 0) ? 1 - 1 / PHI : 1 / PHI;
|
|
705
|
+
voidZones.push({
|
|
706
|
+
x: width * (gx + (rng() - 0.5) * 0.05),
|
|
707
|
+
y: height * (gy + (rng() - 0.5) * 0.05),
|
|
708
|
+
radius: Math.min(width, height) * (0.08 + rng() * 0.08),
|
|
709
|
+
});
|
|
710
|
+
} else {
|
|
711
|
+
voidZones.push({
|
|
712
|
+
x: width * (0.15 + rng() * 0.7),
|
|
713
|
+
y: height * (0.15 + rng() * 0.7),
|
|
714
|
+
radius: Math.min(width, height) * (0.06 + rng() * 0.1),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
601
717
|
}
|
|
602
718
|
|
|
603
719
|
function applyFocalBias(rx: number, ry: number): [number, number] {
|
|
@@ -624,23 +740,27 @@ export function renderHashArt(
|
|
|
624
740
|
ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
|
|
625
741
|
ctx.stroke();
|
|
626
742
|
|
|
627
|
-
// ~50% chance: scatter tiny dots inside the void
|
|
743
|
+
// ~50% chance: scatter tiny dots inside the void — batched into single path
|
|
628
744
|
if (rng() < 0.5) {
|
|
629
745
|
const dotCount = 3 + Math.floor(rng() * 6);
|
|
630
746
|
ctx.globalAlpha = 0.06 + rng() * 0.04;
|
|
631
747
|
ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
|
|
748
|
+
ctx.beginPath();
|
|
632
749
|
for (let d = 0; d < dotCount; d++) {
|
|
633
750
|
const angle = rng() * Math.PI * 2;
|
|
634
751
|
const dist = rng() * zone.radius * 0.7;
|
|
635
752
|
const dotR = (1 + rng() * 3) * scaleFactor;
|
|
636
|
-
ctx.
|
|
753
|
+
ctx.moveTo(
|
|
754
|
+
zone.x + Math.cos(angle) * dist + dotR,
|
|
755
|
+
zone.y + Math.sin(angle) * dist,
|
|
756
|
+
);
|
|
637
757
|
ctx.arc(
|
|
638
758
|
zone.x + Math.cos(angle) * dist,
|
|
639
759
|
zone.y + Math.sin(angle) * dist,
|
|
640
760
|
dotR, 0, Math.PI * 2,
|
|
641
761
|
);
|
|
642
|
-
ctx.fill();
|
|
643
762
|
}
|
|
763
|
+
ctx.fill();
|
|
644
764
|
}
|
|
645
765
|
|
|
646
766
|
// ~30% chance: thin concentric ring inside
|
|
@@ -681,6 +801,10 @@ export function renderHashArt(
|
|
|
681
801
|
// Track all placed shapes for density checks and connecting curves
|
|
682
802
|
const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
|
|
683
803
|
|
|
804
|
+
// Spatial grid for O(1) density and nearest-neighbor lookups
|
|
805
|
+
const densityCheckRadius = Math.min(width, height) * 0.08;
|
|
806
|
+
const spatialGrid = new SpatialGrid(densityCheckRadius);
|
|
807
|
+
|
|
684
808
|
// Hero avoidance radius — shapes near the hero orient toward it
|
|
685
809
|
let heroCenter: { x: number; y: number; size: number } | null = null;
|
|
686
810
|
|
|
@@ -727,13 +851,35 @@ export function renderHashArt(
|
|
|
727
851
|
|
|
728
852
|
heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
|
|
729
853
|
shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
|
|
854
|
+
spatialGrid.insert({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
|
|
730
855
|
}
|
|
731
856
|
|
|
732
857
|
|
|
733
858
|
// ── 5. Shape layers ────────────────────────────────────────────
|
|
734
|
-
const densityCheckRadius = Math.min(width, height) * 0.08;
|
|
735
859
|
const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
|
|
736
860
|
|
|
861
|
+
// ── Complexity budget — caps total rendering work ──────────────
|
|
862
|
+
// Budget scales with pixel area so larger canvases get proportionally
|
|
863
|
+
// more headroom. The multiplier extras (glazing, echoes, nesting,
|
|
864
|
+
// constellations, rhythm) are gated behind the budget; when it runs
|
|
865
|
+
// low they are skipped. When it's exhausted, expensive render styles
|
|
866
|
+
// are downgraded to cheaper alternatives.
|
|
867
|
+
//
|
|
868
|
+
// RNG values are always consumed even when skipping, so the
|
|
869
|
+
// deterministic sequence for shapes that *do* render is preserved.
|
|
870
|
+
const pixelArea = width * height;
|
|
871
|
+
const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
|
|
872
|
+
let complexityBudget = (pixelArea / 1_000_000) * BUDGET_PER_MEGAPIXEL;
|
|
873
|
+
const totalBudget = complexityBudget;
|
|
874
|
+
const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
|
|
875
|
+
let extrasSpent = 0;
|
|
876
|
+
|
|
877
|
+
// Hard cap on clip-heavy render styles (stipple, noise-grain).
|
|
878
|
+
// These generate O(size²) fillRect calls per shape and dominate
|
|
879
|
+
// worst-case render time. Cap scales with pixel area.
|
|
880
|
+
const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1_000_000)));
|
|
881
|
+
let clipHeavyCount = 0;
|
|
882
|
+
|
|
737
883
|
for (let layer = 0; layer < layers; layer++) {
|
|
738
884
|
const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
|
|
739
885
|
const numShapes =
|
|
@@ -792,7 +938,7 @@ export function renderHashArt(
|
|
|
792
938
|
if (isInVoidZone(x, y, voidZones)) {
|
|
793
939
|
if (rng() < 0.85) continue;
|
|
794
940
|
}
|
|
795
|
-
if (
|
|
941
|
+
if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
|
|
796
942
|
if (rng() < 0.6) continue;
|
|
797
943
|
}
|
|
798
944
|
|
|
@@ -870,7 +1016,30 @@ export function renderHashArt(
|
|
|
870
1016
|
|
|
871
1017
|
// Organic edge jitter — applied via watercolor style on ~15% of shapes
|
|
872
1018
|
const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
|
|
873
|
-
|
|
1019
|
+
let finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
|
|
1020
|
+
|
|
1021
|
+
// Budget check: downgrade expensive styles proportionally —
|
|
1022
|
+
// the more expensive the style, the earlier it gets downgraded.
|
|
1023
|
+
// noise-grain (400) downgrades when budget < 20% remaining,
|
|
1024
|
+
// stipple (90) when < 82%, wood-grain (10) when < 98%.
|
|
1025
|
+
let styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
|
|
1026
|
+
if (styleCost > 3) {
|
|
1027
|
+
const downgradeThreshold = Math.min(0.85, styleCost / 500);
|
|
1028
|
+
if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
|
|
1029
|
+
finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
|
|
1030
|
+
styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// Hard cap: clip-heavy styles (stipple, noise-grain) are limited
|
|
1034
|
+
// to MAX_CLIP_HEAVY_SHAPES total across the entire render.
|
|
1035
|
+
if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") &&
|
|
1036
|
+
clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
|
|
1037
|
+
finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
|
|
1038
|
+
styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
|
|
1039
|
+
}
|
|
1040
|
+
if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") {
|
|
1041
|
+
clipHeavyCount++;
|
|
1042
|
+
}
|
|
874
1043
|
|
|
875
1044
|
// Consistent light direction — subtle shadow offset
|
|
876
1045
|
const shadowDist = hasGlow ? 0 : (size * 0.02);
|
|
@@ -881,17 +1050,11 @@ export function renderHashArt(
|
|
|
881
1050
|
let finalX = x;
|
|
882
1051
|
let finalY = y;
|
|
883
1052
|
if (shapePositions.length > 0 && rng() < 0.25) {
|
|
884
|
-
//
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
for (const sp of shapePositions) {
|
|
888
|
-
const d = Math.hypot(x - sp.x, y - sp.y);
|
|
889
|
-
if (d < nearestDist && d > 0) {
|
|
890
|
-
nearestDist = d;
|
|
891
|
-
nearestPos = sp;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
1053
|
+
// Use spatial grid for O(1) nearest-neighbor lookup
|
|
1054
|
+
const searchRadius = adjustedMaxSize * 3;
|
|
1055
|
+
const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
|
|
894
1056
|
if (nearestPos) {
|
|
1057
|
+
const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
|
|
895
1058
|
// Target distance: edges kissing (sum of half-sizes)
|
|
896
1059
|
const targetDist = (size + nearestPos.size) * 0.5;
|
|
897
1060
|
if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
|
|
@@ -935,58 +1098,73 @@ export function renderHashArt(
|
|
|
935
1098
|
mirrorAxis: mirrorAxis!,
|
|
936
1099
|
mirrorGap: size * (0.1 + rng() * 0.3),
|
|
937
1100
|
});
|
|
1101
|
+
complexityBudget -= styleCost * 2; // mirrored = 2 shapes
|
|
938
1102
|
} else {
|
|
939
1103
|
enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
|
|
1104
|
+
complexityBudget -= styleCost;
|
|
940
1105
|
}
|
|
941
1106
|
|
|
1107
|
+
// ── Extras budget gate — skip multiplier sections when over budget ──
|
|
1108
|
+
const extrasAllowed = extrasSpent < budgetForExtras;
|
|
1109
|
+
|
|
942
1110
|
// ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
|
|
943
1111
|
if (rng() < 0.2 && size > adjustedMinSize * 2) {
|
|
944
1112
|
const glazePasses = 2 + Math.floor(rng() * 2);
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1113
|
+
if (extrasAllowed) {
|
|
1114
|
+
for (let g = 0; g < glazePasses; g++) {
|
|
1115
|
+
const glazeScale = 1 - (g + 1) * 0.12;
|
|
1116
|
+
const glazeAlpha = 0.08 + g * 0.04;
|
|
1117
|
+
ctx.globalAlpha = glazeAlpha;
|
|
1118
|
+
enhanceShapeGeneration(ctx, shape, finalX, finalY, {
|
|
1119
|
+
fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
|
|
1120
|
+
strokeColor: "rgba(0,0,0,0)",
|
|
1121
|
+
strokeWidth: 0,
|
|
1122
|
+
size: size * glazeScale,
|
|
1123
|
+
rotation,
|
|
1124
|
+
proportionType: "GOLDEN_RATIO",
|
|
1125
|
+
renderStyle: "fill-only",
|
|
1126
|
+
rng,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
extrasSpent += glazePasses;
|
|
959
1130
|
}
|
|
1131
|
+
// RNG consumed by glazePasses calculation above regardless
|
|
960
1132
|
}
|
|
961
1133
|
|
|
962
1134
|
shapePositions.push({ x: finalX, y: finalY, size, shape });
|
|
1135
|
+
spatialGrid.insert({ x: finalX, y: finalY, size, shape });
|
|
963
1136
|
|
|
964
1137
|
// ── 5c. Size echo — large shapes spawn trailing smaller copies ──
|
|
965
1138
|
if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
|
|
966
1139
|
const echoCount = 2 + Math.floor(rng() * 2);
|
|
967
1140
|
const echoAngle = rng() * Math.PI * 2;
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1141
|
+
if (extrasAllowed) {
|
|
1142
|
+
for (let e = 0; e < echoCount; e++) {
|
|
1143
|
+
const echoScale = 0.3 - e * 0.08;
|
|
1144
|
+
const echoDist = size * (0.6 + e * 0.4);
|
|
1145
|
+
const echoX = finalX + Math.cos(echoAngle) * echoDist;
|
|
1146
|
+
const echoY = finalY + Math.sin(echoAngle) * echoDist;
|
|
1147
|
+
const echoSize = size * Math.max(0.1, echoScale);
|
|
1148
|
+
|
|
1149
|
+
if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
|
|
1150
|
+
|
|
1151
|
+
ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
|
|
1152
|
+
enhanceShapeGeneration(ctx, shape, echoX, echoY, {
|
|
1153
|
+
fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
|
|
1154
|
+
strokeColor: hexWithAlpha(strokeColor, 0.4),
|
|
1155
|
+
strokeWidth: strokeWidth * 0.6,
|
|
1156
|
+
size: echoSize,
|
|
1157
|
+
rotation: rotation + (e + 1) * 15,
|
|
1158
|
+
proportionType: "GOLDEN_RATIO",
|
|
1159
|
+
renderStyle: finalRenderStyle,
|
|
1160
|
+
rng,
|
|
1161
|
+
});
|
|
1162
|
+
shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
|
|
1163
|
+
spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
|
|
1164
|
+
}
|
|
1165
|
+
extrasSpent += echoCount * styleCost;
|
|
989
1166
|
}
|
|
1167
|
+
// RNG for echoCount + echoAngle consumed above regardless
|
|
990
1168
|
}
|
|
991
1169
|
|
|
992
1170
|
// ── 5d. Recursive nesting ──────────────────────────────────
|
|
@@ -995,36 +1173,51 @@ export function renderHashArt(
|
|
|
995
1173
|
const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
|
|
996
1174
|
if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
|
|
997
1175
|
const innerCount = 1 + Math.floor(rng() * 3);
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1176
|
+
if (extrasAllowed) {
|
|
1177
|
+
for (let n = 0; n < innerCount; n++) {
|
|
1178
|
+
// Pick inner shape from palette affinities
|
|
1179
|
+
const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
|
|
1180
|
+
const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
|
|
1181
|
+
const innerSize = size * (0.15 + rng() * 0.25);
|
|
1182
|
+
const innerOffX = (rng() - 0.5) * size * 0.4;
|
|
1183
|
+
const innerOffY = (rng() - 0.5) * size * 0.4;
|
|
1184
|
+
const innerRot = rng() * 360;
|
|
1185
|
+
const innerFill = hexWithAlpha(
|
|
1186
|
+
jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
|
|
1187
|
+
0.3 + rng() * 0.4,
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
let innerStyle = pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle;
|
|
1191
|
+
// Apply clip-heavy cap to nested shapes too
|
|
1192
|
+
if ((innerStyle === "stipple" || innerStyle === "noise-grain") &&
|
|
1193
|
+
clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
|
|
1194
|
+
innerStyle = downgradeRenderStyle(innerStyle);
|
|
1195
|
+
}
|
|
1196
|
+
if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
|
|
1197
|
+
ctx.globalAlpha = layerOpacity * 0.7;
|
|
1198
|
+
enhanceShapeGeneration(
|
|
1199
|
+
ctx,
|
|
1200
|
+
innerShape,
|
|
1201
|
+
finalX + innerOffX,
|
|
1202
|
+
finalY + innerOffY,
|
|
1203
|
+
{
|
|
1204
|
+
fillColor: innerFill,
|
|
1205
|
+
strokeColor: hexWithAlpha(strokeColor, 0.5),
|
|
1206
|
+
strokeWidth: strokeWidth * 0.6,
|
|
1207
|
+
size: innerSize,
|
|
1208
|
+
rotation: innerRot,
|
|
1209
|
+
proportionType: "GOLDEN_RATIO",
|
|
1210
|
+
renderStyle: innerStyle,
|
|
1211
|
+
rng,
|
|
1212
|
+
},
|
|
1213
|
+
);
|
|
1214
|
+
extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
// Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
|
|
1218
|
+
for (let n = 0; n < innerCount; n++) {
|
|
1219
|
+
rng(); rng(); rng(); rng(); rng(); rng(); rng(); rng();
|
|
1220
|
+
}
|
|
1028
1221
|
}
|
|
1029
1222
|
}
|
|
1030
1223
|
|
|
@@ -1034,41 +1227,109 @@ export function renderHashArt(
|
|
|
1034
1227
|
const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
|
|
1035
1228
|
const members = constellation.build(rng, size);
|
|
1036
1229
|
const groupRotation = rng() * Math.PI * 2;
|
|
1037
|
-
const cosR = Math.cos(groupRotation);
|
|
1038
|
-
const sinR = Math.sin(groupRotation);
|
|
1039
|
-
|
|
1040
|
-
for (const member of members) {
|
|
1041
|
-
// Rotate the group offset by the group rotation
|
|
1042
|
-
const mx = finalX + member.dx * cosR - member.dy * sinR;
|
|
1043
|
-
const my = finalY + member.dx * sinR + member.dy * cosR;
|
|
1044
1230
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1231
|
+
if (extrasAllowed) {
|
|
1232
|
+
const cosR = Math.cos(groupRotation);
|
|
1233
|
+
const sinR = Math.sin(groupRotation);
|
|
1234
|
+
|
|
1235
|
+
for (const member of members) {
|
|
1236
|
+
// Rotate the group offset by the group rotation
|
|
1237
|
+
const mx = finalX + member.dx * cosR - member.dy * sinR;
|
|
1238
|
+
const my = finalY + member.dx * sinR + member.dy * cosR;
|
|
1239
|
+
|
|
1240
|
+
if (mx < 0 || mx > width || my < 0 || my > height) continue;
|
|
1241
|
+
|
|
1242
|
+
const memberFill = hexWithAlpha(
|
|
1243
|
+
jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
|
|
1244
|
+
fillAlpha * 0.8,
|
|
1245
|
+
);
|
|
1246
|
+
const memberStroke = enforceContrast(
|
|
1247
|
+
jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
ctx.globalAlpha = layerOpacity * 0.6;
|
|
1251
|
+
// Use the member's shape if available, otherwise fall back to palette
|
|
1252
|
+
const memberShape = shapeNames.includes(member.shape)
|
|
1253
|
+
? member.shape
|
|
1254
|
+
: pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
|
|
1255
|
+
|
|
1256
|
+
let memberStyle = pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle;
|
|
1257
|
+
// Apply clip-heavy cap to constellation members too
|
|
1258
|
+
if ((memberStyle === "stipple" || memberStyle === "noise-grain") &&
|
|
1259
|
+
clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
|
|
1260
|
+
memberStyle = downgradeRenderStyle(memberStyle);
|
|
1261
|
+
}
|
|
1262
|
+
if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
|
|
1263
|
+
enhanceShapeGeneration(ctx, memberShape, mx, my, {
|
|
1264
|
+
fillColor: memberFill,
|
|
1265
|
+
strokeColor: memberStroke,
|
|
1266
|
+
strokeWidth: strokeWidth * 0.7,
|
|
1267
|
+
size: member.size,
|
|
1268
|
+
rotation: member.rotation + (groupRotation * 180) / Math.PI,
|
|
1269
|
+
proportionType: "GOLDEN_RATIO",
|
|
1270
|
+
renderStyle: memberStyle,
|
|
1271
|
+
rng,
|
|
1272
|
+
});
|
|
1273
|
+
shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1274
|
+
spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1275
|
+
extrasSpent += RENDER_STYLE_COST[memberStyle] ?? 1;
|
|
1276
|
+
}
|
|
1277
|
+
} else {
|
|
1278
|
+
// Drain RNG — each member consumes ~6 rng calls for colors/style
|
|
1279
|
+
for (let m = 0; m < members.length; m++) {
|
|
1280
|
+
rng(); rng(); rng(); rng(); rng(); rng();
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1054
1284
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1285
|
+
// ── 5f. Rhythm placement — deliberate geometric progressions ──
|
|
1286
|
+
// ~12% of medium-large shapes spawn a rhythmic sequence
|
|
1287
|
+
if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
|
|
1288
|
+
const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
|
|
1289
|
+
const rhythmAngle = rng() * Math.PI * 2;
|
|
1290
|
+
const rhythmSpacing = size * (0.8 + rng() * 0.6);
|
|
1291
|
+
const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
|
|
1292
|
+
const rhythmShape = shape; // same shape for visual rhythm
|
|
1293
|
+
|
|
1294
|
+
if (extrasAllowed) {
|
|
1295
|
+
let rhythmSize = size * 0.6;
|
|
1296
|
+
for (let r = 0; r < rhythmCount; r++) {
|
|
1297
|
+
const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
|
|
1298
|
+
const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
|
|
1299
|
+
|
|
1300
|
+
if (rx < 0 || rx > width || ry < 0 || ry > height) break;
|
|
1301
|
+
if (isInVoidZone(rx, ry, voidZones)) break;
|
|
1302
|
+
|
|
1303
|
+
rhythmSize *= rhythmDecay;
|
|
1304
|
+
if (rhythmSize < adjustedMinSize) break;
|
|
1305
|
+
|
|
1306
|
+
const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
|
|
1307
|
+
ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
|
|
1308
|
+
|
|
1309
|
+
const rhythmFill = hexWithAlpha(
|
|
1310
|
+
jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
|
|
1311
|
+
fillAlpha * 0.7,
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
|
|
1315
|
+
fillColor: rhythmFill,
|
|
1316
|
+
strokeColor: hexWithAlpha(strokeColor, 0.5),
|
|
1317
|
+
strokeWidth: strokeWidth * 0.7,
|
|
1318
|
+
size: rhythmSize,
|
|
1319
|
+
rotation: rotation + (r + 1) * 12,
|
|
1320
|
+
proportionType: "GOLDEN_RATIO",
|
|
1321
|
+
renderStyle: finalRenderStyle,
|
|
1322
|
+
rng,
|
|
1323
|
+
});
|
|
1324
|
+
shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1325
|
+
spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1326
|
+
}
|
|
1327
|
+
extrasSpent += rhythmCount * styleCost;
|
|
1328
|
+
} else {
|
|
1329
|
+
// Drain RNG — each rhythm step consumes ~3 rng calls for colors
|
|
1330
|
+
for (let r = 0; r < rhythmCount; r++) {
|
|
1331
|
+
rng(); rng(); rng();
|
|
1332
|
+
}
|
|
1072
1333
|
}
|
|
1073
1334
|
}
|
|
1074
1335
|
}
|
|
@@ -1077,7 +1338,7 @@ export function renderHashArt(
|
|
|
1077
1338
|
// Reset blend mode for post-processing passes
|
|
1078
1339
|
ctx.globalCompositeOperation = "source-over";
|
|
1079
1340
|
|
|
1080
|
-
// ──
|
|
1341
|
+
// ── 5g. Layered masking / cutout portals ───────────────────────
|
|
1081
1342
|
// ~18% of images get 1-3 portal windows that paint over foreground
|
|
1082
1343
|
// with a tinted background wash, creating a "peek through" effect.
|
|
1083
1344
|
if (rng() < 0.18 && shapePositions.length > 3) {
|
|
@@ -1145,15 +1406,30 @@ export function renderHashArt(
|
|
|
1145
1406
|
|
|
1146
1407
|
|
|
1147
1408
|
// ── 6. Flow-line pass — variable color, branching, pressure ────
|
|
1409
|
+
// Optimized: collect all segments into width-quantized buckets, then
|
|
1410
|
+
// render each bucket as a single batched path. This reduces
|
|
1411
|
+
// beginPath/stroke calls from O(segments) to O(buckets).
|
|
1148
1412
|
const baseFlowLines = 6 + Math.floor(rng() * 10);
|
|
1149
1413
|
const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
|
|
1150
1414
|
|
|
1415
|
+
// Width buckets — 6 buckets cover the taper×pressure range
|
|
1416
|
+
const FLOW_WIDTH_BUCKETS = 6;
|
|
1417
|
+
type FlowSeg = { x1: number; y1: number; x2: number; y2: number; color: string; alpha: number };
|
|
1418
|
+
const flowBuckets: Array<FlowSeg[]> = [];
|
|
1419
|
+
for (let b = 0; b < FLOW_WIDTH_BUCKETS; b++) flowBuckets.push([]);
|
|
1420
|
+
// Track the representative width for each bucket
|
|
1421
|
+
const flowBucketWidths: number[] = new Array(FLOW_WIDTH_BUCKETS);
|
|
1422
|
+
|
|
1423
|
+
// Pre-compute max possible width for bucket assignment
|
|
1424
|
+
let globalMaxFlowWidth = 0;
|
|
1425
|
+
|
|
1151
1426
|
for (let i = 0; i < numFlowLines; i++) {
|
|
1152
1427
|
let fx = rng() * width;
|
|
1153
1428
|
let fy = rng() * height;
|
|
1154
1429
|
const steps = 30 + Math.floor(rng() * 40);
|
|
1155
1430
|
const stepLen = (3 + rng() * 5) * scaleFactor;
|
|
1156
1431
|
const startWidth = (1 + rng() * 3) * scaleFactor;
|
|
1432
|
+
if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
|
|
1157
1433
|
|
|
1158
1434
|
// Variable color: interpolate between two hierarchy colors along the stroke
|
|
1159
1435
|
const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
|
|
@@ -1173,24 +1449,29 @@ export function renderHashArt(
|
|
|
1173
1449
|
|
|
1174
1450
|
if (fx < 0 || fx > width || fy < 0 || fy > height) break;
|
|
1175
1451
|
|
|
1452
|
+
// Skip segments that pass through void zones
|
|
1453
|
+
if (isInVoidZone(fx, fy, voidZones)) {
|
|
1454
|
+
prevX = fx;
|
|
1455
|
+
prevY = fy;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1176
1459
|
const t = s / steps;
|
|
1177
|
-
// Taper + pressure
|
|
1178
1460
|
const taper = 1 - t * 0.8;
|
|
1179
1461
|
const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
// Interpolate color along stroke
|
|
1462
|
+
const segWidth = startWidth * taper * pressure;
|
|
1463
|
+
const segAlpha = lineAlpha * taper;
|
|
1183
1464
|
const lineColor = t < 0.5
|
|
1184
1465
|
? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
|
|
1185
1466
|
: hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
|
|
1186
|
-
ctx.strokeStyle = lineColor;
|
|
1187
|
-
ctx.lineWidth = startWidth * taper * pressure;
|
|
1188
|
-
ctx.lineCap = "round";
|
|
1189
1467
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1468
|
+
// Quantize width into bucket
|
|
1469
|
+
const bucketIdx = Math.min(
|
|
1470
|
+
FLOW_WIDTH_BUCKETS - 1,
|
|
1471
|
+
Math.floor((segWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
|
|
1472
|
+
);
|
|
1473
|
+
flowBuckets[bucketIdx].push({ x1: prevX, y1: prevY, x2: fx, y2: fy, color: lineColor, alpha: segAlpha });
|
|
1474
|
+
flowBucketWidths[bucketIdx] = segWidth;
|
|
1194
1475
|
|
|
1195
1476
|
// Branching: ~12% chance per step to spawn a thinner child stroke
|
|
1196
1477
|
if (rng() < 0.12 && s > 5 && s < steps - 10) {
|
|
@@ -1207,12 +1488,14 @@ export function renderHashArt(
|
|
|
1207
1488
|
by += Math.sin(bAngle) * stepLen * 0.8;
|
|
1208
1489
|
if (bx < 0 || bx > width || by < 0 || by > height) break;
|
|
1209
1490
|
const bTaper = 1 - (bs / branchSteps) * 0.9;
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1491
|
+
const bSegWidth = branchWidth * bTaper;
|
|
1492
|
+
const bAlpha = lineAlpha * taper * bTaper * 0.6;
|
|
1493
|
+
const bBucket = Math.min(
|
|
1494
|
+
FLOW_WIDTH_BUCKETS - 1,
|
|
1495
|
+
Math.floor((bSegWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
|
|
1496
|
+
);
|
|
1497
|
+
flowBuckets[bBucket].push({ x1: bPrevX, y1: bPrevY, x2: bx, y2: by, color: lineColor, alpha: bAlpha });
|
|
1498
|
+
flowBucketWidths[bBucket] = bSegWidth;
|
|
1216
1499
|
bPrevX = bx;
|
|
1217
1500
|
bPrevY = by;
|
|
1218
1501
|
}
|
|
@@ -1223,14 +1506,61 @@ export function renderHashArt(
|
|
|
1223
1506
|
}
|
|
1224
1507
|
}
|
|
1225
1508
|
|
|
1509
|
+
// Render flow line buckets — one batched path per width bucket
|
|
1510
|
+
// Within each bucket, further sub-batch by quantized alpha (4 levels)
|
|
1511
|
+
ctx.lineCap = "round";
|
|
1512
|
+
const FLOW_ALPHA_BUCKETS = 4;
|
|
1513
|
+
for (let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++) {
|
|
1514
|
+
const segs = flowBuckets[wb];
|
|
1515
|
+
if (segs.length === 0) continue;
|
|
1516
|
+
ctx.lineWidth = flowBucketWidths[wb];
|
|
1517
|
+
|
|
1518
|
+
// Sub-bucket by alpha
|
|
1519
|
+
const alphaSubs: FlowSeg[][] = [];
|
|
1520
|
+
for (let a = 0; a < FLOW_ALPHA_BUCKETS; a++) alphaSubs.push([]);
|
|
1521
|
+
let maxAlpha = 0;
|
|
1522
|
+
for (let j = 0; j < segs.length; j++) {
|
|
1523
|
+
if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
|
|
1524
|
+
}
|
|
1525
|
+
for (let j = 0; j < segs.length; j++) {
|
|
1526
|
+
const ai = Math.min(
|
|
1527
|
+
FLOW_ALPHA_BUCKETS - 1,
|
|
1528
|
+
Math.floor((segs[j].alpha / (maxAlpha || 1)) * FLOW_ALPHA_BUCKETS),
|
|
1529
|
+
);
|
|
1530
|
+
alphaSubs[ai].push(segs[j]);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
for (let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++) {
|
|
1534
|
+
const sub = alphaSubs[ai];
|
|
1535
|
+
if (sub.length === 0) continue;
|
|
1536
|
+
// Use the median segment's alpha and color as representative
|
|
1537
|
+
const rep = sub[Math.floor(sub.length / 2)];
|
|
1538
|
+
ctx.globalAlpha = rep.alpha;
|
|
1539
|
+
ctx.strokeStyle = rep.color;
|
|
1540
|
+
ctx.beginPath();
|
|
1541
|
+
for (let j = 0; j < sub.length; j++) {
|
|
1542
|
+
ctx.moveTo(sub[j].x1, sub[j].y1);
|
|
1543
|
+
ctx.lineTo(sub[j].x2, sub[j].y2);
|
|
1544
|
+
}
|
|
1545
|
+
ctx.stroke();
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1226
1549
|
// ── 6b. Motion/energy lines — short directional bursts ─────────
|
|
1550
|
+
// Optimized: collect all burst segments, then batch by quantized alpha
|
|
1227
1551
|
const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
|
|
1228
1552
|
const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
|
|
1229
1553
|
if (hasEnergyLines && shapePositions.length > 0) {
|
|
1230
1554
|
const energyCount = 5 + Math.floor(rng() * 10);
|
|
1231
1555
|
ctx.lineCap = "round";
|
|
1556
|
+
|
|
1557
|
+
// Collect all energy segments with their computed state
|
|
1558
|
+
const ENERGY_ALPHA_BUCKETS = 3;
|
|
1559
|
+
const energyBuckets: Array<Array<{ x1: number; y1: number; x2: number; y2: number; color: string; lw: number }>> = [];
|
|
1560
|
+
for (let b = 0; b < ENERGY_ALPHA_BUCKETS; b++) energyBuckets.push([]);
|
|
1561
|
+
const energyAlphas: number[] = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
|
|
1562
|
+
|
|
1232
1563
|
for (let e = 0; e < energyCount; e++) {
|
|
1233
|
-
// Pick a random shape to radiate from
|
|
1234
1564
|
const source = shapePositions[Math.floor(rng() * shapePositions.length)];
|
|
1235
1565
|
const burstCount = 2 + Math.floor(rng() * 4);
|
|
1236
1566
|
const baseAngle = flowAngle(source.x, source.y);
|
|
@@ -1244,16 +1574,34 @@ export function renderHashArt(
|
|
|
1244
1574
|
const ex = sx + Math.cos(angle) * lineLen;
|
|
1245
1575
|
const ey = sy + Math.sin(angle) * lineLen;
|
|
1246
1576
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1577
|
+
const eAlpha = 0.04 + rng() * 0.06;
|
|
1578
|
+
const eColor = hexWithAlpha(
|
|
1249
1579
|
enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
|
|
1250
1580
|
);
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1581
|
+
const eLw = (0.5 + rng() * 1.5) * scaleFactor;
|
|
1582
|
+
|
|
1583
|
+
// Quantize alpha into bucket
|
|
1584
|
+
const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
|
|
1585
|
+
energyBuckets[bi].push({ x1: sx, y1: sy, x2: ex, y2: ey, color: eColor, lw: eLw });
|
|
1586
|
+
energyAlphas[bi] = eAlpha;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Render batched energy lines
|
|
1591
|
+
for (let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++) {
|
|
1592
|
+
const segs = energyBuckets[bi];
|
|
1593
|
+
if (segs.length === 0) continue;
|
|
1594
|
+
ctx.globalAlpha = energyAlphas[bi];
|
|
1595
|
+
// Use median segment's color and width as representative
|
|
1596
|
+
const rep = segs[Math.floor(segs.length / 2)];
|
|
1597
|
+
ctx.strokeStyle = rep.color;
|
|
1598
|
+
ctx.lineWidth = rep.lw;
|
|
1599
|
+
ctx.beginPath();
|
|
1600
|
+
for (let j = 0; j < segs.length; j++) {
|
|
1601
|
+
ctx.moveTo(segs[j].x1, segs[j].y1);
|
|
1602
|
+
ctx.lineTo(segs[j].x2, segs[j].y2);
|
|
1256
1603
|
}
|
|
1604
|
+
ctx.stroke();
|
|
1257
1605
|
}
|
|
1258
1606
|
}
|
|
1259
1607
|
|
|
@@ -1279,34 +1627,96 @@ export function renderHashArt(
|
|
|
1279
1627
|
}
|
|
1280
1628
|
|
|
1281
1629
|
|
|
1282
|
-
// ── 7. Noise texture overlay
|
|
1630
|
+
// ── 7. Noise texture overlay — batched via ImageData ─────────────
|
|
1631
|
+
// Optimized: cap density at large sizes (diminishing returns above ~2K dots),
|
|
1632
|
+
// skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
|
|
1283
1633
|
const noiseRng = createRng(seedFromHash(gitHash, 777));
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1634
|
+
const rawNoiseDensity = Math.floor((width * height) / 800);
|
|
1635
|
+
// Cap at 2500 dots — beyond this the visual effect is indistinguishable
|
|
1636
|
+
// but getImageData/putImageData cost scales with canvas size
|
|
1637
|
+
const noiseDensity = Math.min(rawNoiseDensity, 2500);
|
|
1638
|
+
try {
|
|
1639
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
1640
|
+
const data = imageData.data;
|
|
1641
|
+
const pixelScale = Math.max(1, Math.round(scaleFactor));
|
|
1642
|
+
if (pixelScale === 1) {
|
|
1643
|
+
// Fast path — no inner loop, direct pixel write
|
|
1644
|
+
// Pre-compute alpha blend as integer math (avoid float multiply per channel)
|
|
1645
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1646
|
+
const nx = Math.floor(noiseRng() * width);
|
|
1647
|
+
const ny = Math.floor(noiseRng() * height);
|
|
1648
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1649
|
+
// srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
|
|
1650
|
+
const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
|
|
1651
|
+
const invA256 = 256 - srcA256;
|
|
1652
|
+
const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
|
|
1653
|
+
const idx = (ny * width + nx) << 2;
|
|
1654
|
+
data[idx] = (data[idx] * invA256 + bSrc) >> 8;
|
|
1655
|
+
data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
|
|
1656
|
+
data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
|
|
1657
|
+
}
|
|
1658
|
+
} else {
|
|
1659
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1660
|
+
const nx = Math.floor(noiseRng() * width);
|
|
1661
|
+
const ny = Math.floor(noiseRng() * height);
|
|
1662
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1663
|
+
const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
|
|
1664
|
+
const invA256 = 256 - srcA256;
|
|
1665
|
+
const bSrc = brightness * srcA256;
|
|
1666
|
+
for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
|
|
1667
|
+
for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
|
|
1668
|
+
const idx = ((ny + dy) * width + (nx + dx)) << 2;
|
|
1669
|
+
data[idx] = (data[idx] * invA256 + bSrc) >> 8;
|
|
1670
|
+
data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
|
|
1671
|
+
data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
ctx.putImageData(imageData, 0, 0);
|
|
1677
|
+
} catch {
|
|
1678
|
+
// Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
|
|
1679
|
+
for (let i = 0; i < noiseDensity; i++) {
|
|
1680
|
+
const nx = noiseRng() * width;
|
|
1681
|
+
const ny = noiseRng() * height;
|
|
1682
|
+
const brightness = noiseRng() > 0.5 ? 255 : 0;
|
|
1683
|
+
const alpha = 0.01 + noiseRng() * 0.03;
|
|
1684
|
+
ctx.globalAlpha = alpha;
|
|
1685
|
+
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
1686
|
+
ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
|
|
1687
|
+
}
|
|
1293
1688
|
}
|
|
1294
1689
|
|
|
1295
1690
|
// ── 8. Vignette — darken edges to draw the eye inward ───────────
|
|
1296
1691
|
ctx.globalAlpha = 1;
|
|
1297
1692
|
const vignetteStrength = 0.25 + rng() * 0.2;
|
|
1298
1693
|
const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
|
|
1694
|
+
// Tint vignette based on background: warm sepia for light, cool blue for dark
|
|
1695
|
+
const isLightBg = bgLum > 0.5;
|
|
1696
|
+
const vignetteColor = isLightBg
|
|
1697
|
+
? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
|
|
1698
|
+
: `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
|
|
1299
1699
|
vigGrad.addColorStop(0, "rgba(0,0,0,0)");
|
|
1300
1700
|
vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
|
|
1301
|
-
vigGrad.addColorStop(1,
|
|
1701
|
+
vigGrad.addColorStop(1, vignetteColor);
|
|
1302
1702
|
ctx.fillStyle = vigGrad;
|
|
1303
1703
|
ctx.fillRect(0, 0, width, height);
|
|
1304
1704
|
|
|
1305
|
-
// ── 9. Organic connecting curves
|
|
1705
|
+
// ── 9. Organic connecting curves — proximity-aware ───────────────
|
|
1706
|
+
// Optimized: batch all curves into alpha-quantized groups to reduce
|
|
1707
|
+
// beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
|
|
1306
1708
|
if (shapePositions.length > 1) {
|
|
1307
1709
|
const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
|
|
1710
|
+
const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
|
|
1308
1711
|
ctx.lineWidth = 0.8 * scaleFactor;
|
|
1309
1712
|
|
|
1713
|
+
// Collect curves into 3 alpha buckets
|
|
1714
|
+
const CURVE_ALPHA_BUCKETS = 3;
|
|
1715
|
+
const curveBuckets: Array<Array<{ ax: number; ay: number; cpx: number; cpy: number; bx: number; by: number }>> = [];
|
|
1716
|
+
const curveColors: string[] = [];
|
|
1717
|
+
const curveAlphas: number[] = new Array(CURVE_ALPHA_BUCKETS).fill(0);
|
|
1718
|
+
for (let b = 0; b < CURVE_ALPHA_BUCKETS; b++) curveBuckets.push([]);
|
|
1719
|
+
|
|
1310
1720
|
for (let i = 0; i < numCurves; i++) {
|
|
1311
1721
|
const idxA = Math.floor(rng() * shapePositions.length);
|
|
1312
1722
|
const offset =
|
|
@@ -1316,25 +1726,46 @@ export function renderHashArt(
|
|
|
1316
1726
|
const a = shapePositions[idxA];
|
|
1317
1727
|
const b = shapePositions[idxB];
|
|
1318
1728
|
|
|
1319
|
-
const mx = (a.x + b.x) / 2;
|
|
1320
|
-
const my = (a.y + b.y) / 2;
|
|
1321
1729
|
const dx = b.x - a.x;
|
|
1322
1730
|
const dy = b.y - a.y;
|
|
1323
1731
|
const dist = Math.hypot(dx, dy);
|
|
1732
|
+
|
|
1733
|
+
// Skip connections between distant shapes
|
|
1734
|
+
if (dist > maxCurveDist) {
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const mx = (a.x + b.x) / 2;
|
|
1739
|
+
const my = (a.y + b.y) / 2;
|
|
1324
1740
|
const bulge = (rng() - 0.5) * dist * 0.4;
|
|
1325
1741
|
|
|
1326
1742
|
const cpx = mx + (-dy / (dist || 1)) * bulge;
|
|
1327
1743
|
const cpy = my + (dx / (dist || 1)) * bulge;
|
|
1328
1744
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1745
|
+
const curveAlpha = 0.06 + rng() * 0.1;
|
|
1746
|
+
const curveColor = hexWithAlpha(
|
|
1331
1747
|
enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum),
|
|
1332
1748
|
0.3,
|
|
1333
1749
|
);
|
|
1334
1750
|
|
|
1751
|
+
const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
|
|
1752
|
+
curveBuckets[bi].push({ ax: a.x, ay: a.y, cpx, cpy, bx: b.x, by: b.y });
|
|
1753
|
+
curveAlphas[bi] = curveAlpha;
|
|
1754
|
+
if (!curveColors[bi]) curveColors[bi] = curveColor;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// Render batched curves
|
|
1758
|
+
for (let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++) {
|
|
1759
|
+
const curves = curveBuckets[bi];
|
|
1760
|
+
if (curves.length === 0) continue;
|
|
1761
|
+
ctx.globalAlpha = curveAlphas[bi];
|
|
1762
|
+
ctx.strokeStyle = curveColors[bi];
|
|
1335
1763
|
ctx.beginPath();
|
|
1336
|
-
|
|
1337
|
-
|
|
1764
|
+
for (let j = 0; j < curves.length; j++) {
|
|
1765
|
+
const c = curves[j];
|
|
1766
|
+
ctx.moveTo(c.ax, c.ay);
|
|
1767
|
+
ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
|
|
1768
|
+
}
|
|
1338
1769
|
ctx.stroke();
|
|
1339
1770
|
}
|
|
1340
1771
|
}
|
|
@@ -1442,12 +1873,15 @@ export function renderHashArt(
|
|
|
1442
1873
|
}
|
|
1443
1874
|
} else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
|
|
1444
1875
|
// Vine tendrils — organic curving lines along edges
|
|
1876
|
+
// Optimized: batch all tendrils into a single path
|
|
1445
1877
|
ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
|
|
1446
1878
|
ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
|
|
1447
1879
|
ctx.globalAlpha = 0.12 + borderRng() * 0.08;
|
|
1448
1880
|
ctx.lineCap = "round";
|
|
1449
1881
|
|
|
1450
1882
|
const tendrilCount = 8 + Math.floor(borderRng() * 8);
|
|
1883
|
+
ctx.beginPath();
|
|
1884
|
+
const leafPositions: Array<{ x: number; y: number; r: number }> = [];
|
|
1451
1885
|
for (let t = 0; t < tendrilCount; t++) {
|
|
1452
1886
|
// Start from a random edge point
|
|
1453
1887
|
const edge = Math.floor(borderRng() * 4);
|
|
@@ -1457,7 +1891,6 @@ export function renderHashArt(
|
|
|
1457
1891
|
else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
|
|
1458
1892
|
else { tx = width - borderPad; ty = borderRng() * height; }
|
|
1459
1893
|
|
|
1460
|
-
ctx.beginPath();
|
|
1461
1894
|
ctx.moveTo(tx, ty);
|
|
1462
1895
|
const segs = 3 + Math.floor(borderRng() * 4);
|
|
1463
1896
|
for (let s = 0; s < segs; s++) {
|
|
@@ -1471,16 +1904,24 @@ export function renderHashArt(
|
|
|
1471
1904
|
ty = cpy3;
|
|
1472
1905
|
ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
|
|
1473
1906
|
}
|
|
1474
|
-
ctx.stroke();
|
|
1475
1907
|
|
|
1476
|
-
//
|
|
1908
|
+
// Collect leaf positions for batch fill
|
|
1477
1909
|
if (borderRng() < 0.6) {
|
|
1478
|
-
|
|
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();
|
|
1910
|
+
leafPositions.push({ x: tx, y: ty, r: borderPad * (0.15 + borderRng() * 0.2) });
|
|
1482
1911
|
}
|
|
1483
1912
|
}
|
|
1913
|
+
ctx.stroke();
|
|
1914
|
+
|
|
1915
|
+
// Batch all leaf dots into a single fill
|
|
1916
|
+
if (leafPositions.length > 0) {
|
|
1917
|
+
ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
|
|
1918
|
+
ctx.beginPath();
|
|
1919
|
+
for (const leaf of leafPositions) {
|
|
1920
|
+
ctx.moveTo(leaf.x + leaf.r, leaf.y);
|
|
1921
|
+
ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
|
|
1922
|
+
}
|
|
1923
|
+
ctx.fill();
|
|
1924
|
+
}
|
|
1484
1925
|
} else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
|
|
1485
1926
|
// Star-studded arcs along edges
|
|
1486
1927
|
ctx.globalAlpha = 0.1 + borderRng() * 0.08;
|
|
@@ -1496,8 +1937,9 @@ export function renderHashArt(
|
|
|
1496
1937
|
ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
|
|
1497
1938
|
ctx.stroke();
|
|
1498
1939
|
|
|
1499
|
-
// Scatter small stars along the border region
|
|
1940
|
+
// Scatter small stars along the border region — batched into single path
|
|
1500
1941
|
const starCount = 15 + Math.floor(borderRng() * 15);
|
|
1942
|
+
ctx.beginPath();
|
|
1501
1943
|
for (let s = 0; s < starCount; s++) {
|
|
1502
1944
|
const edge = Math.floor(borderRng() * 4);
|
|
1503
1945
|
let sx: number, sy: number;
|
|
@@ -1508,7 +1950,6 @@ export function renderHashArt(
|
|
|
1508
1950
|
|
|
1509
1951
|
const starR = (1 + borderRng() * 2.5) * scaleFactor;
|
|
1510
1952
|
// 4-point star
|
|
1511
|
-
ctx.beginPath();
|
|
1512
1953
|
for (let p = 0; p < 8; p++) {
|
|
1513
1954
|
const a = (p / 8) * Math.PI * 2;
|
|
1514
1955
|
const r = p % 2 === 0 ? starR : starR * 0.4;
|
|
@@ -1518,8 +1959,8 @@ export function renderHashArt(
|
|
|
1518
1959
|
else ctx.lineTo(px2, py2);
|
|
1519
1960
|
}
|
|
1520
1961
|
ctx.closePath();
|
|
1521
|
-
ctx.fill();
|
|
1522
1962
|
}
|
|
1963
|
+
ctx.fill();
|
|
1523
1964
|
} else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
|
|
1524
1965
|
// Thin single rule — understated elegance
|
|
1525
1966
|
ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
|
|
@@ -1532,13 +1973,31 @@ export function renderHashArt(
|
|
|
1532
1973
|
ctx.restore();
|
|
1533
1974
|
}
|
|
1534
1975
|
|
|
1535
|
-
// ── 11. Signature mark —
|
|
1976
|
+
// ── 11. Signature mark — placed in the least-dense corner ──────
|
|
1536
1977
|
{
|
|
1537
1978
|
const sigRng = createRng(seedFromHash(gitHash, 42));
|
|
1538
1979
|
const sigSize = Math.min(width, height) * 0.025;
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1980
|
+
const sigMargin = sigSize * 2.5;
|
|
1981
|
+
|
|
1982
|
+
// Find the corner with the lowest local density
|
|
1983
|
+
const cornerCandidates = [
|
|
1984
|
+
{ x: sigMargin, y: sigMargin }, // top-left
|
|
1985
|
+
{ x: width - sigMargin, y: sigMargin }, // top-right
|
|
1986
|
+
{ x: sigMargin, y: height - sigMargin }, // bottom-left
|
|
1987
|
+
{ x: width - sigMargin, y: height - sigMargin }, // bottom-right
|
|
1988
|
+
];
|
|
1989
|
+
let bestCorner = cornerCandidates[3]; // default: bottom-right
|
|
1990
|
+
let minDensity = Infinity;
|
|
1991
|
+
for (const corner of cornerCandidates) {
|
|
1992
|
+
const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
|
|
1993
|
+
if (density < minDensity) {
|
|
1994
|
+
minDensity = density;
|
|
1995
|
+
bestCorner = corner;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
const sigX = bestCorner.x;
|
|
2000
|
+
const sigY = bestCorner.y;
|
|
1542
2001
|
const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
|
|
1543
2002
|
const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
|
|
1544
2003
|
|