git-hash-art 0.7.0 → 0.9.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/src/lib/render.ts CHANGED
@@ -5,59 +5,56 @@
5
5
  * identically in Node (@napi-rs/canvas) and browsers.
6
6
  *
7
7
  * Generation pipeline:
8
- * 1. Background radial gradient from hash-derived dark palette
9
- * 1b. Layered background large faint shapes / subtle pattern for depth
10
- * 2. Composition modehash selects: radial, flow-field, spiral, grid-subdivision, or clustered
11
- * 3. Focal points + void zones (negative space)
12
- * 4. Flow field seed values
13
- * 5. Shape layers — blend modes, render styles, weighted selection,
14
- * focal-point placement, atmospheric depth, organic edges
8
+ * 0. Archetype selection + shape palette + color hierarchy
9
+ * 1. Backgroundstyle 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-line passtapered brush-stroke curves
17
- * 7. Noise texture overlay
18
- * 8. Organic connecting curves
18
+ * 6. Flow linesvariable 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,
26
30
  luminance,
27
31
  enforceContrast,
32
+ buildColorHierarchy,
33
+ pickHierarchyColor, pickColorGrade,
34
+ type ColorHierarchy
28
35
  } from "./canvas/colors";
29
36
  import {
30
37
  enhanceShapeGeneration,
38
+ drawMirroredShape,
39
+ pickMirrorAxis,
31
40
  pickBlendMode,
32
41
  pickRenderStyle,
33
- type RenderStyle,
42
+ type RenderStyle
34
43
  } from "./canvas/draw";
35
44
  import { shapes } from "./canvas/shapes";
45
+ import {
46
+ buildShapePalette,
47
+ pickShapeFromPalette,
48
+ pickStyleForShape,
49
+ SHAPE_PROFILES
50
+ } from "./canvas/shapes/affinity";
36
51
  import { createRng, seedFromHash } from "./utils";
37
52
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
38
53
  import { selectArchetype, type BackgroundStyle } from "./archetypes";
39
54
 
40
- // ── Shape categories for weighted selection ─────────────────────────
41
55
 
42
- const BASIC_SHAPES = [
43
- "circle",
44
- "square",
45
- "triangle",
46
- "hexagon",
47
- "diamond",
48
- "cube",
49
- ];
50
- const COMPLEX_SHAPES = [
51
- "star",
52
- "jacked-star",
53
- "heart",
54
- "platonicSolid",
55
- "fibonacciSpiral",
56
- "islamicPattern",
57
- "celticKnot",
58
- "merkaba",
59
- "fractal",
60
- ];
56
+ // ── Shape categories for weighted selection (legacy fallback) ───────
57
+
61
58
  const SACRED_SHAPES = [
62
59
  "mandala",
63
60
  "flowerOfLife",
@@ -69,15 +66,6 @@ const SACRED_SHAPES = [
69
66
  "torus",
70
67
  "eggOfLife",
71
68
  ];
72
- const PROCEDURAL_SHAPES = [
73
- "blob",
74
- "ngon",
75
- "lissajous",
76
- "superellipse",
77
- "spirograph",
78
- "waveRing",
79
- "rose",
80
- ];
81
69
 
82
70
  // ── Composition modes ───────────────────────────────────────────────
83
71
 
@@ -96,33 +84,6 @@ const COMPOSITION_MODES: CompositionMode[] = [
96
84
  "clustered",
97
85
  ];
98
86
 
99
- // ── Helper: pick shape with layer-aware weighting ───────────────────
100
-
101
- function pickShape(
102
- rng: () => number,
103
- layerRatio: number,
104
- shapeNames: string[],
105
- ): string {
106
- const basicW = 1 - layerRatio * 0.6;
107
- const complexW = 0.3 + layerRatio * 0.3;
108
- const sacredW = 0.1 + layerRatio * 0.4;
109
- const proceduralW = 0.25 + layerRatio * 0.2; // always present, grows with depth
110
- const total = basicW + complexW + sacredW + proceduralW;
111
- const roll = rng() * total;
112
-
113
- let pool: string[];
114
- if (roll < basicW) pool = BASIC_SHAPES;
115
- else if (roll < basicW + complexW) pool = COMPLEX_SHAPES;
116
- else if (roll < basicW + complexW + sacredW) pool = SACRED_SHAPES;
117
- else pool = PROCEDURAL_SHAPES;
118
-
119
- const available = pool.filter((s) => shapeNames.includes(s));
120
- if (available.length === 0) {
121
- return shapeNames[Math.floor(rng() * shapeNames.length)];
122
- }
123
- return available[Math.floor(rng() * available.length)];
124
- }
125
-
126
87
  // ── Helper: get position based on composition mode ──────────────────
127
88
 
128
89
  function getCompositionPosition(
@@ -180,24 +141,33 @@ function getCompositionPosition(
180
141
  }
181
142
  }
182
143
 
183
- // ── Helper: positional color blending ───────────────────────────────
144
+ // ── Helper: positional color from hierarchy ─────────────────────────
184
145
 
185
146
  function getPositionalColor(
186
147
  x: number,
187
148
  y: number,
188
149
  width: number,
189
150
  height: number,
190
- colors: string[],
151
+ hierarchy: ColorHierarchy,
191
152
  rng: () => number,
192
153
  ): string {
193
- const nx = x / width;
194
- const ny = y / height;
195
- const posIndex = (nx * 0.6 + ny * 0.4) * (colors.length - 1);
196
- const baseIdx = Math.floor(posIndex) % colors.length;
197
- return jitterColor(colors[baseIdx], rng, 0.08);
154
+ // Blend position into color selection — shapes near center lean dominant
155
+ const distFromCenter = Math.hypot(x - width / 2, y - height / 2) /
156
+ Math.hypot(width / 2, height / 2);
157
+ // Center = more dominant, edges = more accent
158
+ if (distFromCenter < 0.35) {
159
+ return jitterColorHSL(hierarchy.dominant, rng, 10, 0.08);
160
+ } else if (distFromCenter < 0.7) {
161
+ return jitterColorHSL(pickHierarchyColor(hierarchy, rng), rng, 8, 0.06);
162
+ } else {
163
+ // Edges: bias toward secondary/accent
164
+ const roll = rng();
165
+ const color = roll < 0.4 ? hierarchy.secondary : roll < 0.75 ? hierarchy.accent : hierarchy.dominant;
166
+ return jitterColorHSL(color, rng, 12, 0.08);
167
+ }
198
168
  }
199
169
 
200
- // ── Helper: check if a position is inside a void zone (Feature E) ───
170
+ // ── Helper: check if a position is inside a void zone ───────────────
201
171
 
202
172
  function isInVoidZone(
203
173
  x: number,
@@ -210,7 +180,7 @@ function isInVoidZone(
210
180
  return false;
211
181
  }
212
182
 
213
- // ── Helper: density check for negative space (Feature E) ────────────
183
+ // ── Helper: density check ───────────────────────────────────────────
214
184
 
215
185
  function localDensity(
216
186
  x: number,
@@ -304,6 +274,92 @@ function drawBackground(
304
274
  }
305
275
  }
306
276
 
277
+ // ── Shape constellations — pre-composed groups of shapes ────────
278
+
279
+ interface ConstellationDef {
280
+ name: string;
281
+ /** Generate member positions/shapes relative to center */
282
+ build: (rng: () => number, baseSize: number) => Array<{
283
+ dx: number; dy: number; shape: string; size: number; rotation: number;
284
+ }>;
285
+ }
286
+
287
+ const CONSTELLATIONS: ConstellationDef[] = [
288
+ {
289
+ name: "flanked-triangle",
290
+ build: (rng, baseSize) => {
291
+ const gap = baseSize * (0.6 + rng() * 0.3);
292
+ return [
293
+ { dx: 0, dy: 0, shape: "triangle", size: baseSize, rotation: rng() * 360 },
294
+ { dx: -gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
295
+ { dx: gap, dy: gap * 0.3, shape: "circle", size: baseSize * 0.35, rotation: 0 },
296
+ ];
297
+ },
298
+ },
299
+ {
300
+ name: "hexagon-ring",
301
+ build: (rng, baseSize) => {
302
+ const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
303
+ const count = 5 + Math.floor(rng() * 2);
304
+ const ringR = baseSize * 0.6;
305
+ for (let i = 0; i < count; i++) {
306
+ const angle = (i / count) * Math.PI * 2;
307
+ members.push({
308
+ dx: Math.cos(angle) * ringR,
309
+ dy: Math.sin(angle) * ringR,
310
+ shape: "hexagon",
311
+ size: baseSize * (0.25 + rng() * 0.1),
312
+ rotation: (angle * 180) / Math.PI,
313
+ });
314
+ }
315
+ return members;
316
+ },
317
+ },
318
+ {
319
+ name: "spiral-dots",
320
+ build: (rng, baseSize) => {
321
+ const members: Array<{ dx: number; dy: number; shape: string; size: number; rotation: number }> = [];
322
+ const count = 7 + Math.floor(rng() * 5);
323
+ const turns = 1.5 + rng();
324
+ for (let i = 0; i < count; i++) {
325
+ const t = i / count;
326
+ const angle = t * Math.PI * 2 * turns;
327
+ const r = t * baseSize * 0.7;
328
+ members.push({
329
+ dx: Math.cos(angle) * r,
330
+ dy: Math.sin(angle) * r,
331
+ shape: "circle",
332
+ size: baseSize * (0.08 + (1 - t) * 0.12),
333
+ rotation: 0,
334
+ });
335
+ }
336
+ return members;
337
+ },
338
+ },
339
+ {
340
+ name: "diamond-cluster",
341
+ build: (rng, baseSize) => {
342
+ const gap = baseSize * 0.45;
343
+ return [
344
+ { dx: 0, dy: -gap, shape: "diamond", size: baseSize * 0.4, rotation: 0 },
345
+ { dx: gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: 15 },
346
+ { dx: 0, dy: gap, shape: "diamond", size: baseSize * 0.3, rotation: 30 },
347
+ { dx: -gap, dy: 0, shape: "diamond", size: baseSize * 0.35, rotation: -15 },
348
+ ];
349
+ },
350
+ },
351
+ {
352
+ name: "crescent-pair",
353
+ build: (rng, baseSize) => {
354
+ const gap = baseSize * 0.5;
355
+ return [
356
+ { dx: -gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.5, rotation: rng() * 30 },
357
+ { dx: gap * 0.4, dy: 0, shape: "crescent", size: baseSize * 0.45, rotation: 180 + rng() * 30 },
358
+ ];
359
+ },
360
+ },
361
+ ];
362
+
307
363
  // ── Main render function ────────────────────────────────────────────
308
364
 
309
365
  export function renderHashArt(
@@ -340,7 +396,19 @@ export function renderHashArt(
340
396
  const fgTempTarget: "warm" | "cool" | null =
341
397
  tempMode === "warm-bg" ? "cool" : tempMode === "cool-bg" ? "warm" : null;
342
398
 
399
+ // ── 0b. Color hierarchy — dominant/secondary/accent weighting ──
400
+ const colorHierarchy = buildColorHierarchy(colors, rng);
401
+
402
+ // ── 0c. Shape palette — curated shapes that work well together ──
343
403
  const shapeNames = Object.keys(shapes);
404
+ const shapePalette = buildShapePalette(rng, shapeNames, archetype.name);
405
+
406
+ // ── 0d. Color grading — unified tone for the whole image ───────
407
+ const colorGrade = pickColorGrade(rng);
408
+
409
+ // ── 0e. Light direction — consistent shadow angle ──────────────
410
+ const lightAngle = rng() * Math.PI * 2;
411
+
344
412
  const scaleFactor = Math.min(width, height) / 1024;
345
413
  const adjustedMinSize = minShapeSize * scaleFactor;
346
414
  const adjustedMaxSize = maxShapeSize * scaleFactor;
@@ -352,28 +420,50 @@ export function renderHashArt(
352
420
  const bgRadius = Math.hypot(cx, cy);
353
421
  drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
354
422
 
423
+ // Gradient mesh overlay — 3-4 color control points for richer backgrounds
424
+ const meshPoints = 3 + Math.floor(rng() * 2);
425
+ ctx.globalCompositeOperation = "soft-light";
426
+ for (let i = 0; i < meshPoints; i++) {
427
+ const mx = rng() * width;
428
+ const my = rng() * height;
429
+ const mRadius = Math.min(width, height) * (0.3 + rng() * 0.4);
430
+ const mColor = pickHierarchyColor(colorHierarchy, rng);
431
+ const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
432
+ grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06));
433
+ grad.addColorStop(1, "rgba(0,0,0,0)");
434
+ ctx.globalAlpha = 1;
435
+ ctx.fillStyle = grad;
436
+ ctx.fillRect(0, 0, width, height);
437
+ }
438
+ ctx.globalCompositeOperation = "source-over";
439
+
355
440
  // Compute average background luminance for contrast enforcement
356
441
  const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2;
357
442
 
358
- // ── 1b. Layered background (Feature G) ─────────────────────────
359
- // Draw large, very faint shapes to give the background texture
443
+ // ── 1b. Layered background archetype-coherent shapes ─────────
360
444
  const bgShapeCount = 3 + Math.floor(rng() * 4);
361
445
  ctx.globalCompositeOperation = "soft-light";
362
446
  for (let i = 0; i < bgShapeCount; i++) {
363
447
  const bx = rng() * width;
364
448
  const by = rng() * height;
365
449
  const bSize = (width * 0.3 + rng() * width * 0.5);
366
- const bColor = colors[Math.floor(rng() * colors.length)];
450
+ const bColor = pickHierarchyColor(colorHierarchy, rng);
367
451
  ctx.globalAlpha = 0.03 + rng() * 0.05;
368
452
  ctx.fillStyle = hexWithAlpha(bColor, 0.15);
369
453
  ctx.beginPath();
370
- ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
454
+ // Use archetype-appropriate background shapes
455
+ if (archetype.name === "geometric-precision" || archetype.name === "op-art") {
456
+ // Rectangular shapes for geometric archetypes
457
+ ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
458
+ } else {
459
+ ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
460
+ }
371
461
  ctx.fill();
372
462
  }
373
463
  // Subtle concentric rings from center
374
464
  const ringCount = 2 + Math.floor(rng() * 3);
375
465
  ctx.globalAlpha = 0.02 + rng() * 0.03;
376
- ctx.strokeStyle = hexWithAlpha(colors[0], 0.1);
466
+ ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
377
467
  ctx.lineWidth = 1 * scaleFactor;
378
468
  for (let i = 1; i <= ringCount; i++) {
379
469
  const r = (Math.min(width, height) * 0.15) * i;
@@ -383,6 +473,69 @@ export function renderHashArt(
383
473
  }
384
474
  ctx.globalCompositeOperation = "source-over";
385
475
 
476
+ // ── 1c. Background pattern layer — subtle textured paper ───────
477
+ const bgPatternRoll = rng();
478
+ if (bgPatternRoll < 0.6) {
479
+ ctx.save();
480
+ ctx.globalCompositeOperation = "soft-light";
481
+ const patternOpacity = 0.02 + rng() * 0.04;
482
+ const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
483
+
484
+ if (bgPatternRoll < 0.2) {
485
+ // Dot grid
486
+ const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
487
+ const dotR = dotSpacing * 0.08;
488
+ ctx.globalAlpha = patternOpacity;
489
+ ctx.fillStyle = patternColor;
490
+ for (let px = 0; px < width; px += dotSpacing) {
491
+ for (let py = 0; py < height; py += dotSpacing) {
492
+ ctx.beginPath();
493
+ ctx.arc(px, py, dotR, 0, Math.PI * 2);
494
+ ctx.fill();
495
+ }
496
+ }
497
+ } else if (bgPatternRoll < 0.4) {
498
+ // Diagonal lines
499
+ const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
500
+ ctx.globalAlpha = patternOpacity;
501
+ ctx.strokeStyle = patternColor;
502
+ ctx.lineWidth = 0.5 * scaleFactor;
503
+ const diag = Math.hypot(width, height);
504
+ for (let d = -diag; d < diag; d += lineSpacing) {
505
+ ctx.beginPath();
506
+ ctx.moveTo(d, 0);
507
+ ctx.lineTo(d + height, height);
508
+ ctx.stroke();
509
+ }
510
+ } else {
511
+ // Tessellation — hexagonal grid of tiny shapes
512
+ const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
513
+ const tessH = tessSize * Math.sqrt(3);
514
+ ctx.globalAlpha = patternOpacity * 0.7;
515
+ ctx.strokeStyle = patternColor;
516
+ ctx.lineWidth = 0.4 * scaleFactor;
517
+ for (let row = 0; row * tessH < height + tessH; row++) {
518
+ const offsetX = (row % 2) * tessSize * 0.75;
519
+ for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
520
+ const hx = col * tessSize * 1.5 + offsetX;
521
+ const hy = row * tessH;
522
+ ctx.beginPath();
523
+ for (let s = 0; s < 6; s++) {
524
+ const angle = (Math.PI / 3) * s - Math.PI / 6;
525
+ const vx = hx + Math.cos(angle) * tessSize * 0.5;
526
+ const vy = hy + Math.sin(angle) * tessSize * 0.5;
527
+ if (s === 0) ctx.moveTo(vx, vy);
528
+ else ctx.lineTo(vx, vy);
529
+ }
530
+ ctx.closePath();
531
+ ctx.stroke();
532
+ }
533
+ }
534
+ }
535
+ ctx.restore();
536
+ }
537
+ ctx.globalCompositeOperation = "source-over";
538
+
386
539
  // ── 2. Composition mode ────────────────────────────────────────
387
540
  const compositionMode =
388
541
  COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
@@ -396,7 +549,6 @@ export function renderHashArt(
396
549
  symRoll < 0.25 ? "quad" : "none";
397
550
 
398
551
  // ── 3. Focal points + void zones ───────────────────────────────
399
- // Rule-of-thirds intersection points for intentional composition
400
552
  const THIRDS_POINTS = [
401
553
  { x: 1 / 3, y: 1 / 3 },
402
554
  { x: 2 / 3, y: 1 / 3 },
@@ -406,10 +558,8 @@ export function renderHashArt(
406
558
  const numFocal = 1 + Math.floor(rng() * 2);
407
559
  const focalPoints: Array<{ x: number; y: number; strength: number }> = [];
408
560
  for (let f = 0; f < numFocal; f++) {
409
- // 70% chance to snap to a rule-of-thirds point, 30% free placement
410
561
  if (rng() < 0.7) {
411
562
  const tp = THIRDS_POINTS[Math.floor(rng() * THIRDS_POINTS.length)];
412
- // Small jitter around the thirds point so it's not robotic
413
563
  focalPoints.push({
414
564
  x: width * (tp.x + (rng() - 0.5) * 0.08),
415
565
  y: height * (tp.y + (rng() - 0.5) * 0.08),
@@ -424,7 +574,6 @@ export function renderHashArt(
424
574
  }
425
575
  }
426
576
 
427
- // Feature E: 1-2 void zones where shapes are sparse (negative space)
428
577
  const numVoids = Math.floor(rng() * 2) + 1;
429
578
  const voidZones: Array<{ x: number; y: number; radius: number }> = [];
430
579
  for (let v = 0; v < numVoids; v++) {
@@ -462,24 +611,34 @@ export function renderHashArt(
462
611
  }
463
612
 
464
613
  // Track all placed shapes for density checks and connecting curves
465
- const shapePositions: Array<{ x: number; y: number; size: number }> = [];
614
+ const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
615
+
616
+ // Hero avoidance radius — shapes near the hero orient toward it
617
+ let heroCenter: { x: number; y: number; size: number } | null = null;
466
618
 
467
619
  // ── 4b. Hero shape — a dominant focal element ───────────────────
468
620
  if (archetype.heroShape && rng() < 0.6) {
469
621
  const heroFocal = focalPoints[0];
470
- const heroPool = [...SACRED_SHAPES, "fibonacciSpiral", "merkaba", "fractal"];
471
- const heroShape =
472
- heroPool.filter((s) => shapeNames.includes(s))[
473
- Math.floor(rng() * heroPool.filter((s) => shapeNames.includes(s)).length)
474
- ] || shapeNames[Math.floor(rng() * shapeNames.length)];
622
+ // Use shape palette hero candidates
623
+ const heroPool = [...shapePalette.primary, ...shapePalette.supporting]
624
+ .filter((s) => SHAPE_PROFILES[s]?.heroCandidate && shapeNames.includes(s));
625
+ const heroShape = heroPool.length > 0
626
+ ? heroPool[Math.floor(rng() * heroPool.length)]
627
+ : shapeNames[Math.floor(rng() * shapeNames.length)];
475
628
 
476
629
  const heroSize = adjustedMaxSize * (0.8 + rng() * 0.5);
477
630
  const heroRotation = rng() * 360;
478
631
  const heroFill = hexWithAlpha(
479
- enforceContrast(jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.05), bgLum),
632
+ enforceContrast(jitterColorHSL(colorHierarchy.dominant, rng, 6, 0.05), bgLum),
480
633
  0.15 + rng() * 0.2,
481
634
  );
482
- const heroStroke = enforceContrast(jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.05), bgLum);
635
+ const heroStroke = enforceContrast(jitterColorHSL(colorHierarchy.accent, rng, 6, 0.05), bgLum);
636
+
637
+ // Get best style for this hero shape
638
+ const heroProfile = SHAPE_PROFILES[heroShape];
639
+ const heroStyle: RenderStyle = heroProfile
640
+ ? (heroProfile.bestStyles[Math.floor(rng() * heroProfile.bestStyles.length)] as RenderStyle)
641
+ : (rng() < 0.4 ? "watercolor" : "fill-and-stroke");
483
642
 
484
643
  ctx.globalAlpha = 0.5 + rng() * 0.2;
485
644
  enhanceShapeGeneration(ctx, heroShape, heroFocal.x, heroFocal.y, {
@@ -491,14 +650,16 @@ export function renderHashArt(
491
650
  proportionType: "GOLDEN_RATIO",
492
651
  glowRadius: (12 + rng() * 20) * scaleFactor,
493
652
  glowColor: hexWithAlpha(heroStroke, 0.4),
494
- gradientFillEnd: jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1),
495
- renderStyle: rng() < 0.4 ? "watercolor" : "fill-and-stroke",
653
+ gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
654
+ renderStyle: heroStyle,
496
655
  rng,
497
656
  });
498
657
 
499
- shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize });
658
+ heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
659
+ shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
500
660
  }
501
661
 
662
+
502
663
  // ── 5. Shape layers ────────────────────────────────────────────
503
664
  const densityCheckRadius = Math.min(width, height) * 0.08;
504
665
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
@@ -511,17 +672,23 @@ export function renderHashArt(
511
672
  const layerOpacity = Math.max(0.15, baseOpacity - layer * opacityReduction);
512
673
  const layerSizeScale = 1 - layer * 0.15;
513
674
 
514
- // Feature B: per-layer blend mode
675
+ // Per-layer blend mode
515
676
  const layerBlend = pickBlendMode(rng);
516
677
  ctx.globalCompositeOperation = layerBlend;
517
678
 
518
- // Feature C: per-layer render style bias — prefer archetype styles
679
+ // Per-layer render style bias — prefer archetype styles
519
680
  const layerRenderStyle: RenderStyle = rng() < 0.6
520
681
  ? archetype.preferredStyles[Math.floor(rng() * archetype.preferredStyles.length)]
521
682
  : pickRenderStyle(rng);
522
683
 
523
- // Feature D: atmospheric desaturation for later layers
524
- const atmosphericDesat = layerRatio * 0.3; // 0 for first layer, up to 0.3 for last
684
+ // Atmospheric desaturation for later layers
685
+ const atmosphericDesat = layerRatio * 0.3;
686
+
687
+ // Depth-of-field simulation — later layers are "further away"
688
+ // Reduce stroke widths and shift colors toward the background
689
+ const dofFactor = 1 - layerRatio * 0.5; // 1.0 for front layer, 0.5 for back
690
+ const dofStrokeScale = 0.4 + dofFactor * 0.6; // strokes thin out with depth
691
+ const dofContrastReduction = layerRatio * 0.2; // colors fade toward bg
525
692
 
526
693
  for (let i = 0; i < numShapes; i++) {
527
694
  // Position from composition mode, then focal bias
@@ -537,35 +704,48 @@ export function renderHashArt(
537
704
  );
538
705
  const [x, y] = applyFocalBias(rawPos.x, rawPos.y);
539
706
 
540
- // Feature E: skip shapes in void zones, reduce in dense areas
707
+ // Skip shapes in void zones, reduce in dense areas
541
708
  if (isInVoidZone(x, y, voidZones)) {
542
- // 85% chance to skip — allows a few shapes to bleed in
543
709
  if (rng() < 0.85) continue;
544
710
  }
545
711
  if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
546
- if (rng() < 0.6) continue; // thin out dense areas
712
+ if (rng() < 0.6) continue;
547
713
  }
548
714
 
549
- // Weighted shape selection
550
- const shape = pickShape(rng, layerRatio, shapeNames);
551
-
552
715
  // Power distribution for size — archetype controls the curve
553
716
  const sizeT = Math.pow(rng(), archetype.sizePower);
554
717
  const size =
555
718
  (adjustedMinSize + sizeT * (adjustedMaxSize - adjustedMinSize)) *
556
719
  layerSizeScale;
557
720
 
721
+ // Size fraction for affinity-aware shape selection
722
+ const sizeFraction = size / adjustedMaxSize;
723
+
724
+ // Palette-driven shape selection (replaces naive pickShape)
725
+ const shape = pickShapeFromPalette(shapePalette, rng, sizeFraction);
726
+
558
727
  // Flow-field rotation in flow-field mode, random otherwise
559
- const rotation =
728
+ let rotation =
560
729
  compositionMode === "flow-field"
561
730
  ? (flowAngle(x, y) * 180) / Math.PI + (rng() - 0.5) * 30
562
731
  : rng() * 360;
563
732
 
564
- // Positional color blending + jitter
565
- let fillBase = getPositionalColor(x, y, width, height, colors, rng);
566
- const strokeBase = colors[Math.floor(rng() * colors.length)];
733
+ // Hero avoidance: shapes near the hero orient toward it
734
+ if (heroCenter) {
735
+ const distToHero = Math.hypot(x - heroCenter.x, y - heroCenter.y);
736
+ const heroInfluence = heroCenter.size * 1.5;
737
+ if (distToHero < heroInfluence && distToHero > 0) {
738
+ const angleToHero = Math.atan2(heroCenter.y - y, heroCenter.x - x) * 180 / Math.PI;
739
+ const blendFactor = 1 - (distToHero / heroInfluence);
740
+ rotation = rotation + (angleToHero - rotation) * blendFactor * 0.4;
741
+ }
742
+ }
567
743
 
568
- // Feature D: desaturate colors on later layers for depth
744
+ // Positional color from hierarchy + jitter
745
+ let fillBase = getPositionalColor(x, y, width, height, colorHierarchy, rng);
746
+ const strokeBase = pickHierarchyColor(colorHierarchy, rng);
747
+
748
+ // Desaturate colors on later layers for depth
569
749
  if (atmosphericDesat > 0) {
570
750
  fillBase = desaturate(fillBase, atmosphericDesat);
571
751
  }
@@ -575,16 +755,18 @@ export function renderHashArt(
575
755
  fillBase = shiftTemperature(fillBase, fgTempTarget, 0.15 + layerRatio * 0.1);
576
756
  }
577
757
 
578
- const fillColor = enforceContrast(jitterColor(fillBase, rng, 0.06), bgLum);
579
- const strokeColor = enforceContrast(jitterColor(strokeBase, rng, 0.05), bgLum);
758
+ const fillColor = enforceContrast(jitterColorHSL(fillBase, rng, 6, 0.05), bgLum);
759
+ const strokeColor = enforceContrast(jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum);
580
760
 
581
761
  // Semi-transparent fill
582
762
  const fillAlpha = 0.2 + rng() * 0.5;
583
763
  const transparentFill = hexWithAlpha(fillColor, fillAlpha);
584
764
 
585
- const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor;
765
+ const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor * dofStrokeScale;
586
766
 
587
- ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5);
767
+ // Depth-of-field: reduce opacity slightly for distant layers
768
+ const dofOpacityScale = 1 - dofContrastReduction;
769
+ ctx.globalAlpha = layerOpacity * (0.5 + rng() * 0.5) * dofOpacityScale;
588
770
 
589
771
  // Glow on sacred shapes more often — scaled by archetype
590
772
  const isSacred = SACRED_SHAPES.includes(shape);
@@ -596,48 +778,124 @@ export function renderHashArt(
596
778
  // Gradient fill on ~30%
597
779
  const hasGradient = rng() < 0.3;
598
780
  const gradientEnd = hasGradient
599
- ? jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1)
781
+ ? jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1)
600
782
  : undefined;
601
783
 
602
- // Feature C: per-shape render style (70% use layer style, 30% pick their own)
603
- const shapeRenderStyle =
604
- rng() < 0.7 ? layerRenderStyle : pickRenderStyle(rng);
784
+ // Affinity-aware render style selection
785
+ const shapeRenderStyle = pickStyleForShape(shape, layerRenderStyle, rng) as RenderStyle;
605
786
 
606
- // Feature F: organic edge jitter — applied via watercolor style on ~15% of shapes
787
+ // Organic edge jitter — applied via watercolor style on ~15% of shapes
607
788
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
608
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
789
+ const finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
790
+
791
+ // Consistent light direction — subtle shadow offset
792
+ const shadowDist = hasGlow ? 0 : (size * 0.02);
793
+ const shadowOffX = shadowDist * Math.cos(lightAngle);
794
+ const shadowOffY = shadowDist * Math.sin(lightAngle);
795
+
796
+ // ── 5a. Tangent placement — nudge toward nearest shape edge ──
797
+ let finalX = x;
798
+ let finalY = y;
799
+ if (shapePositions.length > 0 && rng() < 0.25) {
800
+ // Find nearest placed shape
801
+ let nearestDist = Infinity;
802
+ let nearestPos: { x: number; y: number; size: number } | null = null;
803
+ for (const sp of shapePositions) {
804
+ const d = Math.hypot(x - sp.x, y - sp.y);
805
+ if (d < nearestDist && d > 0) {
806
+ nearestDist = d;
807
+ nearestPos = sp;
808
+ }
809
+ }
810
+ if (nearestPos) {
811
+ // Target distance: edges kissing (sum of half-sizes)
812
+ const targetDist = (size + nearestPos.size) * 0.5;
813
+ if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
814
+ const angle = Math.atan2(y - nearestPos.y, x - nearestPos.x);
815
+ finalX = nearestPos.x + Math.cos(angle) * targetDist;
816
+ finalY = nearestPos.y + Math.sin(angle) * targetDist;
817
+ // Keep in bounds
818
+ finalX = Math.max(0, Math.min(width, finalX));
819
+ finalY = Math.max(0, Math.min(height, finalY));
820
+ }
821
+ }
822
+ }
823
+
824
+ // ── 5b. Shape mirroring — basic shapes get reflected copies ──
825
+ const mirrorAxis = pickMirrorAxis(rng);
826
+ const isBasicShape = ["circle", "triangle", "square", "hexagon", "star",
827
+ "diamond", "crescent", "penroseTile", "reuleauxTriangle"].includes(shape);
828
+ const shouldMirror = mirrorAxis !== null && isBasicShape && size > adjustedMaxSize * 0.2;
609
829
 
610
- enhanceShapeGeneration(ctx, shape, x, y, {
830
+ const shapeConfig = {
611
831
  fillColor: transparentFill,
612
832
  strokeColor,
613
833
  strokeWidth,
614
834
  size,
615
835
  rotation,
616
- proportionType: "GOLDEN_RATIO",
617
- glowRadius,
618
- glowColor: hasGlow ? hexWithAlpha(fillColor, 0.6) : undefined,
836
+ proportionType: "GOLDEN_RATIO" as const,
837
+ glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0),
838
+ glowColor: hasGlow
839
+ ? hexWithAlpha(fillColor, 0.6)
840
+ : (shadowDist > 0 ? "rgba(0,0,0,0.08)" : undefined),
619
841
  gradientFillEnd: gradientEnd,
620
842
  renderStyle: finalRenderStyle,
621
843
  rng,
622
- });
844
+ };
623
845
 
624
- shapePositions.push({ x, y, size });
846
+ if (shouldMirror) {
847
+ drawMirroredShape(ctx, shape, finalX, finalY, {
848
+ ...shapeConfig,
849
+ mirrorAxis: mirrorAxis!,
850
+ mirrorGap: size * (0.1 + rng() * 0.3),
851
+ });
852
+ } else {
853
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
854
+ }
625
855
 
626
- // ── 5b. Recursive nesting ──────────────────────────────────
856
+ shapePositions.push({ x: finalX, y: finalY, size, shape });
857
+
858
+ // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
859
+ if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
860
+ const echoCount = 2 + Math.floor(rng() * 2);
861
+ const echoAngle = rng() * Math.PI * 2;
862
+ for (let e = 0; e < echoCount; e++) {
863
+ const echoScale = 0.3 - e * 0.08;
864
+ const echoDist = size * (0.6 + e * 0.4);
865
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
866
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
867
+ const echoSize = size * Math.max(0.1, echoScale);
868
+
869
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
870
+
871
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
872
+ enhanceShapeGeneration(ctx, shape, echoX, echoY, {
873
+ fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
874
+ strokeColor: hexWithAlpha(strokeColor, 0.4),
875
+ strokeWidth: strokeWidth * 0.6,
876
+ size: echoSize,
877
+ rotation: rotation + (e + 1) * 15,
878
+ proportionType: "GOLDEN_RATIO",
879
+ renderStyle: finalRenderStyle,
880
+ rng,
881
+ });
882
+ shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
883
+ }
884
+ }
885
+
886
+ // ── 5d. Recursive nesting ──────────────────────────────────
627
887
  if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
628
888
  const innerCount = 1 + Math.floor(rng() * 3);
629
889
  for (let n = 0; n < innerCount; n++) {
630
- const innerShape = pickShape(
631
- rng,
632
- Math.min(1, layerRatio + 0.3),
633
- shapeNames,
634
- );
890
+ // Pick inner shape from palette affinities
891
+ const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
892
+ const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
635
893
  const innerSize = size * (0.15 + rng() * 0.25);
636
894
  const innerOffX = (rng() - 0.5) * size * 0.4;
637
895
  const innerOffY = (rng() - 0.5) * size * 0.4;
638
896
  const innerRot = rng() * 360;
639
897
  const innerFill = hexWithAlpha(
640
- jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1),
898
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
641
899
  0.3 + rng() * 0.4,
642
900
  );
643
901
 
@@ -645,8 +903,8 @@ export function renderHashArt(
645
903
  enhanceShapeGeneration(
646
904
  ctx,
647
905
  innerShape,
648
- x + innerOffX,
649
- y + innerOffY,
906
+ finalX + innerOffX,
907
+ finalY + innerOffY,
650
908
  {
651
909
  fillColor: innerFill,
652
910
  strokeColor: hexWithAlpha(strokeColor, 0.5),
@@ -654,21 +912,66 @@ export function renderHashArt(
654
912
  size: innerSize,
655
913
  rotation: innerRot,
656
914
  proportionType: "GOLDEN_RATIO",
657
- renderStyle: shapeRenderStyle,
915
+ renderStyle: pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle,
658
916
  rng,
659
917
  },
660
918
  );
661
919
  }
662
920
  }
921
+
922
+ // ── 5e. Shape constellations — pre-composed groups ─────────
923
+ if (size > adjustedMaxSize * 0.35 && rng() < 0.12) {
924
+ const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
925
+ const members = constellation.build(rng, size);
926
+ const groupRotation = rng() * Math.PI * 2;
927
+ const cosR = Math.cos(groupRotation);
928
+ const sinR = Math.sin(groupRotation);
929
+
930
+ for (const member of members) {
931
+ // Rotate the group offset by the group rotation
932
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
933
+ const my = finalY + member.dx * sinR + member.dy * cosR;
934
+
935
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
936
+
937
+ const memberFill = hexWithAlpha(
938
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
939
+ fillAlpha * 0.8,
940
+ );
941
+ const memberStroke = enforceContrast(
942
+ jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
943
+ );
944
+
945
+ ctx.globalAlpha = layerOpacity * 0.6;
946
+ // Use the member's shape if available, otherwise fall back to palette
947
+ const memberShape = shapeNames.includes(member.shape)
948
+ ? member.shape
949
+ : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
950
+
951
+ enhanceShapeGeneration(ctx, memberShape, mx, my, {
952
+ fillColor: memberFill,
953
+ strokeColor: memberStroke,
954
+ strokeWidth: strokeWidth * 0.7,
955
+ size: member.size,
956
+ rotation: member.rotation + (groupRotation * 180) / Math.PI,
957
+ proportionType: "GOLDEN_RATIO",
958
+ renderStyle: pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle,
959
+ rng,
960
+ });
961
+ shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
962
+ }
963
+ }
663
964
  }
664
965
  }
665
966
 
666
967
  // Reset blend mode for post-processing passes
667
968
  ctx.globalCompositeOperation = "source-over";
668
969
 
669
- // ── 6. Flow-line pass (Feature H: tapered brush strokes) ───────
970
+
971
+ // ── 6. Flow-line pass — variable color, branching, pressure ────
670
972
  const baseFlowLines = 6 + Math.floor(rng() * 10);
671
973
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
974
+
672
975
  for (let i = 0; i < numFlowLines; i++) {
673
976
  let fx = rng() * width;
674
977
  let fy = rng() * height;
@@ -676,13 +979,15 @@ export function renderHashArt(
676
979
  const stepLen = (3 + rng() * 5) * scaleFactor;
677
980
  const startWidth = (1 + rng() * 3) * scaleFactor;
678
981
 
679
- const lineColor = hexWithAlpha(
680
- enforceContrast(colors[Math.floor(rng() * colors.length)], bgLum),
681
- 0.4,
682
- );
982
+ // Variable color: interpolate between two hierarchy colors along the stroke
983
+ const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
984
+ const lineColorEnd = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
683
985
  const lineAlpha = 0.06 + rng() * 0.1;
684
986
 
685
- // Draw as individual segments with tapering width
987
+ // Pressure simulation: sinusoidal width variation
988
+ const pressureFreq = 2 + rng() * 4;
989
+ const pressurePhase = rng() * Math.PI * 2;
990
+
686
991
  let prevX = fx;
687
992
  let prevY = fy;
688
993
  for (let s = 0; s < steps; s++) {
@@ -692,11 +997,18 @@ export function renderHashArt(
692
997
 
693
998
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
694
999
 
695
- // Taper: thick at start, thin at end
696
- const taper = 1 - (s / steps) * 0.8;
1000
+ const t = s / steps;
1001
+ // Taper + pressure
1002
+ const taper = 1 - t * 0.8;
1003
+ const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
1004
+
697
1005
  ctx.globalAlpha = lineAlpha * taper;
1006
+ // Interpolate color along stroke
1007
+ const lineColor = t < 0.5
1008
+ ? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
1009
+ : hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
698
1010
  ctx.strokeStyle = lineColor;
699
- ctx.lineWidth = startWidth * taper;
1011
+ ctx.lineWidth = startWidth * taper * pressure;
700
1012
  ctx.lineCap = "round";
701
1013
 
702
1014
  ctx.beginPath();
@@ -704,28 +1016,49 @@ export function renderHashArt(
704
1016
  ctx.lineTo(fx, fy);
705
1017
  ctx.stroke();
706
1018
 
1019
+ // Branching: ~12% chance per step to spawn a thinner child stroke
1020
+ if (rng() < 0.12 && s > 5 && s < steps - 10) {
1021
+ const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
1022
+ let bx = fx;
1023
+ let by = fy;
1024
+ let bPrevX = fx;
1025
+ let bPrevY = fy;
1026
+ const branchSteps = 5 + Math.floor(rng() * 10);
1027
+ const branchWidth = startWidth * taper * 0.4;
1028
+ for (let bs = 0; bs < branchSteps; bs++) {
1029
+ const bAngle = branchAngle + (rng() - 0.5) * 0.2;
1030
+ bx += Math.cos(bAngle) * stepLen * 0.8;
1031
+ by += Math.sin(bAngle) * stepLen * 0.8;
1032
+ if (bx < 0 || bx > width || by < 0 || by > height) break;
1033
+ const bTaper = 1 - (bs / branchSteps) * 0.9;
1034
+ ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
1035
+ ctx.lineWidth = branchWidth * bTaper;
1036
+ ctx.beginPath();
1037
+ ctx.moveTo(bPrevX, bPrevY);
1038
+ ctx.lineTo(bx, by);
1039
+ ctx.stroke();
1040
+ bPrevX = bx;
1041
+ bPrevY = by;
1042
+ }
1043
+ }
1044
+
707
1045
  prevX = fx;
708
1046
  prevY = fy;
709
1047
  }
710
1048
  }
711
1049
 
712
1050
  // ── 6b. Apply symmetry mirroring ─────────────────────────────────
713
- // Mirror the rendered content (shapes + flow lines) before post-processing.
714
- // Uses ctx.canvas which is available in both Node (@napi-rs/canvas) and browsers.
715
1051
  if (symmetryMode !== "none") {
716
1052
  const canvas = ctx.canvas;
717
1053
  ctx.save();
718
1054
  if (symmetryMode === "bilateral-x" || symmetryMode === "quad") {
719
- // Mirror left half onto right half
720
1055
  ctx.save();
721
1056
  ctx.translate(width, 0);
722
1057
  ctx.scale(-1, 1);
723
- // Draw the left half (0 to cx) onto the mirrored right side
724
1058
  ctx.drawImage(canvas, 0, 0, Math.ceil(cx), height, 0, 0, Math.ceil(cx), height);
725
1059
  ctx.restore();
726
1060
  }
727
1061
  if (symmetryMode === "bilateral-y" || symmetryMode === "quad") {
728
- // Mirror top half onto bottom half
729
1062
  ctx.save();
730
1063
  ctx.translate(0, height);
731
1064
  ctx.scale(1, -1);
@@ -735,6 +1068,7 @@ export function renderHashArt(
735
1068
  ctx.restore();
736
1069
  }
737
1070
 
1071
+
738
1072
  // ── 7. Noise texture overlay ───────────────────────────────────
739
1073
  const noiseRng = createRng(seedFromHash(gitHash, 777));
740
1074
  const noiseDensity = Math.floor((width * height) / 800);
@@ -750,7 +1084,7 @@ export function renderHashArt(
750
1084
 
751
1085
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
752
1086
  ctx.globalAlpha = 1;
753
- const vignetteStrength = 0.25 + rng() * 0.2; // 25-45% edge darkening
1087
+ const vignetteStrength = 0.25 + rng() * 0.2;
754
1088
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
755
1089
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
756
1090
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
@@ -784,7 +1118,7 @@ export function renderHashArt(
784
1118
 
785
1119
  ctx.globalAlpha = 0.06 + rng() * 0.1;
786
1120
  ctx.strokeStyle = hexWithAlpha(
787
- enforceContrast(colors[Math.floor(rng() * colors.length)], bgLum),
1121
+ enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum),
788
1122
  0.3,
789
1123
  );
790
1124
 
@@ -795,5 +1129,47 @@ export function renderHashArt(
795
1129
  }
796
1130
  }
797
1131
 
1132
+ // ── 10. Post-processing ────────────────────────────────────────
1133
+
1134
+ // 10a. Color grading — unified tone across the whole image
1135
+ // Apply as a semi-transparent overlay in the grade hue
1136
+ ctx.globalAlpha = colorGrade.intensity * 0.25;
1137
+ ctx.globalCompositeOperation = "soft-light";
1138
+ const gradeHsl = `hsl(${Math.round(colorGrade.hue)}, 40%, 50%)`;
1139
+ ctx.fillStyle = gradeHsl;
1140
+ ctx.fillRect(0, 0, width, height);
1141
+ ctx.globalCompositeOperation = "source-over";
1142
+
1143
+ // 10b. Chromatic aberration — subtle RGB channel offset at edges
1144
+ // Only apply for neon/cosmic/ethereal archetypes where it fits
1145
+ const chromaArchetypes = ["neon-glow", "cosmic", "ethereal"];
1146
+ if (chromaArchetypes.includes(archetype.name)) {
1147
+ const chromaOffset = Math.ceil(2 * scaleFactor);
1148
+ const canvas = ctx.canvas;
1149
+ // Shift red channel slightly
1150
+ ctx.globalAlpha = 0.03;
1151
+ ctx.globalCompositeOperation = "screen";
1152
+ ctx.drawImage(canvas, chromaOffset, 0, width, height, 0, 0, width, height);
1153
+ // Shift blue channel opposite
1154
+ ctx.drawImage(canvas, -chromaOffset, 0, width, height, 0, 0, width, height);
1155
+ ctx.globalCompositeOperation = "source-over";
1156
+ }
1157
+
1158
+ // 10c. Bloom — soft glow on bright areas for neon/cosmic archetypes
1159
+ const bloomArchetypes = ["neon-glow", "cosmic"];
1160
+ if (bloomArchetypes.includes(archetype.name)) {
1161
+ const canvas = ctx.canvas;
1162
+ ctx.globalAlpha = 0.08;
1163
+ ctx.globalCompositeOperation = "screen";
1164
+ // Draw the image slightly scaled up and blurred via shadow
1165
+ ctx.save();
1166
+ ctx.shadowBlur = 30 * scaleFactor;
1167
+ ctx.shadowColor = "rgba(255,255,255,0.3)";
1168
+ ctx.drawImage(canvas, 0, 0, width, height);
1169
+ ctx.restore();
1170
+ ctx.globalCompositeOperation = "source-over";
1171
+ }
1172
+
798
1173
  ctx.globalAlpha = 1;
1174
+
799
1175
  }