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/ALGORITHM.md +323 -270
- package/CHANGELOG.md +18 -0
- package/bin/cli.js +17 -14
- package/bin/generateExamples.js +6 -14
- package/bin/generateVersionComparison.js +353 -0
- package/dist/browser.js +2398 -225
- package/dist/browser.js.map +1 -1
- package/dist/main.js +2398 -225
- package/dist/main.js.map +1 -1
- package/dist/module.js +2398 -225
- package/dist/module.js.map +1 -1
- package/package.json +2 -1
- package/src/lib/archetypes.ts +119 -0
- package/src/lib/canvas/colors.ts +110 -2
- package/src/lib/canvas/draw.ts +359 -9
- package/src/lib/canvas/shapes/affinity.ts +624 -0
- package/src/lib/canvas/shapes/procedural.ts +395 -32
- package/src/lib/render.ts +531 -155
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* 0. Archetype selection + shape palette + color hierarchy
|
|
9
|
+
* 1. Background — style 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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* 6. Flow lines — variable 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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
151
|
+
hierarchy: ColorHierarchy,
|
|
191
152
|
rng: () => number,
|
|
192
153
|
): string {
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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(
|
|
632
|
+
enforceContrast(jitterColorHSL(colorHierarchy.dominant, rng, 6, 0.05), bgLum),
|
|
480
633
|
0.15 + rng() * 0.2,
|
|
481
634
|
);
|
|
482
|
-
const heroStroke = enforceContrast(
|
|
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:
|
|
495
|
-
renderStyle:
|
|
653
|
+
gradientFillEnd: jitterColorHSL(colorHierarchy.secondary, rng, 10, 0.1),
|
|
654
|
+
renderStyle: heroStyle,
|
|
496
655
|
rng,
|
|
497
656
|
});
|
|
498
657
|
|
|
499
|
-
|
|
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
|
-
//
|
|
675
|
+
// Per-layer blend mode
|
|
515
676
|
const layerBlend = pickBlendMode(rng);
|
|
516
677
|
ctx.globalCompositeOperation = layerBlend;
|
|
517
678
|
|
|
518
|
-
//
|
|
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
|
-
//
|
|
524
|
-
const atmosphericDesat = layerRatio * 0.3;
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
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
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
//
|
|
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(
|
|
579
|
-
const strokeColor = enforceContrast(
|
|
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
|
-
|
|
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
|
-
?
|
|
781
|
+
? jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1)
|
|
600
782
|
: undefined;
|
|
601
783
|
|
|
602
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
696
|
-
|
|
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;
|
|
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(
|
|
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
|
}
|