git-hash-art 0.3.0 → 0.4.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.
@@ -1,6 +1,56 @@
1
1
  import { PatternCombiner, ProportionType } from "../utils";
2
2
  import { shapes } from "./shapes";
3
3
 
4
+ // ── Blend modes for layer-level compositing (Feature B) ─────────────
5
+ // These are all standard Canvas 2D globalCompositeOperation values,
6
+ // safe in both Node (@napi-rs/canvas) and browsers.
7
+
8
+ export const BLEND_MODES: GlobalCompositeOperation[] = [
9
+ "source-over", // default — safe fallback
10
+ "screen",
11
+ "multiply",
12
+ "overlay",
13
+ "soft-light",
14
+ "color-dodge",
15
+ "color-burn",
16
+ "lighter",
17
+ ];
18
+
19
+ /**
20
+ * Pick a blend mode deterministically from the RNG.
21
+ * ~40% chance of default source-over to keep some images clean.
22
+ */
23
+ export function pickBlendMode(rng: () => number): GlobalCompositeOperation {
24
+ if (rng() < 0.4) return "source-over";
25
+ return BLEND_MODES[1 + Math.floor(rng() * (BLEND_MODES.length - 1))];
26
+ }
27
+
28
+ // ── Shape rendering styles (Feature C) ──────────────────────────────
29
+
30
+ export type RenderStyle =
31
+ | "fill-and-stroke" // classic (current behavior)
32
+ | "fill-only" // soft, no outline
33
+ | "stroke-only" // wireframe
34
+ | "double-stroke" // inner + outer stroke
35
+ | "dashed" // dashed outline
36
+ | "watercolor"; // multiple offset passes at low opacity
37
+
38
+ const RENDER_STYLES: RenderStyle[] = [
39
+ "fill-and-stroke",
40
+ "fill-and-stroke", // weighted: appears twice for higher probability
41
+ "fill-only",
42
+ "stroke-only",
43
+ "double-stroke",
44
+ "dashed",
45
+ "watercolor",
46
+ ];
47
+
48
+ export function pickRenderStyle(rng: () => number): RenderStyle {
49
+ return RENDER_STYLES[Math.floor(rng() * RENDER_STYLES.length)];
50
+ }
51
+
52
+ // ── Config interfaces ───────────────────────────────────────────────
53
+
4
54
  interface DrawShapeConfig {
5
55
  fillColor: string;
6
56
  strokeColor: string;
@@ -19,6 +69,10 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
19
69
  glowColor?: string;
20
70
  /** If provided, fills with a radial gradient between two colors. */
21
71
  gradientFillEnd?: string;
72
+ /** Rendering style — controls fill/stroke treatment. */
73
+ renderStyle?: RenderStyle;
74
+ /** RNG for watercolor jitter (required for "watercolor" style). */
75
+ rng?: () => number;
22
76
  }
23
77
 
24
78
  export function drawShape(
@@ -47,7 +101,83 @@ export function drawShape(
47
101
  }
48
102
 
49
103
  /**
50
- * Enhanced shape drawing with glow, gradient fills, and pattern layering.
104
+ * Apply the chosen render style to the current path.
105
+ */
106
+ function applyRenderStyle(
107
+ ctx: CanvasRenderingContext2D,
108
+ style: RenderStyle,
109
+ fillColor: string,
110
+ strokeColor: string,
111
+ strokeWidth: number,
112
+ size: number,
113
+ rng?: () => number,
114
+ ): void {
115
+ switch (style) {
116
+ case "fill-only":
117
+ ctx.fill();
118
+ break;
119
+
120
+ case "stroke-only":
121
+ ctx.fill(); // transparent fill to define the path
122
+ ctx.globalAlpha *= 0.3; // ghost fill
123
+ ctx.fill();
124
+ ctx.globalAlpha /= 0.3;
125
+ ctx.stroke();
126
+ break;
127
+
128
+ case "double-stroke": {
129
+ ctx.fill();
130
+ // Outer stroke
131
+ ctx.lineWidth = strokeWidth * 2;
132
+ ctx.globalAlpha *= 0.5;
133
+ ctx.stroke();
134
+ ctx.globalAlpha /= 0.5;
135
+ // Inner stroke
136
+ ctx.lineWidth = strokeWidth * 0.5;
137
+ ctx.strokeStyle = fillColor;
138
+ ctx.stroke();
139
+ break;
140
+ }
141
+
142
+ case "dashed":
143
+ ctx.fill();
144
+ ctx.setLineDash([size * 0.05, size * 0.03]);
145
+ ctx.stroke();
146
+ ctx.setLineDash([]);
147
+ break;
148
+
149
+ case "watercolor": {
150
+ // Draw 3-4 slightly offset passes at low opacity for a bleed effect
151
+ const passes = 3 + (rng ? Math.floor(rng() * 2) : 0);
152
+ const savedAlpha = ctx.globalAlpha;
153
+ ctx.globalAlpha = savedAlpha * (0.3 / passes * 2);
154
+ for (let p = 0; p < passes; p++) {
155
+ const jx = rng ? (rng() - 0.5) * size * 0.06 : 0;
156
+ const jy = rng ? (rng() - 0.5) * size * 0.06 : 0;
157
+ ctx.save();
158
+ ctx.translate(jx, jy);
159
+ ctx.fill();
160
+ ctx.restore();
161
+ }
162
+ ctx.globalAlpha = savedAlpha;
163
+ // Light stroke on top
164
+ ctx.globalAlpha *= 0.4;
165
+ ctx.stroke();
166
+ ctx.globalAlpha /= 0.4;
167
+ break;
168
+ }
169
+
170
+ case "fill-and-stroke":
171
+ default:
172
+ ctx.fill();
173
+ ctx.stroke();
174
+ break;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Enhanced shape drawing with glow, gradient fills, blend modes,
180
+ * render style variety, and pattern layering.
51
181
  */
52
182
  export function enhanceShapeGeneration(
53
183
  ctx: CanvasRenderingContext2D,
@@ -69,6 +199,8 @@ export function enhanceShapeGeneration(
69
199
  glowRadius = 0,
70
200
  glowColor,
71
201
  gradientFillEnd,
202
+ renderStyle = "fill-and-stroke",
203
+ rng,
72
204
  } = config;
73
205
 
74
206
  ctx.save();
@@ -99,8 +231,7 @@ export function enhanceShapeGeneration(
99
231
  const drawFunction = shapes[shape];
100
232
  if (drawFunction) {
101
233
  drawFunction(ctx, size);
102
- ctx.fill();
103
- ctx.stroke();
234
+ applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
104
235
  }
105
236
 
106
237
  // Reset shadow so patterns aren't double-glowed
package/src/lib/render.ts CHANGED
@@ -5,17 +5,29 @@
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
- * 2. Composition modehash selects: radial, flow-field, spiral, grid-subdivision, or clustered
10
- * 3. Color field smooth positional color blending across the canvas
11
- * 4. Shape layers weighted selection, focal-point placement, transparency, glow, gradients, jitter
12
- * 5. Recursive nesting — some shapes contain smaller shapes inside
13
- * 6. Flow-line pass bezier curves following a hash-derived vector field
14
- * 7. Noise texture overlay — subtle grain for organic feel
15
- * 8. Organic connecting curves — beziers between nearby shapes
8
+ * 1. Background — radial gradient from hash-derived dark palette
9
+ * 1b. Layered backgroundlarge 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 layersblend modes, render styles, weighted selection,
14
+ * focal-point placement, atmospheric depth, organic edges
15
+ * 5b. Recursive nesting
16
+ * 6. Flow-line pass — tapered brush-stroke curves
17
+ * 7. Noise texture overlay
18
+ * 8. Organic connecting curves
16
19
  */
17
- import { SacredColorScheme, hexWithAlpha, jitterColor } from "./canvas/colors";
18
- import { enhanceShapeGeneration } from "./canvas/draw";
20
+ import {
21
+ SacredColorScheme,
22
+ hexWithAlpha,
23
+ jitterColor,
24
+ desaturate,
25
+ } from "./canvas/colors";
26
+ import {
27
+ enhanceShapeGeneration,
28
+ pickBlendMode,
29
+ pickRenderStyle,
30
+ } from "./canvas/draw";
19
31
  import { shapes } from "./canvas/shapes";
20
32
  import { createRng, seedFromHash } from "./utils";
21
33
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
@@ -95,26 +107,6 @@ function pickShape(
95
107
  return available[Math.floor(rng() * available.length)];
96
108
  }
97
109
 
98
- // ── Helper: simple 2D value noise (hash-seeded) ─────────────────────
99
-
100
- function valueNoise(
101
- x: number,
102
- y: number,
103
- scale: number,
104
- rng: () => number,
105
- ): number {
106
- // Cheap pseudo-noise: combine sin waves at different frequencies
107
- const nx = x / scale;
108
- const ny = y / scale;
109
- return (
110
- (Math.sin(nx * 1.7 + ny * 2.3 + rng() * 0.001) * 0.5 +
111
- Math.sin(nx * 3.1 - ny * 1.9 + rng() * 0.001) * 0.3 +
112
- Math.sin(nx * 5.3 + ny * 4.7 + rng() * 0.001) * 0.2) *
113
- 0.5 +
114
- 0.5
115
- );
116
- }
117
-
118
110
  // ── Helper: get position based on composition mode ──────────────────
119
111
 
120
112
  function getCompositionPosition(
@@ -154,10 +146,8 @@ function getCompositionPosition(
154
146
  };
155
147
  }
156
148
  case "clustered": {
157
- // Pick one of 3-5 cluster centers, then scatter around it
158
149
  const numClusters = 3 + Math.floor(rng() * 3);
159
150
  const ci = Math.floor(rng() * numClusters);
160
- // Deterministic cluster center from index
161
151
  const clusterRng = createRng(seedFromHash(String(ci), 999));
162
152
  const clx = width * (0.15 + clusterRng() * 0.7);
163
153
  const cly = height * (0.15 + clusterRng() * 0.7);
@@ -169,7 +159,6 @@ function getCompositionPosition(
169
159
  }
170
160
  case "flow-field":
171
161
  default: {
172
- // Random position, will be adjusted by flow field direction later
173
162
  return { x: rng() * width, y: rng() * height };
174
163
  }
175
164
  }
@@ -185,16 +174,41 @@ function getPositionalColor(
185
174
  colors: string[],
186
175
  rng: () => number,
187
176
  ): string {
188
- // Blend between palette colors based on position
189
177
  const nx = x / width;
190
178
  const ny = y / height;
191
- // Use position to bias which palette color is chosen
192
179
  const posIndex = (nx * 0.6 + ny * 0.4) * (colors.length - 1);
193
180
  const baseIdx = Math.floor(posIndex) % colors.length;
194
- // Then jitter it slightly
195
181
  return jitterColor(colors[baseIdx], rng, 0.08);
196
182
  }
197
183
 
184
+ // ── Helper: check if a position is inside a void zone (Feature E) ───
185
+
186
+ function isInVoidZone(
187
+ x: number,
188
+ y: number,
189
+ voidZones: Array<{ x: number; y: number; radius: number }>,
190
+ ): boolean {
191
+ for (const zone of voidZones) {
192
+ if (Math.hypot(x - zone.x, y - zone.y) < zone.radius) return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ // ── Helper: density check for negative space (Feature E) ────────────
198
+
199
+ function localDensity(
200
+ x: number,
201
+ y: number,
202
+ positions: Array<{ x: number; y: number; size: number }>,
203
+ radius: number,
204
+ ): number {
205
+ let count = 0;
206
+ for (const p of positions) {
207
+ if (Math.hypot(x - p.x, y - p.y) < radius) count++;
208
+ }
209
+ return count;
210
+ }
211
+
198
212
  // ── Main render function ────────────────────────────────────────────
199
213
 
200
214
  export function renderHashArt(
@@ -238,11 +252,39 @@ export function renderHashArt(
238
252
  ctx.fillStyle = bgGrad;
239
253
  ctx.fillRect(0, 0, width, height);
240
254
 
255
+ // ── 1b. Layered background (Feature G) ─────────────────────────
256
+ // Draw large, very faint shapes to give the background texture
257
+ const bgShapeCount = 3 + Math.floor(rng() * 4);
258
+ ctx.globalCompositeOperation = "soft-light";
259
+ for (let i = 0; i < bgShapeCount; i++) {
260
+ const bx = rng() * width;
261
+ const by = rng() * height;
262
+ const bSize = (width * 0.3 + rng() * width * 0.5);
263
+ const bColor = colors[Math.floor(rng() * colors.length)];
264
+ ctx.globalAlpha = 0.03 + rng() * 0.05;
265
+ ctx.fillStyle = hexWithAlpha(bColor, 0.15);
266
+ ctx.beginPath();
267
+ ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
268
+ ctx.fill();
269
+ }
270
+ // Subtle concentric rings from center
271
+ const ringCount = 2 + Math.floor(rng() * 3);
272
+ ctx.globalAlpha = 0.02 + rng() * 0.03;
273
+ ctx.strokeStyle = hexWithAlpha(colors[0], 0.1);
274
+ ctx.lineWidth = 1 * scaleFactor;
275
+ for (let i = 1; i <= ringCount; i++) {
276
+ const r = (Math.min(width, height) * 0.15) * i;
277
+ ctx.beginPath();
278
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
279
+ ctx.stroke();
280
+ }
281
+ ctx.globalCompositeOperation = "source-over";
282
+
241
283
  // ── 2. Composition mode ────────────────────────────────────────
242
284
  const compositionMode =
243
285
  COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
244
286
 
245
- // ── 3. Focal points ────────────────────────────────────────────
287
+ // ── 3. Focal points + void zones ───────────────────────────────
246
288
  const numFocal = 1 + Math.floor(rng() * 2);
247
289
  const focalPoints: Array<{ x: number; y: number; strength: number }> = [];
248
290
  for (let f = 0; f < numFocal; f++) {
@@ -253,6 +295,17 @@ export function renderHashArt(
253
295
  });
254
296
  }
255
297
 
298
+ // Feature E: 1-2 void zones where shapes are sparse (negative space)
299
+ const numVoids = Math.floor(rng() * 2) + 1;
300
+ const voidZones: Array<{ x: number; y: number; radius: number }> = [];
301
+ for (let v = 0; v < numVoids; v++) {
302
+ voidZones.push({
303
+ x: width * (0.15 + rng() * 0.7),
304
+ y: height * (0.15 + rng() * 0.7),
305
+ radius: Math.min(width, height) * (0.06 + rng() * 0.1),
306
+ });
307
+ }
308
+
256
309
  function applyFocalBias(rx: number, ry: number): [number, number] {
257
310
  let nearest = focalPoints[0];
258
311
  let minDist = Infinity;
@@ -267,7 +320,7 @@ export function renderHashArt(
267
320
  return [rx + (nearest.x - rx) * pull, ry + (nearest.y - ry) * pull];
268
321
  }
269
322
 
270
- // ── 4. Flow field seed values (for flow-field mode & line pass) ─
323
+ // ── 4. Flow field seed values ──────────────────────────────────
271
324
  const fieldAngleBase = rng() * Math.PI * 2;
272
325
  const fieldFreq = 0.5 + rng() * 2;
273
326
 
@@ -281,6 +334,8 @@ export function renderHashArt(
281
334
 
282
335
  // ── 5. Shape layers ────────────────────────────────────────────
283
336
  const shapePositions: Array<{ x: number; y: number; size: number }> = [];
337
+ const densityCheckRadius = Math.min(width, height) * 0.08;
338
+ const maxLocalDensity = Math.ceil(finalConfig.shapesPerLayer * 0.15);
284
339
 
285
340
  for (let layer = 0; layer < layers; layer++) {
286
341
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
@@ -290,6 +345,16 @@ export function renderHashArt(
290
345
  const layerOpacity = Math.max(0.15, baseOpacity - layer * opacityReduction);
291
346
  const layerSizeScale = 1 - layer * 0.15;
292
347
 
348
+ // Feature B: per-layer blend mode
349
+ const layerBlend = pickBlendMode(rng);
350
+ ctx.globalCompositeOperation = layerBlend;
351
+
352
+ // Feature C: per-layer render style bias
353
+ const layerRenderStyle = pickRenderStyle(rng);
354
+
355
+ // Feature D: atmospheric desaturation for later layers
356
+ const atmosphericDesat = layerRatio * 0.3; // 0 for first layer, up to 0.3 for last
357
+
293
358
  for (let i = 0; i < numShapes; i++) {
294
359
  // Position from composition mode, then focal bias
295
360
  const rawPos = getCompositionPosition(
@@ -304,6 +369,15 @@ export function renderHashArt(
304
369
  );
305
370
  const [x, y] = applyFocalBias(rawPos.x, rawPos.y);
306
371
 
372
+ // Feature E: skip shapes in void zones, reduce in dense areas
373
+ if (isInVoidZone(x, y, voidZones)) {
374
+ // 85% chance to skip — allows a few shapes to bleed in
375
+ if (rng() < 0.85) continue;
376
+ }
377
+ if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
378
+ if (rng() < 0.6) continue; // thin out dense areas
379
+ }
380
+
307
381
  // Weighted shape selection
308
382
  const shape = pickShape(rng, layerRatio, shapeNames);
309
383
 
@@ -320,8 +394,14 @@ export function renderHashArt(
320
394
  : rng() * 360;
321
395
 
322
396
  // Positional color blending + jitter
323
- const fillBase = getPositionalColor(x, y, width, height, colors, rng);
397
+ let fillBase = getPositionalColor(x, y, width, height, colors, rng);
324
398
  const strokeBase = colors[Math.floor(rng() * colors.length)];
399
+
400
+ // Feature D: desaturate colors on later layers for depth
401
+ if (atmosphericDesat > 0) {
402
+ fillBase = desaturate(fillBase, atmosphericDesat);
403
+ }
404
+
325
405
  const fillColor = jitterColor(fillBase, rng, 0.06);
326
406
  const strokeColor = jitterColor(strokeBase, rng, 0.05);
327
407
 
@@ -345,6 +425,14 @@ export function renderHashArt(
345
425
  ? jitterColor(colors[Math.floor(rng() * colors.length)], rng, 0.1)
346
426
  : undefined;
347
427
 
428
+ // Feature C: per-shape render style (70% use layer style, 30% pick their own)
429
+ const shapeRenderStyle =
430
+ rng() < 0.7 ? layerRenderStyle : pickRenderStyle(rng);
431
+
432
+ // Feature F: organic edge jitter — applied via watercolor style on ~15% of shapes
433
+ const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
434
+ const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
435
+
348
436
  enhanceShapeGeneration(ctx, shape, x, y, {
349
437
  fillColor: transparentFill,
350
438
  strokeColor,
@@ -355,11 +443,13 @@ export function renderHashArt(
355
443
  glowRadius,
356
444
  glowColor: hasGlow ? hexWithAlpha(fillColor, 0.6) : undefined,
357
445
  gradientFillEnd: gradientEnd,
446
+ renderStyle: finalRenderStyle,
447
+ rng,
358
448
  });
359
449
 
360
450
  shapePositions.push({ x, y, size });
361
451
 
362
- // ── 5b. Recursive nesting: ~15% of larger shapes get inner shapes ──
452
+ // ── 5b. Recursive nesting ──────────────────────────────────
363
453
  if (size > adjustedMaxSize * 0.4 && rng() < 0.15) {
364
454
  const innerCount = 1 + Math.floor(rng() * 3);
365
455
  for (let n = 0; n < innerCount; n++) {
@@ -390,6 +480,8 @@ export function renderHashArt(
390
480
  size: innerSize,
391
481
  rotation: innerRot,
392
482
  proportionType: "GOLDEN_RATIO",
483
+ renderStyle: shapeRenderStyle,
484
+ rng,
393
485
  },
394
486
  );
395
487
  }
@@ -397,39 +489,52 @@ export function renderHashArt(
397
489
  }
398
490
  }
399
491
 
400
- // ── 6. Flow-line pass ──────────────────────────────────────────
401
- // Draw flowing curves that follow the hash-derived vector field
492
+ // Reset blend mode for post-processing passes
493
+ ctx.globalCompositeOperation = "source-over";
494
+
495
+ // ── 6. Flow-line pass (Feature H: tapered brush strokes) ───────
402
496
  const numFlowLines = 6 + Math.floor(rng() * 10);
403
497
  for (let i = 0; i < numFlowLines; i++) {
404
498
  let fx = rng() * width;
405
499
  let fy = rng() * height;
406
500
  const steps = 30 + Math.floor(rng() * 40);
407
501
  const stepLen = (3 + rng() * 5) * scaleFactor;
502
+ const startWidth = (1 + rng() * 3) * scaleFactor;
408
503
 
409
- ctx.globalAlpha = 0.06 + rng() * 0.1;
410
- ctx.strokeStyle = hexWithAlpha(
504
+ const lineColor = hexWithAlpha(
411
505
  colors[Math.floor(rng() * colors.length)],
412
506
  0.4,
413
507
  );
414
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
415
-
416
- ctx.beginPath();
417
- ctx.moveTo(fx, fy);
508
+ const lineAlpha = 0.06 + rng() * 0.1;
418
509
 
510
+ // Draw as individual segments with tapering width
511
+ let prevX = fx;
512
+ let prevY = fy;
419
513
  for (let s = 0; s < steps; s++) {
420
514
  const angle = flowAngle(fx, fy) + (rng() - 0.5) * 0.3;
421
515
  fx += Math.cos(angle) * stepLen;
422
516
  fy += Math.sin(angle) * stepLen;
423
517
 
424
- // Stay in bounds
425
518
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
519
+
520
+ // Taper: thick at start, thin at end
521
+ const taper = 1 - (s / steps) * 0.8;
522
+ ctx.globalAlpha = lineAlpha * taper;
523
+ ctx.strokeStyle = lineColor;
524
+ ctx.lineWidth = startWidth * taper;
525
+ ctx.lineCap = "round";
526
+
527
+ ctx.beginPath();
528
+ ctx.moveTo(prevX, prevY);
426
529
  ctx.lineTo(fx, fy);
530
+ ctx.stroke();
531
+
532
+ prevX = fx;
533
+ prevY = fy;
427
534
  }
428
- ctx.stroke();
429
535
  }
430
536
 
431
537
  // ── 7. Noise texture overlay ───────────────────────────────────
432
- // Subtle grain rendered as tiny semi-transparent dots
433
538
  const noiseRng = createRng(seedFromHash(gitHash, 777));
434
539
  const noiseDensity = Math.floor((width * height) / 800);
435
540
  for (let i = 0; i < noiseDensity; i++) {