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/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
- * 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,
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
- const BASIC_SHAPES = [
41
- "circle",
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 blending ───────────────────────────────
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
- colors: string[],
149
+ hierarchy: ColorHierarchy,
178
150
  rng: () => number,
179
151
  ): string {
180
- const nx = x / width;
181
- const ny = y / height;
182
- const posIndex = (nx * 0.6 + ny * 0.4) * (colors.length - 1);
183
- const baseIdx = Math.floor(posIndex) % colors.length;
184
- return jitterColor(colors[baseIdx], rng, 0.08);
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 (Feature E) ───
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 for negative space (Feature E) ────────────
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
- // ── 1b. Layered background (Feature G) ─────────────────────────
343
- // Draw large, very faint shapes to give the background texture
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 = colors[Math.floor(rng() * colors.length)];
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
- ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
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(colors[0], 0.1);
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
- const heroPool = [...SACRED_SHAPES, "fibonacciSpiral", "merkaba", "fractal"];
455
- const heroShape =
456
- heroPool.filter((s) => shapeNames.includes(s))[
457
- Math.floor(rng() * heroPool.filter((s) => shapeNames.includes(s)).length)
458
- ] || shapeNames[Math.floor(rng() * shapeNames.length)];
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
- jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.05),
481
+ enforceContrast(jitterColorHSL(colorHierarchy.dominant, rng, 6, 0.05), bgLum),
464
482
  0.15 + rng() * 0.2,
465
483
  );
466
- const heroStroke = jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.05);
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: jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1),
479
- renderStyle: rng() < 0.4 ? "watercolor" : "fill-and-stroke",
502
+ gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
503
+ renderStyle: heroStyle,
480
504
  rng,
481
505
  });
482
506
 
483
- shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize });
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
- // Feature B: per-layer blend mode
524
+ // Per-layer blend mode
499
525
  const layerBlend = pickBlendMode(rng);
500
526
  ctx.globalCompositeOperation = layerBlend;
501
527
 
502
- // Feature C: per-layer render style bias — prefer archetype styles
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
- // Feature D: atmospheric desaturation for later layers
508
- const atmosphericDesat = layerRatio * 0.3; // 0 for first layer, up to 0.3 for last
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
- // Feature E: skip shapes in void zones, reduce in dense areas
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; // thin out dense areas
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
- const rotation =
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
- // Positional color blending + jitter
549
- let fillBase = getPositionalColor(x, y, width, height, colors, rng);
550
- const strokeBase = colors[Math.floor(rng() * colors.length)];
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
- // Feature D: desaturate colors on later layers for depth
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 = jitterColor(fillBase, rng, 0.06);
563
- const strokeColor = jitterColor(strokeBase, rng, 0.05);
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
- ? jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1)
622
+ ? jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1)
584
623
  : undefined;
585
624
 
586
- // Feature C: per-shape render style (70% use layer style, 30% pick their own)
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
- // Feature F: organic edge jitter — applied via watercolor style on ~15% of shapes
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 ? hexWithAlpha(fillColor, 0.6) : undefined,
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
- // ── 5b. Recursive nesting ──────────────────────────────────
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
- const innerShape = pickShape(
615
- rng,
616
- Math.min(1, layerRatio + 0.3),
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
- jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1),
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: shapeRenderStyle,
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
- // ── 6. Flow-line pass (Feature H: tapered brush strokes) ───────
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
- const lineColor = hexWithAlpha(
664
- colors[Math.floor(rng() * colors.length)],
665
- 0.4,
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
- // Draw as individual segments with tapering width
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
- // Taper: thick at start, thin at end
680
- const taper = 1 - (s / steps) * 0.8;
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; // 25-45% edge darkening
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
- colors[Math.floor(rng() * colors.length)],
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
  }