git-hash-art 0.5.0 → 0.7.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 +115 -20
- package/CHANGELOG.md +17 -0
- package/bin/cli.js +155 -0
- package/dist/browser.js +639 -33
- package/dist/browser.js.map +1 -1
- package/dist/main.js +639 -33
- package/dist/main.js.map +1 -1
- package/dist/module.js +639 -33
- package/dist/module.js.map +1 -1
- package/package.json +4 -1
- package/src/lib/archetypes.ts +248 -0
- package/src/lib/canvas/colors.ts +116 -0
- package/src/lib/canvas/draw.ts +1 -1
- package/src/lib/canvas/shapes/index.ts +2 -0
- package/src/lib/canvas/shapes/procedural.ts +209 -0
- package/src/lib/render.ts +138 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-hash-art",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"author": "gfargo <ghfargo@gmail.com>",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"watch": "parcel watch",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"test:publish": "npm publish --dry-run",
|
|
15
15
|
"prepublishOnly": "yarn build"
|
|
16
16
|
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"git-hash-art": "bin/cli.js"
|
|
19
|
+
},
|
|
17
20
|
"source": "src/index.ts",
|
|
18
21
|
"main": "dist/main.js",
|
|
19
22
|
"module": "dist/module.js",
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual archetypes — fundamentally different rendering personalities
|
|
3
|
+
* selected deterministically from the hash.
|
|
4
|
+
*
|
|
5
|
+
* Each archetype overrides key rendering parameters to produce images
|
|
6
|
+
* that look like they came from different generators entirely.
|
|
7
|
+
*/
|
|
8
|
+
import type { RenderStyle } from "./canvas/draw";
|
|
9
|
+
|
|
10
|
+
// ── Background types ────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type BackgroundStyle =
|
|
13
|
+
| "radial-dark" // current default: dark radial gradient
|
|
14
|
+
| "radial-light" // light center, medium edges
|
|
15
|
+
| "linear-horizontal" // left-to-right gradient
|
|
16
|
+
| "linear-diagonal" // corner-to-corner gradient
|
|
17
|
+
| "solid-dark" // flat dark color
|
|
18
|
+
| "solid-light" // flat light/white color
|
|
19
|
+
| "multi-stop"; // 3-4 color gradient
|
|
20
|
+
|
|
21
|
+
// ── Palette modes ───────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type PaletteMode =
|
|
24
|
+
| "harmonious" // current default: full palette
|
|
25
|
+
| "monochrome" // single hue, varying lightness
|
|
26
|
+
| "duotone" // two colors only
|
|
27
|
+
| "neon" // high saturation on dark
|
|
28
|
+
| "pastel-light" // soft pastels on light background
|
|
29
|
+
| "earth" // muted warm naturals
|
|
30
|
+
| "high-contrast"; // black + white + one accent
|
|
31
|
+
|
|
32
|
+
// ── Archetype definition ────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export interface Archetype {
|
|
35
|
+
name: string;
|
|
36
|
+
/** Override gridSize (controls shape count) */
|
|
37
|
+
gridSize: number;
|
|
38
|
+
/** Override layer count */
|
|
39
|
+
layers: number;
|
|
40
|
+
/** Override base opacity */
|
|
41
|
+
baseOpacity: number;
|
|
42
|
+
/** Override opacity reduction per layer */
|
|
43
|
+
opacityReduction: number;
|
|
44
|
+
/** Override min shape size */
|
|
45
|
+
minShapeSize: number;
|
|
46
|
+
/** Override max shape size */
|
|
47
|
+
maxShapeSize: number;
|
|
48
|
+
/** Background rendering style */
|
|
49
|
+
backgroundStyle: BackgroundStyle;
|
|
50
|
+
/** Color palette mode */
|
|
51
|
+
paletteMode: PaletteMode;
|
|
52
|
+
/** Preferred render styles (weighted toward these) */
|
|
53
|
+
preferredStyles: RenderStyle[];
|
|
54
|
+
/** Flow line count multiplier (1 = default) */
|
|
55
|
+
flowLineMultiplier: number;
|
|
56
|
+
/** Whether to draw the hero shape */
|
|
57
|
+
heroShape: boolean;
|
|
58
|
+
/** Glow probability multiplier */
|
|
59
|
+
glowMultiplier: number;
|
|
60
|
+
/** Shape size power curve exponent (higher = more small shapes) */
|
|
61
|
+
sizePower: number;
|
|
62
|
+
/** Whether to invert colors (light shapes on dark, or dark on light) */
|
|
63
|
+
invertForeground: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Archetype definitions ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const ARCHETYPES: Archetype[] = [
|
|
69
|
+
{
|
|
70
|
+
name: "dense-chaotic",
|
|
71
|
+
gridSize: 9,
|
|
72
|
+
layers: 5,
|
|
73
|
+
baseOpacity: 0.5,
|
|
74
|
+
opacityReduction: 0.06,
|
|
75
|
+
minShapeSize: 10,
|
|
76
|
+
maxShapeSize: 200,
|
|
77
|
+
backgroundStyle: "radial-dark",
|
|
78
|
+
paletteMode: "harmonious",
|
|
79
|
+
preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
|
|
80
|
+
flowLineMultiplier: 2.5,
|
|
81
|
+
heroShape: false,
|
|
82
|
+
glowMultiplier: 0.5,
|
|
83
|
+
sizePower: 1.2,
|
|
84
|
+
invertForeground: false,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "minimal-spacious",
|
|
88
|
+
gridSize: 2,
|
|
89
|
+
layers: 2,
|
|
90
|
+
baseOpacity: 0.85,
|
|
91
|
+
opacityReduction: 0.05,
|
|
92
|
+
minShapeSize: 150,
|
|
93
|
+
maxShapeSize: 600,
|
|
94
|
+
backgroundStyle: "solid-light",
|
|
95
|
+
paletteMode: "duotone",
|
|
96
|
+
preferredStyles: ["fill-and-stroke", "stroke-only", "incomplete"],
|
|
97
|
+
flowLineMultiplier: 0.3,
|
|
98
|
+
heroShape: true,
|
|
99
|
+
glowMultiplier: 0,
|
|
100
|
+
sizePower: 0.8,
|
|
101
|
+
invertForeground: false,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "organic-flow",
|
|
105
|
+
gridSize: 4,
|
|
106
|
+
layers: 3,
|
|
107
|
+
baseOpacity: 0.4,
|
|
108
|
+
opacityReduction: 0.08,
|
|
109
|
+
minShapeSize: 20,
|
|
110
|
+
maxShapeSize: 250,
|
|
111
|
+
backgroundStyle: "radial-dark",
|
|
112
|
+
paletteMode: "earth",
|
|
113
|
+
preferredStyles: ["watercolor", "fill-only", "incomplete"],
|
|
114
|
+
flowLineMultiplier: 4,
|
|
115
|
+
heroShape: false,
|
|
116
|
+
glowMultiplier: 0.3,
|
|
117
|
+
sizePower: 1.5,
|
|
118
|
+
invertForeground: false,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "geometric-precision",
|
|
122
|
+
gridSize: 6,
|
|
123
|
+
layers: 3,
|
|
124
|
+
baseOpacity: 0.9,
|
|
125
|
+
opacityReduction: 0.15,
|
|
126
|
+
minShapeSize: 40,
|
|
127
|
+
maxShapeSize: 300,
|
|
128
|
+
backgroundStyle: "solid-dark",
|
|
129
|
+
paletteMode: "high-contrast",
|
|
130
|
+
preferredStyles: ["stroke-only", "dashed", "double-stroke", "hatched"],
|
|
131
|
+
flowLineMultiplier: 0,
|
|
132
|
+
heroShape: false,
|
|
133
|
+
glowMultiplier: 0,
|
|
134
|
+
sizePower: 1.0,
|
|
135
|
+
invertForeground: false,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "ethereal",
|
|
139
|
+
gridSize: 7,
|
|
140
|
+
layers: 5,
|
|
141
|
+
baseOpacity: 0.3,
|
|
142
|
+
opacityReduction: 0.03,
|
|
143
|
+
minShapeSize: 50,
|
|
144
|
+
maxShapeSize: 500,
|
|
145
|
+
backgroundStyle: "radial-light",
|
|
146
|
+
paletteMode: "pastel-light",
|
|
147
|
+
preferredStyles: ["watercolor", "incomplete", "fill-only"],
|
|
148
|
+
flowLineMultiplier: 1.5,
|
|
149
|
+
heroShape: true,
|
|
150
|
+
glowMultiplier: 2,
|
|
151
|
+
sizePower: 1.4,
|
|
152
|
+
invertForeground: false,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "bold-graphic",
|
|
156
|
+
gridSize: 2,
|
|
157
|
+
layers: 2,
|
|
158
|
+
baseOpacity: 0.95,
|
|
159
|
+
opacityReduction: 0.1,
|
|
160
|
+
minShapeSize: 200,
|
|
161
|
+
maxShapeSize: 800,
|
|
162
|
+
backgroundStyle: "linear-diagonal",
|
|
163
|
+
paletteMode: "duotone",
|
|
164
|
+
preferredStyles: ["fill-and-stroke", "double-stroke"],
|
|
165
|
+
flowLineMultiplier: 0,
|
|
166
|
+
heroShape: true,
|
|
167
|
+
glowMultiplier: 0,
|
|
168
|
+
sizePower: 0.5,
|
|
169
|
+
invertForeground: false,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "neon-glow",
|
|
173
|
+
gridSize: 5,
|
|
174
|
+
layers: 4,
|
|
175
|
+
baseOpacity: 0.6,
|
|
176
|
+
opacityReduction: 0.1,
|
|
177
|
+
minShapeSize: 20,
|
|
178
|
+
maxShapeSize: 350,
|
|
179
|
+
backgroundStyle: "solid-dark",
|
|
180
|
+
paletteMode: "neon",
|
|
181
|
+
preferredStyles: ["stroke-only", "double-stroke", "dashed"],
|
|
182
|
+
flowLineMultiplier: 2,
|
|
183
|
+
heroShape: true,
|
|
184
|
+
glowMultiplier: 3,
|
|
185
|
+
sizePower: 1.6,
|
|
186
|
+
invertForeground: false,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "monochrome-ink",
|
|
190
|
+
gridSize: 6,
|
|
191
|
+
layers: 3,
|
|
192
|
+
baseOpacity: 0.7,
|
|
193
|
+
opacityReduction: 0.15,
|
|
194
|
+
minShapeSize: 15,
|
|
195
|
+
maxShapeSize: 350,
|
|
196
|
+
backgroundStyle: "solid-light",
|
|
197
|
+
paletteMode: "monochrome",
|
|
198
|
+
preferredStyles: ["hatched", "incomplete", "stroke-only", "dashed"],
|
|
199
|
+
flowLineMultiplier: 1.5,
|
|
200
|
+
heroShape: false,
|
|
201
|
+
glowMultiplier: 0,
|
|
202
|
+
sizePower: 1.8,
|
|
203
|
+
invertForeground: false,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "cosmic",
|
|
207
|
+
gridSize: 8,
|
|
208
|
+
layers: 5,
|
|
209
|
+
baseOpacity: 0.5,
|
|
210
|
+
opacityReduction: 0.06,
|
|
211
|
+
minShapeSize: 5,
|
|
212
|
+
maxShapeSize: 300,
|
|
213
|
+
backgroundStyle: "radial-dark",
|
|
214
|
+
paletteMode: "neon",
|
|
215
|
+
preferredStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
216
|
+
flowLineMultiplier: 3,
|
|
217
|
+
heroShape: true,
|
|
218
|
+
glowMultiplier: 2.5,
|
|
219
|
+
sizePower: 2.5,
|
|
220
|
+
invertForeground: false,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "classic",
|
|
224
|
+
gridSize: 5,
|
|
225
|
+
layers: 4,
|
|
226
|
+
baseOpacity: 0.7,
|
|
227
|
+
opacityReduction: 0.12,
|
|
228
|
+
minShapeSize: 30,
|
|
229
|
+
maxShapeSize: 400,
|
|
230
|
+
backgroundStyle: "radial-dark",
|
|
231
|
+
paletteMode: "harmonious",
|
|
232
|
+
preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
|
|
233
|
+
flowLineMultiplier: 1,
|
|
234
|
+
heroShape: true,
|
|
235
|
+
glowMultiplier: 1,
|
|
236
|
+
sizePower: 1.8,
|
|
237
|
+
invertForeground: false,
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Select an archetype deterministically from the hash.
|
|
243
|
+
* The "classic" archetype preserves the original look for backward compat
|
|
244
|
+
* but only gets ~10% of hashes.
|
|
245
|
+
*/
|
|
246
|
+
export function selectArchetype(rng: () => number): Archetype {
|
|
247
|
+
return ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
|
|
248
|
+
}
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -148,6 +148,77 @@ export class SacredColorScheme {
|
|
|
148
148
|
return [...new Set(all)];
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Returns a palette shaped by the given palette mode.
|
|
153
|
+
* Falls back to getColors() for "harmonious".
|
|
154
|
+
*/
|
|
155
|
+
getColorsByMode(mode: string): string[] {
|
|
156
|
+
const baseHue = this.seed % 360;
|
|
157
|
+
switch (mode) {
|
|
158
|
+
case "monochrome": {
|
|
159
|
+
// Single hue, 5 lightness steps
|
|
160
|
+
const s = 0.5 + this.rng() * 0.3;
|
|
161
|
+
return [0.15, 0.3, 0.45, 0.6, 0.75].map((l) =>
|
|
162
|
+
hslToHex(baseHue, s, l),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
case "duotone": {
|
|
166
|
+
// Two contrasting colors + tints
|
|
167
|
+
const hue2 = (baseHue + 150 + this.rng() * 60) % 360;
|
|
168
|
+
return [
|
|
169
|
+
hslToHex(baseHue, 0.7, 0.5),
|
|
170
|
+
hslToHex(baseHue, 0.6, 0.7),
|
|
171
|
+
hslToHex(hue2, 0.7, 0.5),
|
|
172
|
+
hslToHex(hue2, 0.6, 0.7),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
case "neon": {
|
|
176
|
+
// High saturation, vivid colors
|
|
177
|
+
const hues = [baseHue, (baseHue + 90) % 360, (baseHue + 180) % 360, (baseHue + 270) % 360];
|
|
178
|
+
return hues.map((h) => hslToHex(h, 1.0, 0.55 + this.rng() * 0.1));
|
|
179
|
+
}
|
|
180
|
+
case "pastel-light": {
|
|
181
|
+
// Soft pastels
|
|
182
|
+
const hues = [baseHue, (baseHue + 60) % 360, (baseHue + 120) % 360, (baseHue + 200) % 360];
|
|
183
|
+
return hues.map((h) => hslToHex(h, 0.4 + this.rng() * 0.2, 0.75 + this.rng() * 0.1));
|
|
184
|
+
}
|
|
185
|
+
case "earth": {
|
|
186
|
+
// Warm muted naturals: browns, olives, terracotta, sage
|
|
187
|
+
const earthHues = [25, 35, 45, 80, 150]; // orange-brown to olive to sage
|
|
188
|
+
return earthHues.map((h) =>
|
|
189
|
+
hslToHex(h + this.rng() * 15, 0.25 + this.rng() * 0.2, 0.35 + this.rng() * 0.2),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
case "high-contrast": {
|
|
193
|
+
// Black, white, and one accent color
|
|
194
|
+
const accent = hslToHex(baseHue, 0.9, 0.5);
|
|
195
|
+
return ["#111111", "#eeeeee", accent, hslToHex(baseHue, 0.7, 0.35)];
|
|
196
|
+
}
|
|
197
|
+
case "harmonious":
|
|
198
|
+
default:
|
|
199
|
+
return this.getColors();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns background colors appropriate for the given palette mode.
|
|
205
|
+
*/
|
|
206
|
+
getBackgroundColorsByMode(mode: string): [string, string] {
|
|
207
|
+
switch (mode) {
|
|
208
|
+
case "pastel-light":
|
|
209
|
+
return [hslToHex(this.seed % 360, 0.15, 0.92), hslToHex((this.seed + 30) % 360, 0.1, 0.88)];
|
|
210
|
+
case "high-contrast":
|
|
211
|
+
case "monochrome-ink":
|
|
212
|
+
return ["#f5f5f0", "#e8e8e0"];
|
|
213
|
+
case "neon":
|
|
214
|
+
return ["#0a0a12", "#050510"];
|
|
215
|
+
case "earth":
|
|
216
|
+
return [this.darken(hslToHex(35, 0.3, 0.25), 0.8), this.darken(hslToHex(25, 0.25, 0.2), 0.7)];
|
|
217
|
+
default:
|
|
218
|
+
return this.getBackgroundColors();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
151
222
|
/**
|
|
152
223
|
* Returns two background colors derived from the hash — darker variants
|
|
153
224
|
* of the base scheme, temperature-shifted for warm/cool contrast.
|
|
@@ -286,3 +357,48 @@ export function shiftTemperature(hex: string, target: "warm" | "cool", amount: n
|
|
|
286
357
|
const [h, s, l] = hexToHsl(hex);
|
|
287
358
|
return hslToHex(shiftHueToward(h, target, amount), s, l);
|
|
288
359
|
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Compute relative luminance of a hex color (0 = black, 1 = white).
|
|
363
|
+
* Uses the sRGB luminance formula from WCAG.
|
|
364
|
+
*/
|
|
365
|
+
export function luminance(hex: string): number {
|
|
366
|
+
const [r, g, b] = hexToRgb(hex).map((c) => {
|
|
367
|
+
const s = c / 255;
|
|
368
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
369
|
+
});
|
|
370
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Enforce minimum contrast between a foreground color and a background
|
|
375
|
+
* luminance. On light backgrounds, darkens/saturates the foreground.
|
|
376
|
+
* On dark backgrounds, lightens/saturates the foreground.
|
|
377
|
+
*
|
|
378
|
+
* `bgLuminance` is 0-1 (pre-computed from the background color).
|
|
379
|
+
* `minContrast` is the minimum luminance difference to enforce (default 0.15).
|
|
380
|
+
*/
|
|
381
|
+
export function enforceContrast(
|
|
382
|
+
fgHex: string,
|
|
383
|
+
bgLuminance: number,
|
|
384
|
+
minContrast = 0.15,
|
|
385
|
+
): string {
|
|
386
|
+
const fgLum = luminance(fgHex);
|
|
387
|
+
const diff = Math.abs(fgLum - bgLuminance);
|
|
388
|
+
|
|
389
|
+
if (diff >= minContrast) return fgHex;
|
|
390
|
+
|
|
391
|
+
const [h, s, l] = hexToHsl(fgHex);
|
|
392
|
+
|
|
393
|
+
if (bgLuminance > 0.5) {
|
|
394
|
+
// Light background — darken and boost saturation
|
|
395
|
+
const targetL = Math.max(0.08, l - (minContrast - diff) * 1.5);
|
|
396
|
+
const targetS = Math.min(1, s + 0.2);
|
|
397
|
+
return hslToHex(h, targetS, targetL);
|
|
398
|
+
} else {
|
|
399
|
+
// Dark background — lighten and boost saturation
|
|
400
|
+
const targetL = Math.min(0.92, l + (minContrast - diff) * 1.5);
|
|
401
|
+
const targetS = Math.min(1, s + 0.15);
|
|
402
|
+
return hslToHex(h, targetS, targetL);
|
|
403
|
+
}
|
|
404
|
+
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -304,7 +304,7 @@ export function enhanceShapeGeneration(
|
|
|
304
304
|
|
|
305
305
|
const drawFunction = shapes[shape];
|
|
306
306
|
if (drawFunction) {
|
|
307
|
-
drawFunction(ctx, size);
|
|
307
|
+
drawFunction(ctx, size, { rng });
|
|
308
308
|
applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
|
|
309
309
|
}
|
|
310
310
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basicShapes } from "./basic";
|
|
2
2
|
import { complexShapes } from "./complex";
|
|
3
3
|
import { sacredShapes } from "./sacred";
|
|
4
|
+
import { proceduralShapes } from "./procedural";
|
|
4
5
|
|
|
5
6
|
type DrawFunction = (
|
|
6
7
|
ctx: CanvasRenderingContext2D,
|
|
@@ -12,4 +13,5 @@ export const shapes: Record<string, DrawFunction> = {
|
|
|
12
13
|
...basicShapes,
|
|
13
14
|
...complexShapes,
|
|
14
15
|
...sacredShapes,
|
|
16
|
+
...proceduralShapes,
|
|
15
17
|
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedural shape generators — hash-derived shapes that are unique
|
|
3
|
+
* per generation. Unlike the fixed shape library, these produce geometry
|
|
4
|
+
* that doesn't repeat across hashes.
|
|
5
|
+
*
|
|
6
|
+
* All draw functions accept an RNG via the config parameter so the
|
|
7
|
+
* shapes are deterministic from the hash.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type DrawFunction = (
|
|
11
|
+
ctx: CanvasRenderingContext2D,
|
|
12
|
+
size: number,
|
|
13
|
+
config?: any,
|
|
14
|
+
) => void;
|
|
15
|
+
|
|
16
|
+
// ── Blob: organic closed curve via cubic bezier ─────────────────────
|
|
17
|
+
// Generates 5-9 control points around a circle with hash-derived
|
|
18
|
+
// radius jitter, then connects them with smooth cubic beziers.
|
|
19
|
+
|
|
20
|
+
export const drawBlob: DrawFunction = (ctx, size, config) => {
|
|
21
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
22
|
+
const r = size / 2;
|
|
23
|
+
const numPoints = 5 + Math.floor(rng() * 5); // 5-9 lobes
|
|
24
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < numPoints; i++) {
|
|
27
|
+
const angle = (i / numPoints) * Math.PI * 2;
|
|
28
|
+
const jitter = 0.5 + rng() * 0.5; // radius varies 50-100%
|
|
29
|
+
points.push({
|
|
30
|
+
x: Math.cos(angle) * r * jitter,
|
|
31
|
+
y: Math.sin(angle) * r * jitter,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ctx.beginPath();
|
|
36
|
+
// Start at midpoint between last and first point
|
|
37
|
+
const last = points[points.length - 1];
|
|
38
|
+
const first = points[0];
|
|
39
|
+
ctx.moveTo((last.x + first.x) / 2, (last.y + first.y) / 2);
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < numPoints; i++) {
|
|
42
|
+
const curr = points[i];
|
|
43
|
+
const next = points[(i + 1) % numPoints];
|
|
44
|
+
const midX = (curr.x + next.x) / 2;
|
|
45
|
+
const midY = (curr.y + next.y) / 2;
|
|
46
|
+
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
|
|
47
|
+
}
|
|
48
|
+
ctx.closePath();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Ngon: irregular polygon with hash-controlled vertices ───────────
|
|
52
|
+
// Vertex count 3-12, each vertex has independent radius jitter
|
|
53
|
+
// producing irregular, organic polygons.
|
|
54
|
+
|
|
55
|
+
export const drawNgon: DrawFunction = (ctx, size, config) => {
|
|
56
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
57
|
+
const r = size / 2;
|
|
58
|
+
const sides = 3 + Math.floor(rng() * 10); // 3-12 sides
|
|
59
|
+
const jitterAmount = 0.1 + rng() * 0.4; // 10-50% vertex displacement
|
|
60
|
+
|
|
61
|
+
ctx.beginPath();
|
|
62
|
+
for (let i = 0; i < sides; i++) {
|
|
63
|
+
const angle = (i / sides) * Math.PI * 2 - Math.PI / 2;
|
|
64
|
+
const radiusJitter = 1 - jitterAmount + rng() * jitterAmount * 2;
|
|
65
|
+
const x = Math.cos(angle) * r * radiusJitter;
|
|
66
|
+
const y = Math.sin(angle) * r * radiusJitter;
|
|
67
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
68
|
+
else ctx.lineTo(x, y);
|
|
69
|
+
}
|
|
70
|
+
ctx.closePath();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ── Lissajous: parametric curves with hash-derived frequency ratios ─
|
|
74
|
+
// Produces figure-8s, knots, and complex looping curves.
|
|
75
|
+
|
|
76
|
+
export const drawLissajous: DrawFunction = (ctx, size, config) => {
|
|
77
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
78
|
+
const r = size / 2;
|
|
79
|
+
// Frequency ratios — small integers produce recognizable patterns
|
|
80
|
+
const freqA = 1 + Math.floor(rng() * 5); // 1-5
|
|
81
|
+
const freqB = 1 + Math.floor(rng() * 5); // 1-5
|
|
82
|
+
const phase = rng() * Math.PI; // phase offset
|
|
83
|
+
const steps = 120;
|
|
84
|
+
|
|
85
|
+
ctx.beginPath();
|
|
86
|
+
for (let i = 0; i <= steps; i++) {
|
|
87
|
+
const t = (i / steps) * Math.PI * 2;
|
|
88
|
+
const x = Math.sin(freqA * t + phase) * r;
|
|
89
|
+
const y = Math.sin(freqB * t) * r;
|
|
90
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
91
|
+
else ctx.lineTo(x, y);
|
|
92
|
+
}
|
|
93
|
+
ctx.closePath();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── Superellipse: |x|^n + |y|^n = 1 with hash-derived exponent ─────
|
|
97
|
+
// n=2 is circle, n>2 is squircle, n<1 is astroid/star-like.
|
|
98
|
+
|
|
99
|
+
export const drawSuperellipse: DrawFunction = (ctx, size, config) => {
|
|
100
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
101
|
+
const r = size / 2;
|
|
102
|
+
// Exponent range: 0.3 (spiky astroid) to 5 (rounded rectangle)
|
|
103
|
+
const n = 0.3 + rng() * 4.7;
|
|
104
|
+
const steps = 120;
|
|
105
|
+
|
|
106
|
+
ctx.beginPath();
|
|
107
|
+
for (let i = 0; i <= steps; i++) {
|
|
108
|
+
const t = (i / steps) * Math.PI * 2;
|
|
109
|
+
const cosT = Math.cos(t);
|
|
110
|
+
const sinT = Math.sin(t);
|
|
111
|
+
// Superellipse parametric form
|
|
112
|
+
const x = Math.sign(cosT) * Math.pow(Math.abs(cosT), 2 / n) * r;
|
|
113
|
+
const y = Math.sign(sinT) * Math.pow(Math.abs(sinT), 2 / n) * r;
|
|
114
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
115
|
+
else ctx.lineTo(x, y);
|
|
116
|
+
}
|
|
117
|
+
ctx.closePath();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ── Spirograph: hypotrochoid curves ─────────────────────────────────
|
|
121
|
+
// Inner/outer radius ratios from hash produce unique looping patterns.
|
|
122
|
+
|
|
123
|
+
export const drawSpirograph: DrawFunction = (ctx, size, config) => {
|
|
124
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
125
|
+
const scale = size / 2;
|
|
126
|
+
// R = outer radius, r = inner radius, d = pen distance from inner center
|
|
127
|
+
const R = 1;
|
|
128
|
+
const r = 0.2 + rng() * 0.6; // 0.2-0.8
|
|
129
|
+
const d = 0.3 + rng() * 0.7; // 0.3-1.0
|
|
130
|
+
// Number of full rotations needed to close the curve
|
|
131
|
+
const gcd = (a: number, b: number): number => {
|
|
132
|
+
const ai = Math.round(a * 1000);
|
|
133
|
+
const bi = Math.round(b * 1000);
|
|
134
|
+
const g = (x: number, y: number): number => (y === 0 ? x : g(y, x % y));
|
|
135
|
+
return g(ai, bi) / 1000;
|
|
136
|
+
};
|
|
137
|
+
const period = r / gcd(R, r);
|
|
138
|
+
const maxT = Math.min(period, 10) * Math.PI * 2; // cap at 10 rotations
|
|
139
|
+
const steps = Math.min(600, Math.floor(maxT * 20));
|
|
140
|
+
|
|
141
|
+
ctx.beginPath();
|
|
142
|
+
for (let i = 0; i <= steps; i++) {
|
|
143
|
+
const t = (i / steps) * maxT;
|
|
144
|
+
const x = ((R - r) * Math.cos(t) + d * Math.cos(((R - r) / r) * t)) * scale / (1 + d);
|
|
145
|
+
const y = ((R - r) * Math.sin(t) - d * Math.sin(((R - r) / r) * t)) * scale / (1 + d);
|
|
146
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
147
|
+
else ctx.lineTo(x, y);
|
|
148
|
+
}
|
|
149
|
+
ctx.closePath();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ── Wave ring: concentric ring with sinusoidal displacement ─────────
|
|
153
|
+
// Hash controls frequency, amplitude, and number of rings.
|
|
154
|
+
|
|
155
|
+
export const drawWaveRing: DrawFunction = (ctx, size, config) => {
|
|
156
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
157
|
+
const r = size / 2;
|
|
158
|
+
const rings = 2 + Math.floor(rng() * 4); // 2-5 rings
|
|
159
|
+
const freq = 3 + Math.floor(rng() * 12); // 3-14 waves per ring
|
|
160
|
+
const amp = 0.05 + rng() * 0.15; // 5-20% of radius
|
|
161
|
+
|
|
162
|
+
ctx.beginPath();
|
|
163
|
+
for (let ring = 0; ring < rings; ring++) {
|
|
164
|
+
const baseR = r * (0.3 + (ring / rings) * 0.7);
|
|
165
|
+
const steps = 80;
|
|
166
|
+
for (let i = 0; i <= steps; i++) {
|
|
167
|
+
const t = (i / steps) * Math.PI * 2;
|
|
168
|
+
const wave = Math.sin(t * freq + ring * 1.5) * baseR * amp;
|
|
169
|
+
const x = Math.cos(t) * (baseR + wave);
|
|
170
|
+
const y = Math.sin(t) * (baseR + wave);
|
|
171
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
172
|
+
else ctx.lineTo(x, y);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ── Rose curve: polar rose r = cos(k*theta) ────────────────────────
|
|
178
|
+
// k determines petal count. Integer k = k petals (odd) or 2k petals (even).
|
|
179
|
+
|
|
180
|
+
export const drawRose: DrawFunction = (ctx, size, config) => {
|
|
181
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
182
|
+
const r = size / 2;
|
|
183
|
+
const k = 2 + Math.floor(rng() * 6); // 2-7 petal parameter
|
|
184
|
+
const steps = 200;
|
|
185
|
+
|
|
186
|
+
ctx.beginPath();
|
|
187
|
+
for (let i = 0; i <= steps; i++) {
|
|
188
|
+
const theta = (i / steps) * Math.PI * 2 * (k % 2 === 0 ? 1 : 2);
|
|
189
|
+
const rr = Math.cos(k * theta) * r;
|
|
190
|
+
const x = rr * Math.cos(theta);
|
|
191
|
+
const y = rr * Math.sin(theta);
|
|
192
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
193
|
+
else ctx.lineTo(x, y);
|
|
194
|
+
}
|
|
195
|
+
ctx.closePath();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
// ── Shape registry ──────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export const proceduralShapes: Record<string, DrawFunction> = {
|
|
202
|
+
blob: drawBlob,
|
|
203
|
+
ngon: drawNgon,
|
|
204
|
+
lissajous: drawLissajous,
|
|
205
|
+
superellipse: drawSuperellipse,
|
|
206
|
+
spirograph: drawSpirograph,
|
|
207
|
+
waveRing: drawWaveRing,
|
|
208
|
+
rose: drawRose,
|
|
209
|
+
};
|