git-hash-art 0.8.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
@@ -35,9 +35,11 @@ import {
35
35
  } from "./canvas/colors";
36
36
  import {
37
37
  enhanceShapeGeneration,
38
+ drawMirroredShape,
39
+ pickMirrorAxis,
38
40
  pickBlendMode,
39
41
  pickRenderStyle,
40
- type RenderStyle,
42
+ type RenderStyle
41
43
  } from "./canvas/draw";
42
44
  import { shapes } from "./canvas/shapes";
43
45
  import {
@@ -272,6 +274,92 @@ function drawBackground(
272
274
  }
273
275
  }
274
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
+
275
363
  // ── Main render function ────────────────────────────────────────────
276
364
 
277
365
  export function renderHashArt(
@@ -385,6 +473,69 @@ export function renderHashArt(
385
473
  }
386
474
  ctx.globalCompositeOperation = "source-over";
387
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
+
388
539
  // ── 2. Composition mode ────────────────────────────────────────
389
540
  const compositionMode =
390
541
  COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
@@ -533,6 +684,12 @@ export function renderHashArt(
533
684
  // Atmospheric desaturation for later layers
534
685
  const atmosphericDesat = layerRatio * 0.3;
535
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
692
+
536
693
  for (let i = 0; i < numShapes; i++) {
537
694
  // Position from composition mode, then focal bias
538
695
  const rawPos = getCompositionPosition(
@@ -605,9 +762,11 @@ export function renderHashArt(
605
762
  const fillAlpha = 0.2 + rng() * 0.5;
606
763
  const transparentFill = hexWithAlpha(fillColor, fillAlpha);
607
764
 
608
- const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor;
765
+ const strokeWidth = (0.5 + rng() * 2.0) * scaleFactor * dofStrokeScale;
609
766
 
610
- 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;
611
770
 
612
771
  // Glow on sacred shapes more often — scaled by archetype
613
772
  const isSacred = SACRED_SHAPES.includes(shape);
@@ -634,13 +793,47 @@ export function renderHashArt(
634
793
  const shadowOffX = shadowDist * Math.cos(lightAngle);
635
794
  const shadowOffY = shadowDist * Math.sin(lightAngle);
636
795
 
637
- enhanceShapeGeneration(ctx, shape, x, y, {
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;
829
+
830
+ const shapeConfig = {
638
831
  fillColor: transparentFill,
639
832
  strokeColor,
640
833
  strokeWidth,
641
834
  size,
642
835
  rotation,
643
- proportionType: "GOLDEN_RATIO",
836
+ proportionType: "GOLDEN_RATIO" as const,
644
837
  glowRadius: glowRadius || (shadowDist > 0 ? shadowDist * 2 : 0),
645
838
  glowColor: hasGlow
646
839
  ? hexWithAlpha(fillColor, 0.6)
@@ -648,19 +841,29 @@ export function renderHashArt(
648
841
  gradientFillEnd: gradientEnd,
649
842
  renderStyle: finalRenderStyle,
650
843
  rng,
651
- });
844
+ };
845
+
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
+ }
652
855
 
653
- shapePositions.push({ x, y, size, shape });
856
+ shapePositions.push({ x: finalX, y: finalY, size, shape });
654
857
 
655
- // ── 5b. Size echo — large shapes spawn trailing smaller copies ──
858
+ // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
656
859
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
657
860
  const echoCount = 2 + Math.floor(rng() * 2);
658
861
  const echoAngle = rng() * Math.PI * 2;
659
862
  for (let e = 0; e < echoCount; e++) {
660
863
  const echoScale = 0.3 - e * 0.08;
661
864
  const echoDist = size * (0.6 + e * 0.4);
662
- const echoX = x + Math.cos(echoAngle) * echoDist;
663
- const echoY = y + Math.sin(echoAngle) * echoDist;
865
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
866
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
664
867
  const echoSize = size * Math.max(0.1, echoScale);
665
868
 
666
869
  if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
@@ -680,7 +883,7 @@ export function renderHashArt(
680
883
  }
681
884
  }
682
885
 
683
- // ── 5c. Recursive nesting ──────────────────────────────────
886
+ // ── 5d. Recursive nesting ──────────────────────────────────
684
887
  if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
685
888
  const innerCount = 1 + Math.floor(rng() * 3);
686
889
  for (let n = 0; n < innerCount; n++) {
@@ -700,8 +903,8 @@ export function renderHashArt(
700
903
  enhanceShapeGeneration(
701
904
  ctx,
702
905
  innerShape,
703
- x + innerOffX,
704
- y + innerOffY,
906
+ finalX + innerOffX,
907
+ finalY + innerOffY,
705
908
  {
706
909
  fillColor: innerFill,
707
910
  strokeColor: hexWithAlpha(strokeColor, 0.5),
@@ -715,6 +918,49 @@ export function renderHashArt(
715
918
  );
716
919
  }
717
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
+ }
718
964
  }
719
965
  }
720
966