git-hash-art 0.6.0 → 0.8.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/ALGORITHM.md +30 -3
- package/CHANGELOG.md +16 -0
- package/bin/generateExamples.js +6 -14
- package/dist/browser.js +1289 -125
- package/dist/browser.js.map +1 -1
- package/dist/main.js +1289 -125
- package/dist/main.js.map +1 -1
- package/dist/module.js +1289 -125
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/archetypes.ts +51 -0
- package/src/lib/canvas/colors.ts +155 -2
- package/src/lib/canvas/draw.ts +42 -9
- package/src/lib/canvas/shapes/affinity.ts +479 -0
- package/src/lib/canvas/shapes/index.ts +2 -0
- package/src/lib/canvas/shapes/procedural.ts +209 -0
- package/src/lib/render.ts +282 -136
package/src/lib/render.ts
CHANGED
|
@@ -5,24 +5,33 @@
|
|
|
5
5
|
* identically in Node (@napi-rs/canvas) and browsers.
|
|
6
6
|
*
|
|
7
7
|
* Generation pipeline:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* 0. Archetype selection + shape palette + color hierarchy
|
|
9
|
+
* 1. Background — style from archetype, gradient mesh for depth
|
|
10
|
+
* 1b. Layered background — archetype-coherent shapes
|
|
11
|
+
* 2. Composition mode + symmetry
|
|
12
|
+
* 3. Focal points + void zones + hero avoidance field
|
|
13
|
+
* 4. Flow field
|
|
14
|
+
* 4b. Hero shape
|
|
15
|
+
* 5. Shape layers — palette-driven selection, affinity-aware styles,
|
|
16
|
+
* size echo, tangent placement, atmospheric depth
|
|
15
17
|
* 5b. Recursive nesting
|
|
16
|
-
* 6. Flow
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* 6. Flow lines — variable color, branching, pressure simulation
|
|
19
|
+
* 6b. Symmetry mirroring
|
|
20
|
+
* 7. Noise texture
|
|
21
|
+
* 8. Vignette
|
|
22
|
+
* 9. Organic connecting curves
|
|
23
|
+
* 10. Post-processing — color grading, chromatic aberration, bloom
|
|
19
24
|
*/
|
|
20
25
|
import {
|
|
21
26
|
SacredColorScheme,
|
|
22
|
-
hexWithAlpha,
|
|
23
|
-
jitterColor,
|
|
27
|
+
hexWithAlpha, jitterColorHSL,
|
|
24
28
|
desaturate,
|
|
25
29
|
shiftTemperature,
|
|
30
|
+
luminance,
|
|
31
|
+
enforceContrast,
|
|
32
|
+
buildColorHierarchy,
|
|
33
|
+
pickHierarchyColor, pickColorGrade,
|
|
34
|
+
type ColorHierarchy
|
|
26
35
|
} from "./canvas/colors";
|
|
27
36
|
import {
|
|
28
37
|
enhanceShapeGeneration,
|
|
@@ -31,31 +40,19 @@ import {
|
|
|
31
40
|
type RenderStyle,
|
|
32
41
|
} from "./canvas/draw";
|
|
33
42
|
import { shapes } from "./canvas/shapes";
|
|
43
|
+
import {
|
|
44
|
+
buildShapePalette,
|
|
45
|
+
pickShapeFromPalette,
|
|
46
|
+
pickStyleForShape,
|
|
47
|
+
SHAPE_PROFILES
|
|
48
|
+
} from "./canvas/shapes/affinity";
|
|
34
49
|
import { createRng, seedFromHash } from "./utils";
|
|
35
50
|
import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
|
|
36
51
|
import { selectArchetype, type BackgroundStyle } from "./archetypes";
|
|
37
52
|
|
|
38
|
-
// ── Shape categories for weighted selection ─────────────────────────
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"square",
|
|
43
|
-
"triangle",
|
|
44
|
-
"hexagon",
|
|
45
|
-
"diamond",
|
|
46
|
-
"cube",
|
|
47
|
-
];
|
|
48
|
-
const COMPLEX_SHAPES = [
|
|
49
|
-
"star",
|
|
50
|
-
"jacked-star",
|
|
51
|
-
"heart",
|
|
52
|
-
"platonicSolid",
|
|
53
|
-
"fibonacciSpiral",
|
|
54
|
-
"islamicPattern",
|
|
55
|
-
"celticKnot",
|
|
56
|
-
"merkaba",
|
|
57
|
-
"fractal",
|
|
58
|
-
];
|
|
54
|
+
// ── Shape categories for weighted selection (legacy fallback) ───────
|
|
55
|
+
|
|
59
56
|
const SACRED_SHAPES = [
|
|
60
57
|
"mandala",
|
|
61
58
|
"flowerOfLife",
|
|
@@ -85,31 +82,6 @@ const COMPOSITION_MODES: CompositionMode[] = [
|
|
|
85
82
|
"clustered",
|
|
86
83
|
];
|
|
87
84
|
|
|
88
|
-
// ── Helper: pick shape with layer-aware weighting ───────────────────
|
|
89
|
-
|
|
90
|
-
function pickShape(
|
|
91
|
-
rng: () => number,
|
|
92
|
-
layerRatio: number,
|
|
93
|
-
shapeNames: string[],
|
|
94
|
-
): string {
|
|
95
|
-
const basicW = 1 - layerRatio * 0.6;
|
|
96
|
-
const complexW = 0.3 + layerRatio * 0.3;
|
|
97
|
-
const sacredW = 0.1 + layerRatio * 0.4;
|
|
98
|
-
const total = basicW + complexW + sacredW;
|
|
99
|
-
const roll = rng() * total;
|
|
100
|
-
|
|
101
|
-
let pool: string[];
|
|
102
|
-
if (roll < basicW) pool = BASIC_SHAPES;
|
|
103
|
-
else if (roll < basicW + complexW) pool = COMPLEX_SHAPES;
|
|
104
|
-
else pool = SACRED_SHAPES;
|
|
105
|
-
|
|
106
|
-
const available = pool.filter((s) => shapeNames.includes(s));
|
|
107
|
-
if (available.length === 0) {
|
|
108
|
-
return shapeNames[Math.floor(rng() * shapeNames.length)];
|
|
109
|
-
}
|
|
110
|
-
return available[Math.floor(rng() * available.length)];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
85
|
// ── Helper: get position based on composition mode ──────────────────
|
|
114
86
|
|
|
115
87
|
function getCompositionPosition(
|
|
@@ -167,24 +139,33 @@ function getCompositionPosition(
|
|
|
167
139
|
}
|
|
168
140
|
}
|
|
169
141
|
|
|
170
|
-
// ── Helper: positional color
|
|
142
|
+
// ── Helper: positional color from hierarchy ─────────────────────────
|
|
171
143
|
|
|
172
144
|
function getPositionalColor(
|
|
173
145
|
x: number,
|
|
174
146
|
y: number,
|
|
175
147
|
width: number,
|
|
176
148
|
height: number,
|
|
177
|
-
|
|
149
|
+
hierarchy: ColorHierarchy,
|
|
178
150
|
rng: () => number,
|
|
179
151
|
): string {
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
152
|
+
// Blend position into color selection — shapes near center lean dominant
|
|
153
|
+
const distFromCenter = Math.hypot(x - width / 2, y - height / 2) /
|
|
154
|
+
Math.hypot(width / 2, height / 2);
|
|
155
|
+
// Center = more dominant, edges = more accent
|
|
156
|
+
if (distFromCenter < 0.35) {
|
|
157
|
+
return jitterColorHSL(hierarchy.dominant, rng, 10, 0.08);
|
|
158
|
+
} else if (distFromCenter < 0.7) {
|
|
159
|
+
return jitterColorHSL(pickHierarchyColor(hierarchy, rng), rng, 8, 0.06);
|
|
160
|
+
} else {
|
|
161
|
+
// Edges: bias toward secondary/accent
|
|
162
|
+
const roll = rng();
|
|
163
|
+
const color = roll < 0.4 ? hierarchy.secondary : roll < 0.75 ? hierarchy.accent : hierarchy.dominant;
|
|
164
|
+
return jitterColorHSL(color, rng, 12, 0.08);
|
|
165
|
+
}
|
|
185
166
|
}
|
|
186
167
|
|
|
187
|
-
// ── Helper: check if a position is inside a void zone
|
|
168
|
+
// ── Helper: check if a position is inside a void zone ───────────────
|
|
188
169
|
|
|
189
170
|
function isInVoidZone(
|
|
190
171
|
x: number,
|
|
@@ -197,7 +178,7 @@ function isInVoidZone(
|
|
|
197
178
|
return false;
|
|
198
179
|
}
|
|
199
180
|
|
|
200
|
-
// ── Helper: density check
|
|
181
|
+
// ── Helper: density check ───────────────────────────────────────────
|
|
201
182
|
|
|
202
183
|
function localDensity(
|
|
203
184
|
x: number,
|
|
@@ -327,7 +308,19 @@ export function renderHashArt(
|
|
|
327
308
|
const fgTempTarget: "warm" | "cool" | null =
|
|
328
309
|
tempMode === "warm-bg" ? "cool" : tempMode === "cool-bg" ? "warm" : null;
|
|
329
310
|
|
|
311
|
+
// ── 0b. Color hierarchy — dominant/secondary/accent weighting ──
|
|
312
|
+
const colorHierarchy = buildColorHierarchy(colors, rng);
|
|
313
|
+
|
|
314
|
+
// ── 0c. Shape palette — curated shapes that work well together ──
|
|
330
315
|
const shapeNames = Object.keys(shapes);
|
|
316
|
+
const shapePalette = buildShapePalette(rng, shapeNames, archetype.name);
|
|
317
|
+
|
|
318
|
+
// ── 0d. Color grading — unified tone for the whole image ───────
|
|
319
|
+
const colorGrade = pickColorGrade(rng);
|
|
320
|
+
|
|
321
|
+
// ── 0e. Light direction — consistent shadow angle ──────────────
|
|
322
|
+
const lightAngle = rng() * Math.PI * 2;
|
|
323
|
+
|
|
331
324
|
const scaleFactor = Math.min(width, height) / 1024;
|
|
332
325
|
const adjustedMinSize = minShapeSize * scaleFactor;
|
|
333
326
|
const adjustedMaxSize = maxShapeSize * scaleFactor;
|
|
@@ -339,25 +332,50 @@ export function renderHashArt(
|
|
|
339
332
|
const bgRadius = Math.hypot(cx, cy);
|
|
340
333
|
drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
|
|
341
334
|
|
|
342
|
-
//
|
|
343
|
-
|
|
335
|
+
// Gradient mesh overlay — 3-4 color control points for richer backgrounds
|
|
336
|
+
const meshPoints = 3 + Math.floor(rng() * 2);
|
|
337
|
+
ctx.globalCompositeOperation = "soft-light";
|
|
338
|
+
for (let i = 0; i < meshPoints; i++) {
|
|
339
|
+
const mx = rng() * width;
|
|
340
|
+
const my = rng() * height;
|
|
341
|
+
const mRadius = Math.min(width, height) * (0.3 + rng() * 0.4);
|
|
342
|
+
const mColor = pickHierarchyColor(colorHierarchy, rng);
|
|
343
|
+
const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
|
|
344
|
+
grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06));
|
|
345
|
+
grad.addColorStop(1, "rgba(0,0,0,0)");
|
|
346
|
+
ctx.globalAlpha = 1;
|
|
347
|
+
ctx.fillStyle = grad;
|
|
348
|
+
ctx.fillRect(0, 0, width, height);
|
|
349
|
+
}
|
|
350
|
+
ctx.globalCompositeOperation = "source-over";
|
|
351
|
+
|
|
352
|
+
// Compute average background luminance for contrast enforcement
|
|
353
|
+
const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2;
|
|
354
|
+
|
|
355
|
+
// ── 1b. Layered background — archetype-coherent shapes ─────────
|
|
344
356
|
const bgShapeCount = 3 + Math.floor(rng() * 4);
|
|
345
357
|
ctx.globalCompositeOperation = "soft-light";
|
|
346
358
|
for (let i = 0; i < bgShapeCount; i++) {
|
|
347
359
|
const bx = rng() * width;
|
|
348
360
|
const by = rng() * height;
|
|
349
361
|
const bSize = (width * 0.3 + rng() * width * 0.5);
|
|
350
|
-
const bColor =
|
|
362
|
+
const bColor = pickHierarchyColor(colorHierarchy, rng);
|
|
351
363
|
ctx.globalAlpha = 0.03 + rng() * 0.05;
|
|
352
364
|
ctx.fillStyle = hexWithAlpha(bColor, 0.15);
|
|
353
365
|
ctx.beginPath();
|
|
354
|
-
|
|
366
|
+
// Use archetype-appropriate background shapes
|
|
367
|
+
if (archetype.name === "geometric-precision" || archetype.name === "op-art") {
|
|
368
|
+
// Rectangular shapes for geometric archetypes
|
|
369
|
+
ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
|
|
370
|
+
} else {
|
|
371
|
+
ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
|
|
372
|
+
}
|
|
355
373
|
ctx.fill();
|
|
356
374
|
}
|
|
357
375
|
// Subtle concentric rings from center
|
|
358
376
|
const ringCount = 2 + Math.floor(rng() * 3);
|
|
359
377
|
ctx.globalAlpha = 0.02 + rng() * 0.03;
|
|
360
|
-
ctx.strokeStyle = hexWithAlpha(
|
|
378
|
+
ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
|
|
361
379
|
ctx.lineWidth = 1 * scaleFactor;
|
|
362
380
|
for (let i = 1; i <= ringCount; i++) {
|
|
363
381
|
const r = (Math.min(width, height) * 0.15) * i;
|
|
@@ -380,7 +398,6 @@ export function renderHashArt(
|
|
|
380
398
|
symRoll < 0.25 ? "quad" : "none";
|
|
381
399
|
|
|
382
400
|
// ── 3. Focal points + void zones ───────────────────────────────
|
|
383
|
-
// Rule-of-thirds intersection points for intentional composition
|
|
384
401
|
const THIRDS_POINTS = [
|
|
385
402
|
{ x: 1 / 3, y: 1 / 3 },
|
|
386
403
|
{ x: 2 / 3, y: 1 / 3 },
|
|
@@ -390,10 +407,8 @@ export function renderHashArt(
|
|
|
390
407
|
const numFocal = 1 + Math.floor(rng() * 2);
|
|
391
408
|
const focalPoints: Array<{ x: number; y: number; strength: number }> = [];
|
|
392
409
|
for (let f = 0; f < numFocal; f++) {
|
|
393
|
-
// 70% chance to snap to a rule-of-thirds point, 30% free placement
|
|
394
410
|
if (rng() < 0.7) {
|
|
395
411
|
const tp = THIRDS_POINTS[Math.floor(rng() * THIRDS_POINTS.length)];
|
|
396
|
-
// Small jitter around the thirds point so it's not robotic
|
|
397
412
|
focalPoints.push({
|
|
398
413
|
x: width * (tp.x + (rng() - 0.5) * 0.08),
|
|
399
414
|
y: height * (tp.y + (rng() - 0.5) * 0.08),
|
|
@@ -408,7 +423,6 @@ export function renderHashArt(
|
|
|
408
423
|
}
|
|
409
424
|
}
|
|
410
425
|
|
|
411
|
-
// Feature E: 1-2 void zones where shapes are sparse (negative space)
|
|
412
426
|
const numVoids = Math.floor(rng() * 2) + 1;
|
|
413
427
|
const voidZones: Array<{ x: number; y: number; radius: number }> = [];
|
|
414
428
|
for (let v = 0; v < numVoids; v++) {
|
|
@@ -446,24 +460,34 @@ export function renderHashArt(
|
|
|
446
460
|
}
|
|
447
461
|
|
|
448
462
|
// Track all placed shapes for density checks and connecting curves
|
|
449
|
-
const shapePositions: Array<{ x: number; y: number; size: number }> = [];
|
|
463
|
+
const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
|
|
464
|
+
|
|
465
|
+
// Hero avoidance radius — shapes near the hero orient toward it
|
|
466
|
+
let heroCenter: { x: number; y: number; size: number } | null = null;
|
|
450
467
|
|
|
451
468
|
// ── 4b. Hero shape — a dominant focal element ───────────────────
|
|
452
469
|
if (archetype.heroShape && rng() < 0.6) {
|
|
453
470
|
const heroFocal = focalPoints[0];
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
471
|
+
// Use shape palette hero candidates
|
|
472
|
+
const heroPool = [...shapePalette.primary, ...shapePalette.supporting]
|
|
473
|
+
.filter((s) => SHAPE_PROFILES[s]?.heroCandidate && shapeNames.includes(s));
|
|
474
|
+
const heroShape = heroPool.length > 0
|
|
475
|
+
? heroPool[Math.floor(rng() * heroPool.length)]
|
|
476
|
+
: shapeNames[Math.floor(rng() * shapeNames.length)];
|
|
459
477
|
|
|
460
478
|
const heroSize = adjustedMaxSize * (0.8 + rng() * 0.5);
|
|
461
479
|
const heroRotation = rng() * 360;
|
|
462
480
|
const heroFill = hexWithAlpha(
|
|
463
|
-
|
|
481
|
+
enforceContrast(jitterColorHSL(colorHierarchy.dominant, rng, 6, 0.05), bgLum),
|
|
464
482
|
0.15 + rng() * 0.2,
|
|
465
483
|
);
|
|
466
|
-
const heroStroke =
|
|
484
|
+
const heroStroke = enforceContrast(jitterColorHSL(colorHierarchy.accent, rng, 6, 0.05), bgLum);
|
|
485
|
+
|
|
486
|
+
// Get best style for this hero shape
|
|
487
|
+
const heroProfile = SHAPE_PROFILES[heroShape];
|
|
488
|
+
const heroStyle: RenderStyle = heroProfile
|
|
489
|
+
? (heroProfile.bestStyles[Math.floor(rng() * heroProfile.bestStyles.length)] as RenderStyle)
|
|
490
|
+
: (rng() < 0.4 ? "watercolor" : "fill-and-stroke");
|
|
467
491
|
|
|
468
492
|
ctx.globalAlpha = 0.5 + rng() * 0.2;
|
|
469
493
|
enhanceShapeGeneration(ctx, heroShape, heroFocal.x, heroFocal.y, {
|
|
@@ -475,14 +499,16 @@ export function renderHashArt(
|
|
|
475
499
|
proportionType: "GOLDEN_RATIO",
|
|
476
500
|
glowRadius: (12 + rng() * 20) * scaleFactor,
|
|
477
501
|
glowColor: hexWithAlpha(heroStroke, 0.4),
|
|
478
|
-
gradientFillEnd:
|
|
479
|
-
renderStyle:
|
|
502
|
+
gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
|
|
503
|
+
renderStyle: heroStyle,
|
|
480
504
|
rng,
|
|
481
505
|
});
|
|
482
506
|
|
|
483
|
-
|
|
507
|
+
heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
|
|
508
|
+
shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
|
|
484
509
|
}
|
|
485
510
|
|
|
511
|
+
|
|
486
512
|
// ── 5. Shape layers ────────────────────────────────────────────
|
|
487
513
|
const densityCheckRadius = Math.min(width, height) * 0.08;
|
|
488
514
|
const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
|
|
@@ -495,17 +521,17 @@ export function renderHashArt(
|
|
|
495
521
|
const layerOpacity = Math.max(0.15, baseOpacity - layer * opacityReduction);
|
|
496
522
|
const layerSizeScale = 1 - layer * 0.15;
|
|
497
523
|
|
|
498
|
-
//
|
|
524
|
+
// Per-layer blend mode
|
|
499
525
|
const layerBlend = pickBlendMode(rng);
|
|
500
526
|
ctx.globalCompositeOperation = layerBlend;
|
|
501
527
|
|
|
502
|
-
//
|
|
528
|
+
// Per-layer render style bias — prefer archetype styles
|
|
503
529
|
const layerRenderStyle: RenderStyle = rng() < 0.6
|
|
504
530
|
? archetype.preferredStyles[Math.floor(rng() * archetype.preferredStyles.length)]
|
|
505
531
|
: pickRenderStyle(rng);
|
|
506
532
|
|
|
507
|
-
//
|
|
508
|
-
const atmosphericDesat = layerRatio * 0.3;
|
|
533
|
+
// Atmospheric desaturation for later layers
|
|
534
|
+
const atmosphericDesat = layerRatio * 0.3;
|
|
509
535
|
|
|
510
536
|
for (let i = 0; i < numShapes; i++) {
|
|
511
537
|
// Position from composition mode, then focal bias
|
|
@@ -521,35 +547,48 @@ export function renderHashArt(
|
|
|
521
547
|
);
|
|
522
548
|
const [x, y] = applyFocalBias(rawPos.x, rawPos.y);
|
|
523
549
|
|
|
524
|
-
//
|
|
550
|
+
// Skip shapes in void zones, reduce in dense areas
|
|
525
551
|
if (isInVoidZone(x, y, voidZones)) {
|
|
526
|
-
// 85% chance to skip — allows a few shapes to bleed in
|
|
527
552
|
if (rng() < 0.85) continue;
|
|
528
553
|
}
|
|
529
554
|
if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
|
|
530
|
-
if (rng() < 0.6) continue;
|
|
555
|
+
if (rng() < 0.6) continue;
|
|
531
556
|
}
|
|
532
557
|
|
|
533
|
-
// Weighted shape selection
|
|
534
|
-
const shape = pickShape(rng, layerRatio, shapeNames);
|
|
535
|
-
|
|
536
558
|
// Power distribution for size — archetype controls the curve
|
|
537
559
|
const sizeT = Math.pow(rng(), archetype.sizePower);
|
|
538
560
|
const size =
|
|
539
561
|
(adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
|
|
540
562
|
layerSizeScale;
|
|
541
563
|
|
|
564
|
+
// Size fraction for affinity-aware shape selection
|
|
565
|
+
const sizeFraction = size / adjustedMaxSize;
|
|
566
|
+
|
|
567
|
+
// Palette-driven shape selection (replaces naive pickShape)
|
|
568
|
+
const shape = pickShapeFromPalette(shapePalette, rng, sizeFraction);
|
|
569
|
+
|
|
542
570
|
// Flow-field rotation in flow-field mode, random otherwise
|
|
543
|
-
|
|
571
|
+
let rotation =
|
|
544
572
|
compositionMode === "flow-field"
|
|
545
573
|
? (flowAngle(x, y) * 180) / Math.PI + (rng() - 0.5) * 30
|
|
546
574
|
: rng() * 360;
|
|
547
575
|
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
576
|
+
// Hero avoidance: shapes near the hero orient toward it
|
|
577
|
+
if (heroCenter) {
|
|
578
|
+
const distToHero = Math.hypot(x - heroCenter.x, y - heroCenter.y);
|
|
579
|
+
const heroInfluence = heroCenter.size * 1.5;
|
|
580
|
+
if (distToHero < heroInfluence && distToHero > 0) {
|
|
581
|
+
const angleToHero = Math.atan2(heroCenter.y - y, heroCenter.x - x) * 180 / Math.PI;
|
|
582
|
+
const blendFactor = 1 - (distToHero / heroInfluence);
|
|
583
|
+
rotation = rotation + (angleToHero - rotation) * blendFactor * 0.4;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
551
586
|
|
|
552
|
-
//
|
|
587
|
+
// Positional color from hierarchy + jitter
|
|
588
|
+
let fillBase = getPositionalColor(x, y, width, height, colorHierarchy, rng);
|
|
589
|
+
const strokeBase = pickHierarchyColor(colorHierarchy, rng);
|
|
590
|
+
|
|
591
|
+
// Desaturate colors on later layers for depth
|
|
553
592
|
if (atmosphericDesat > 0) {
|
|
554
593
|
fillBase = desaturate(fillBase, atmosphericDesat);
|
|
555
594
|
}
|
|
@@ -559,8 +598,8 @@ export function renderHashArt(
|
|
|
559
598
|
fillBase = shiftTemperature(fillBase, fgTempTarget, 0.15 + layerRatio * 0.1);
|
|
560
599
|
}
|
|
561
600
|
|
|
562
|
-
const fillColor =
|
|
563
|
-
const strokeColor =
|
|
601
|
+
const fillColor = enforceContrast(jitterColorHSL(fillBase, rng, 6, 0.05), bgLum);
|
|
602
|
+
const strokeColor = enforceContrast(jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum);
|
|
564
603
|
|
|
565
604
|
// Semi-transparent fill
|
|
566
605
|
const fillAlpha = 0.2 + rng() * 0.5;
|
|
@@ -580,16 +619,20 @@ export function renderHashArt(
|
|
|
580
619
|
// Gradient fill on ~30%
|
|
581
620
|
const hasGradient = rng() < 0.3;
|
|
582
621
|
const gradientEnd = hasGradient
|
|
583
|
-
?
|
|
622
|
+
? jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1)
|
|
584
623
|
: undefined;
|
|
585
624
|
|
|
586
|
-
//
|
|
587
|
-
const shapeRenderStyle =
|
|
588
|
-
rng() < 0.7 ? layerRenderStyle : pickRenderStyle(rng);
|
|
625
|
+
// Affinity-aware render style selection
|
|
626
|
+
const shapeRenderStyle = pickStyleForShape(shape, layerRenderStyle, rng) as RenderStyle;
|
|
589
627
|
|
|
590
|
-
//
|
|
628
|
+
// Organic edge jitter — applied via watercolor style on ~15% of shapes
|
|
591
629
|
const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
|
|
592
|
-
const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
|
|
630
|
+
const finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
|
|
631
|
+
|
|
632
|
+
// Consistent light direction — subtle shadow offset
|
|
633
|
+
const shadowDist = hasGlow ? 0 : (size * 0.02);
|
|
634
|
+
const shadowOffX = shadowDist * Math.cos(lightAngle);
|
|
635
|
+
const shadowOffY = shadowDist * Math.sin(lightAngle);
|
|
593
636
|
|
|
594
637
|
enhanceShapeGeneration(ctx, shape, x, y, {
|
|
595
638
|
fillColor: transparentFill,
|
|
@@ -598,30 +641,58 @@ export function renderHashArt(
|
|
|
598
641
|
size,
|
|
599
642
|
rotation,
|
|
600
643
|
proportionType: "GOLDEN_RATIO",
|
|
601
|
-
glowRadius,
|
|
602
|
-
glowColor: hasGlow
|
|
644
|
+
glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0),
|
|
645
|
+
glowColor: hasGlow
|
|
646
|
+
? hexWithAlpha(fillColor, 0.6)
|
|
647
|
+
: (shadowDist > 0 ? "rgba(0,0,0,0.08)" : undefined),
|
|
603
648
|
gradientFillEnd: gradientEnd,
|
|
604
649
|
renderStyle: finalRenderStyle,
|
|
605
650
|
rng,
|
|
606
651
|
});
|
|
607
652
|
|
|
608
|
-
shapePositions.push({ x, y, size });
|
|
653
|
+
shapePositions.push({ x, y, size, shape });
|
|
654
|
+
|
|
655
|
+
// ── 5b. Size echo — large shapes spawn trailing smaller copies ──
|
|
656
|
+
if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
|
|
657
|
+
const echoCount = 2 + Math.floor(rng() * 2);
|
|
658
|
+
const echoAngle = rng() * Math.PI * 2;
|
|
659
|
+
for (let e = 0; e < echoCount; e++) {
|
|
660
|
+
const echoScale = 0.3 - e * 0.08;
|
|
661
|
+
const echoDist = size * (0.6 + e * 0.4);
|
|
662
|
+
const echoX = x + Math.cos(echoAngle) * echoDist;
|
|
663
|
+
const echoY = y + Math.sin(echoAngle) * echoDist;
|
|
664
|
+
const echoSize = size * Math.max(0.1, echoScale);
|
|
665
|
+
|
|
666
|
+
if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
|
|
667
|
+
|
|
668
|
+
ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
|
|
669
|
+
enhanceShapeGeneration(ctx, shape, echoX, echoY, {
|
|
670
|
+
fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
|
|
671
|
+
strokeColor: hexWithAlpha(strokeColor, 0.4),
|
|
672
|
+
strokeWidth: strokeWidth * 0.6,
|
|
673
|
+
size: echoSize,
|
|
674
|
+
rotation: rotation + (e + 1) * 15,
|
|
675
|
+
proportionType: "GOLDEN_RATIO",
|
|
676
|
+
renderStyle: finalRenderStyle,
|
|
677
|
+
rng,
|
|
678
|
+
});
|
|
679
|
+
shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
609
682
|
|
|
610
|
-
// ──
|
|
683
|
+
// ── 5c. Recursive nesting ──────────────────────────────────
|
|
611
684
|
if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
|
|
612
685
|
const innerCount = 1 + Math.floor(rng() * 3);
|
|
613
686
|
for (let n = 0; n < innerCount; n++) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
shapeNames,
|
|
618
|
-
);
|
|
687
|
+
// Pick inner shape from palette affinities
|
|
688
|
+
const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
|
|
689
|
+
const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
|
|
619
690
|
const innerSize = size * (0.15 + rng() * 0.25);
|
|
620
691
|
const innerOffX = (rng() - 0.5) * size * 0.4;
|
|
621
692
|
const innerOffY = (rng() - 0.5) * size * 0.4;
|
|
622
693
|
const innerRot = rng() * 360;
|
|
623
694
|
const innerFill = hexWithAlpha(
|
|
624
|
-
|
|
695
|
+
jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
|
|
625
696
|
0.3 + rng() * 0.4,
|
|
626
697
|
);
|
|
627
698
|
|
|
@@ -638,7 +709,7 @@ export function renderHashArt(
|
|
|
638
709
|
size: innerSize,
|
|
639
710
|
rotation: innerRot,
|
|
640
711
|
proportionType: "GOLDEN_RATIO",
|
|
641
|
-
renderStyle:
|
|
712
|
+
renderStyle: pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle,
|
|
642
713
|
rng,
|
|
643
714
|
},
|
|
644
715
|
);
|
|
@@ -650,9 +721,11 @@ export function renderHashArt(
|
|
|
650
721
|
// Reset blend mode for post-processing passes
|
|
651
722
|
ctx.globalCompositeOperation = "source-over";
|
|
652
723
|
|
|
653
|
-
|
|
724
|
+
|
|
725
|
+
// ── 6. Flow-line pass — variable color, branching, pressure ────
|
|
654
726
|
const baseFlowLines = 6 + Math.floor(rng() * 10);
|
|
655
727
|
const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
|
|
728
|
+
|
|
656
729
|
for (let i = 0; i < numFlowLines; i++) {
|
|
657
730
|
let fx = rng() * width;
|
|
658
731
|
let fy = rng() * height;
|
|
@@ -660,13 +733,15 @@ export function renderHashArt(
|
|
|
660
733
|
const stepLen = (3 + rng() * 5) * scaleFactor;
|
|
661
734
|
const startWidth = (1 + rng() * 3) * scaleFactor;
|
|
662
735
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
);
|
|
736
|
+
// Variable color: interpolate between two hierarchy colors along the stroke
|
|
737
|
+
const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
|
|
738
|
+
const lineColorEnd = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
|
|
667
739
|
const lineAlpha = 0.06 + rng() * 0.1;
|
|
668
740
|
|
|
669
|
-
//
|
|
741
|
+
// Pressure simulation: sinusoidal width variation
|
|
742
|
+
const pressureFreq = 2 + rng() * 4;
|
|
743
|
+
const pressurePhase = rng() * Math.PI * 2;
|
|
744
|
+
|
|
670
745
|
let prevX = fx;
|
|
671
746
|
let prevY = fy;
|
|
672
747
|
for (let s = 0; s < steps; s++) {
|
|
@@ -676,11 +751,18 @@ export function renderHashArt(
|
|
|
676
751
|
|
|
677
752
|
if (fx < 0 || fx > width || fy < 0 || fy > height) break;
|
|
678
753
|
|
|
679
|
-
|
|
680
|
-
|
|
754
|
+
const t = s / steps;
|
|
755
|
+
// Taper + pressure
|
|
756
|
+
const taper = 1 - t * 0.8;
|
|
757
|
+
const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
|
|
758
|
+
|
|
681
759
|
ctx.globalAlpha = lineAlpha * taper;
|
|
760
|
+
// Interpolate color along stroke
|
|
761
|
+
const lineColor = t < 0.5
|
|
762
|
+
? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
|
|
763
|
+
: hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
|
|
682
764
|
ctx.strokeStyle = lineColor;
|
|
683
|
-
ctx.lineWidth = startWidth * taper;
|
|
765
|
+
ctx.lineWidth = startWidth * taper * pressure;
|
|
684
766
|
ctx.lineCap = "round";
|
|
685
767
|
|
|
686
768
|
ctx.beginPath();
|
|
@@ -688,28 +770,49 @@ export function renderHashArt(
|
|
|
688
770
|
ctx.lineTo(fx, fy);
|
|
689
771
|
ctx.stroke();
|
|
690
772
|
|
|
773
|
+
// Branching: ~12% chance per step to spawn a thinner child stroke
|
|
774
|
+
if (rng() < 0.12 && s > 5 && s < steps - 10) {
|
|
775
|
+
const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
|
|
776
|
+
let bx = fx;
|
|
777
|
+
let by = fy;
|
|
778
|
+
let bPrevX = fx;
|
|
779
|
+
let bPrevY = fy;
|
|
780
|
+
const branchSteps = 5 + Math.floor(rng() * 10);
|
|
781
|
+
const branchWidth = startWidth * taper * 0.4;
|
|
782
|
+
for (let bs = 0; bs < branchSteps; bs++) {
|
|
783
|
+
const bAngle = branchAngle + (rng() - 0.5) * 0.2;
|
|
784
|
+
bx += Math.cos(bAngle) * stepLen * 0.8;
|
|
785
|
+
by += Math.sin(bAngle) * stepLen * 0.8;
|
|
786
|
+
if (bx < 0 || bx > width || by < 0 || by > height) break;
|
|
787
|
+
const bTaper = 1 - (bs / branchSteps) * 0.9;
|
|
788
|
+
ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
|
|
789
|
+
ctx.lineWidth = branchWidth * bTaper;
|
|
790
|
+
ctx.beginPath();
|
|
791
|
+
ctx.moveTo(bPrevX, bPrevY);
|
|
792
|
+
ctx.lineTo(bx, by);
|
|
793
|
+
ctx.stroke();
|
|
794
|
+
bPrevX = bx;
|
|
795
|
+
bPrevY = by;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
691
799
|
prevX = fx;
|
|
692
800
|
prevY = fy;
|
|
693
801
|
}
|
|
694
802
|
}
|
|
695
803
|
|
|
696
804
|
// ── 6b. Apply symmetry mirroring ─────────────────────────────────
|
|
697
|
-
// Mirror the rendered content (shapes + flow lines) before post-processing.
|
|
698
|
-
// Uses ctx.canvas which is available in both Node (@napi-rs/canvas) and browsers.
|
|
699
805
|
if (symmetryMode !== "none") {
|
|
700
806
|
const canvas = ctx.canvas;
|
|
701
807
|
ctx.save();
|
|
702
808
|
if (symmetryMode === "bilateral-x" || symmetryMode === "quad") {
|
|
703
|
-
// Mirror left half onto right half
|
|
704
809
|
ctx.save();
|
|
705
810
|
ctx.translate(width, 0);
|
|
706
811
|
ctx.scale(-1, 1);
|
|
707
|
-
// Draw the left half (0 to cx) onto the mirrored right side
|
|
708
812
|
ctx.drawImage(canvas, 0, 0, Math.ceil(cx), height, 0, 0, Math.ceil(cx), height);
|
|
709
813
|
ctx.restore();
|
|
710
814
|
}
|
|
711
815
|
if (symmetryMode === "bilateral-y" || symmetryMode === "quad") {
|
|
712
|
-
// Mirror top half onto bottom half
|
|
713
816
|
ctx.save();
|
|
714
817
|
ctx.translate(0, height);
|
|
715
818
|
ctx.scale(1, -1);
|
|
@@ -719,6 +822,7 @@ export function renderHashArt(
|
|
|
719
822
|
ctx.restore();
|
|
720
823
|
}
|
|
721
824
|
|
|
825
|
+
|
|
722
826
|
// ── 7. Noise texture overlay ───────────────────────────────────
|
|
723
827
|
const noiseRng = createRng(seedFromHash(gitHash, 777));
|
|
724
828
|
const noiseDensity = Math.floor((width * height) / 800);
|
|
@@ -734,7 +838,7 @@ export function renderHashArt(
|
|
|
734
838
|
|
|
735
839
|
// ── 8. Vignette — darken edges to draw the eye inward ───────────
|
|
736
840
|
ctx.globalAlpha = 1;
|
|
737
|
-
const vignetteStrength = 0.25 + rng() * 0.2;
|
|
841
|
+
const vignetteStrength = 0.25 + rng() * 0.2;
|
|
738
842
|
const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
|
|
739
843
|
vigGrad.addColorStop(0, "rgba(0,0,0,0)");
|
|
740
844
|
vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
|
|
@@ -768,7 +872,7 @@ export function renderHashArt(
|
|
|
768
872
|
|
|
769
873
|
ctx.globalAlpha = 0.06 + rng() * 0.1;
|
|
770
874
|
ctx.strokeStyle = hexWithAlpha(
|
|
771
|
-
|
|
875
|
+
enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum),
|
|
772
876
|
0.3,
|
|
773
877
|
);
|
|
774
878
|
|
|
@@ -779,5 +883,47 @@ export function renderHashArt(
|
|
|
779
883
|
}
|
|
780
884
|
}
|
|
781
885
|
|
|
886
|
+
// ── 10. Post-processing ────────────────────────────────────────
|
|
887
|
+
|
|
888
|
+
// 10a. Color grading — unified tone across the whole image
|
|
889
|
+
// Apply as a semi-transparent overlay in the grade hue
|
|
890
|
+
ctx.globalAlpha = colorGrade.intensity * 0.25;
|
|
891
|
+
ctx.globalCompositeOperation = "soft-light";
|
|
892
|
+
const gradeHsl = `hsl(${Math.round(colorGrade.hue)}, 40%, 50%)`;
|
|
893
|
+
ctx.fillStyle = gradeHsl;
|
|
894
|
+
ctx.fillRect(0, 0, width, height);
|
|
895
|
+
ctx.globalCompositeOperation = "source-over";
|
|
896
|
+
|
|
897
|
+
// 10b. Chromatic aberration — subtle RGB channel offset at edges
|
|
898
|
+
// Only apply for neon/cosmic/ethereal archetypes where it fits
|
|
899
|
+
const chromaArchetypes = ["neon-glow", "cosmic", "ethereal"];
|
|
900
|
+
if (chromaArchetypes.includes(archetype.name)) {
|
|
901
|
+
const chromaOffset = Math.ceil(2 * scaleFactor);
|
|
902
|
+
const canvas = ctx.canvas;
|
|
903
|
+
// Shift red channel slightly
|
|
904
|
+
ctx.globalAlpha = 0.03;
|
|
905
|
+
ctx.globalCompositeOperation = "screen";
|
|
906
|
+
ctx.drawImage(canvas, chromaOffset, 0, width, height, 0, 0, width, height);
|
|
907
|
+
// Shift blue channel opposite
|
|
908
|
+
ctx.drawImage(canvas, -chromaOffset, 0, width, height, 0, 0, width, height);
|
|
909
|
+
ctx.globalCompositeOperation = "source-over";
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// 10c. Bloom — soft glow on bright areas for neon/cosmic archetypes
|
|
913
|
+
const bloomArchetypes = ["neon-glow", "cosmic"];
|
|
914
|
+
if (bloomArchetypes.includes(archetype.name)) {
|
|
915
|
+
const canvas = ctx.canvas;
|
|
916
|
+
ctx.globalAlpha = 0.08;
|
|
917
|
+
ctx.globalCompositeOperation = "screen";
|
|
918
|
+
// Draw the image slightly scaled up and blurred via shadow
|
|
919
|
+
ctx.save();
|
|
920
|
+
ctx.shadowBlur = 30 * scaleFactor;
|
|
921
|
+
ctx.shadowColor = "rgba(255,255,255,0.3)";
|
|
922
|
+
ctx.drawImage(canvas, 0, 0, width, height);
|
|
923
|
+
ctx.restore();
|
|
924
|
+
ctx.globalCompositeOperation = "source-over";
|
|
925
|
+
}
|
|
926
|
+
|
|
782
927
|
ctx.globalAlpha = 1;
|
|
928
|
+
|
|
783
929
|
}
|