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.
- package/ALGORITHM.md +81 -15
- package/CHANGELOG.md +7 -0
- package/dist/browser.js +247 -62
- package/dist/browser.js.map +1 -1
- package/dist/main.js +250 -63
- package/dist/main.js.map +1 -1
- package/dist/module.js +250 -63
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/lib/canvas/colors.ts +59 -38
- package/src/lib/canvas/draw.ts +134 -3
- package/src/lib/render.ts +156 -51
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* 1. Background — radial gradient from hash-derived dark palette
|
|
9
|
+
* 1b. Layered background — large faint shapes / subtle pattern for depth
|
|
10
|
+
* 2. Composition mode — hash 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
|
|
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 {
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
401
|
-
|
|
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
|
-
|
|
410
|
-
ctx.strokeStyle = hexWithAlpha(
|
|
504
|
+
const lineColor = hexWithAlpha(
|
|
411
505
|
colors[Math.floor(rng() * colors.length)],
|
|
412
506
|
0.4,
|
|
413
507
|
);
|
|
414
|
-
|
|
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++) {
|