git-hash-art 0.6.0 → 0.8.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.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Shape affinity system — controls which shapes look good together,
3
+ * quality tiers for different rendering contexts, and size preferences.
4
+ *
5
+ * This replaces the naive "pick any shape" approach with intentional
6
+ * curation that produces more cohesive compositions.
7
+ */
8
+
9
+ // ── Quality tiers ───────────────────────────────────────────────────
10
+ // Not all shapes render equally well at all sizes or in all contexts.
11
+ // Tier 1 shapes are visually strong at any size; Tier 3 shapes need
12
+ // specific conditions to look good.
13
+
14
+ export type ShapeTier = 1 | 2 | 3;
15
+
16
+ export interface ShapeProfile {
17
+ /** Visual quality tier (1 = always good, 3 = situational) */
18
+ tier: ShapeTier;
19
+ /** Minimum size (as fraction of maxShapeSize) before shape looks bad */
20
+ minSizeFraction: number;
21
+ /** Maximum size fraction — some shapes look bad when huge */
22
+ maxSizeFraction: number;
23
+ /** Which shapes this one composes well with */
24
+ affinities: string[];
25
+ /** Category for grouping */
26
+ category: "basic" | "complex" | "sacred" | "procedural";
27
+ /** Whether this shape works well as a hero/focal element */
28
+ heroCandidate: boolean;
29
+ /** Best render styles for this shape */
30
+ bestStyles: string[];
31
+ }
32
+
33
+ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
34
+ // ── Basic shapes ──────────────────────────────────────────────
35
+ circle: {
36
+ tier: 1,
37
+ minSizeFraction: 0.05,
38
+ maxSizeFraction: 1.0,
39
+ affinities: ["circle", "blob", "hexagon", "flowerOfLife", "seedOfLife"],
40
+ category: "basic",
41
+ heroCandidate: false,
42
+ bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
43
+ },
44
+ square: {
45
+ tier: 2,
46
+ minSizeFraction: 0.08,
47
+ maxSizeFraction: 0.7,
48
+ affinities: ["square", "diamond", "superellipse", "islamicPattern"],
49
+ category: "basic",
50
+ heroCandidate: false,
51
+ bestStyles: ["fill-and-stroke", "stroke-only", "hatched"],
52
+ },
53
+ triangle: {
54
+ tier: 1,
55
+ minSizeFraction: 0.06,
56
+ maxSizeFraction: 0.9,
57
+ affinities: ["triangle", "diamond", "hexagon", "merkaba", "sriYantra"],
58
+ category: "basic",
59
+ heroCandidate: false,
60
+ bestStyles: ["fill-and-stroke", "fill-only", "watercolor"],
61
+ },
62
+ hexagon: {
63
+ tier: 1,
64
+ minSizeFraction: 0.05,
65
+ maxSizeFraction: 1.0,
66
+ affinities: ["hexagon", "circle", "flowerOfLife", "metatronsCube", "triangle"],
67
+ category: "basic",
68
+ heroCandidate: false,
69
+ bestStyles: ["fill-only", "fill-and-stroke", "watercolor"],
70
+ },
71
+ star: {
72
+ tier: 2,
73
+ minSizeFraction: 0.08,
74
+ maxSizeFraction: 0.6,
75
+ affinities: ["star", "circle", "mandala", "spirograph"],
76
+ category: "basic",
77
+ heroCandidate: false,
78
+ bestStyles: ["fill-and-stroke", "stroke-only", "dashed"],
79
+ },
80
+ "jacked-star": {
81
+ tier: 3,
82
+ minSizeFraction: 0.1,
83
+ maxSizeFraction: 0.4,
84
+ affinities: ["star", "circle"],
85
+ category: "basic",
86
+ heroCandidate: false,
87
+ bestStyles: ["stroke-only", "dashed"],
88
+ },
89
+ heart: {
90
+ tier: 3,
91
+ minSizeFraction: 0.1,
92
+ maxSizeFraction: 0.5,
93
+ affinities: ["circle", "blob"],
94
+ category: "basic",
95
+ heroCandidate: false,
96
+ bestStyles: ["fill-only", "watercolor"],
97
+ },
98
+ diamond: {
99
+ tier: 2,
100
+ minSizeFraction: 0.06,
101
+ maxSizeFraction: 0.8,
102
+ affinities: ["diamond", "triangle", "square", "merkaba"],
103
+ category: "basic",
104
+ heroCandidate: false,
105
+ bestStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
106
+ },
107
+ cube: {
108
+ tier: 3,
109
+ minSizeFraction: 0.08,
110
+ maxSizeFraction: 0.5,
111
+ affinities: ["square", "diamond"],
112
+ category: "basic",
113
+ heroCandidate: false,
114
+ bestStyles: ["stroke-only", "fill-and-stroke"],
115
+ },
116
+
117
+ // ── Complex shapes ────────────────────────────────────────────
118
+ platonicSolid: {
119
+ tier: 2,
120
+ minSizeFraction: 0.15,
121
+ maxSizeFraction: 0.8,
122
+ affinities: ["metatronsCube", "merkaba", "hexagon", "triangle"],
123
+ category: "complex",
124
+ heroCandidate: true,
125
+ bestStyles: ["stroke-only", "double-stroke", "dashed"],
126
+ },
127
+ fibonacciSpiral: {
128
+ tier: 1,
129
+ minSizeFraction: 0.2,
130
+ maxSizeFraction: 1.0,
131
+ affinities: ["circle", "rose", "spirograph", "flowerOfLife"],
132
+ category: "complex",
133
+ heroCandidate: true,
134
+ bestStyles: ["stroke-only", "incomplete", "watercolor"],
135
+ },
136
+ islamicPattern: {
137
+ tier: 2,
138
+ minSizeFraction: 0.25,
139
+ maxSizeFraction: 0.9,
140
+ affinities: ["square", "hexagon", "star", "mandala"],
141
+ category: "complex",
142
+ heroCandidate: true,
143
+ bestStyles: ["stroke-only", "dashed", "hatched"],
144
+ },
145
+ celticKnot: {
146
+ tier: 2,
147
+ minSizeFraction: 0.2,
148
+ maxSizeFraction: 0.7,
149
+ affinities: ["circle", "lissajous", "spirograph"],
150
+ category: "complex",
151
+ heroCandidate: true,
152
+ bestStyles: ["stroke-only", "double-stroke"],
153
+ },
154
+ merkaba: {
155
+ tier: 1,
156
+ minSizeFraction: 0.15,
157
+ maxSizeFraction: 1.0,
158
+ affinities: ["triangle", "diamond", "sriYantra", "metatronsCube"],
159
+ category: "complex",
160
+ heroCandidate: true,
161
+ bestStyles: ["stroke-only", "fill-and-stroke", "double-stroke"],
162
+ },
163
+ mandala: {
164
+ tier: 1,
165
+ minSizeFraction: 0.2,
166
+ maxSizeFraction: 1.0,
167
+ affinities: ["circle", "flowerOfLife", "spirograph", "rose"],
168
+ category: "complex",
169
+ heroCandidate: true,
170
+ bestStyles: ["stroke-only", "dashed", "incomplete"],
171
+ },
172
+ fractal: {
173
+ tier: 2,
174
+ minSizeFraction: 0.2,
175
+ maxSizeFraction: 0.8,
176
+ affinities: ["blob", "lissajous", "circle"],
177
+ category: "complex",
178
+ heroCandidate: true,
179
+ bestStyles: ["stroke-only", "incomplete"],
180
+ },
181
+
182
+ // ── Sacred shapes ─────────────────────────────────────────────
183
+ flowerOfLife: {
184
+ tier: 1,
185
+ minSizeFraction: 0.2,
186
+ maxSizeFraction: 1.0,
187
+ affinities: ["circle", "hexagon", "seedOfLife", "eggOfLife", "metatronsCube"],
188
+ category: "sacred",
189
+ heroCandidate: true,
190
+ bestStyles: ["stroke-only", "watercolor", "incomplete"],
191
+ },
192
+ treeOfLife: {
193
+ tier: 2,
194
+ minSizeFraction: 0.25,
195
+ maxSizeFraction: 0.9,
196
+ affinities: ["circle", "flowerOfLife", "metatronsCube"],
197
+ category: "sacred",
198
+ heroCandidate: true,
199
+ bestStyles: ["stroke-only", "double-stroke"],
200
+ },
201
+ metatronsCube: {
202
+ tier: 1,
203
+ minSizeFraction: 0.2,
204
+ maxSizeFraction: 1.0,
205
+ affinities: ["hexagon", "flowerOfLife", "platonicSolid", "merkaba"],
206
+ category: "sacred",
207
+ heroCandidate: true,
208
+ bestStyles: ["stroke-only", "dashed", "incomplete"],
209
+ },
210
+ sriYantra: {
211
+ tier: 1,
212
+ minSizeFraction: 0.2,
213
+ maxSizeFraction: 1.0,
214
+ affinities: ["triangle", "merkaba", "mandala", "diamond"],
215
+ category: "sacred",
216
+ heroCandidate: true,
217
+ bestStyles: ["stroke-only", "fill-and-stroke", "double-stroke"],
218
+ },
219
+ seedOfLife: {
220
+ tier: 1,
221
+ minSizeFraction: 0.15,
222
+ maxSizeFraction: 0.9,
223
+ affinities: ["circle", "flowerOfLife", "eggOfLife", "hexagon"],
224
+ category: "sacred",
225
+ heroCandidate: true,
226
+ bestStyles: ["stroke-only", "watercolor", "fill-only"],
227
+ },
228
+ vesicaPiscis: {
229
+ tier: 2,
230
+ minSizeFraction: 0.15,
231
+ maxSizeFraction: 0.7,
232
+ affinities: ["circle", "seedOfLife", "flowerOfLife"],
233
+ category: "sacred",
234
+ heroCandidate: false,
235
+ bestStyles: ["stroke-only", "watercolor"],
236
+ },
237
+ torus: {
238
+ tier: 3,
239
+ minSizeFraction: 0.2,
240
+ maxSizeFraction: 0.6,
241
+ affinities: ["circle", "spirograph", "waveRing"],
242
+ category: "sacred",
243
+ heroCandidate: false,
244
+ bestStyles: ["stroke-only", "dashed"],
245
+ },
246
+ eggOfLife: {
247
+ tier: 2,
248
+ minSizeFraction: 0.15,
249
+ maxSizeFraction: 0.8,
250
+ affinities: ["circle", "seedOfLife", "flowerOfLife"],
251
+ category: "sacred",
252
+ heroCandidate: true,
253
+ bestStyles: ["stroke-only", "watercolor"],
254
+ },
255
+
256
+ // ── Procedural shapes ─────────────────────────────────────────
257
+ blob: {
258
+ tier: 1,
259
+ minSizeFraction: 0.05,
260
+ maxSizeFraction: 1.0,
261
+ affinities: ["blob", "circle", "superellipse", "waveRing"],
262
+ category: "procedural",
263
+ heroCandidate: false,
264
+ bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
265
+ },
266
+ ngon: {
267
+ tier: 2,
268
+ minSizeFraction: 0.06,
269
+ maxSizeFraction: 0.8,
270
+ affinities: ["hexagon", "triangle", "diamond", "superellipse"],
271
+ category: "procedural",
272
+ heroCandidate: false,
273
+ bestStyles: ["fill-and-stroke", "fill-only", "hatched"],
274
+ },
275
+ lissajous: {
276
+ tier: 2,
277
+ minSizeFraction: 0.15,
278
+ maxSizeFraction: 0.8,
279
+ affinities: ["spirograph", "rose", "celticKnot", "fibonacciSpiral"],
280
+ category: "procedural",
281
+ heroCandidate: false,
282
+ bestStyles: ["stroke-only", "incomplete", "dashed"],
283
+ },
284
+ superellipse: {
285
+ tier: 1,
286
+ minSizeFraction: 0.05,
287
+ maxSizeFraction: 1.0,
288
+ affinities: ["circle", "square", "blob", "hexagon"],
289
+ category: "procedural",
290
+ heroCandidate: false,
291
+ bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
292
+ },
293
+ spirograph: {
294
+ tier: 1,
295
+ minSizeFraction: 0.15,
296
+ maxSizeFraction: 0.9,
297
+ affinities: ["rose", "lissajous", "mandala", "flowerOfLife"],
298
+ category: "procedural",
299
+ heroCandidate: true,
300
+ bestStyles: ["stroke-only", "incomplete", "dashed"],
301
+ },
302
+ waveRing: {
303
+ tier: 2,
304
+ minSizeFraction: 0.1,
305
+ maxSizeFraction: 0.8,
306
+ affinities: ["circle", "blob", "torus", "spirograph"],
307
+ category: "procedural",
308
+ heroCandidate: false,
309
+ bestStyles: ["stroke-only", "dashed", "incomplete"],
310
+ },
311
+ rose: {
312
+ tier: 1,
313
+ minSizeFraction: 0.1,
314
+ maxSizeFraction: 0.9,
315
+ affinities: ["spirograph", "mandala", "flowerOfLife", "circle"],
316
+ category: "procedural",
317
+ heroCandidate: true,
318
+ bestStyles: ["stroke-only", "fill-only", "watercolor"],
319
+ },
320
+ };
321
+
322
+ // ── Shape palette: curated sets of shapes that work well together ────
323
+
324
+ export interface ShapePalette {
325
+ /** Primary shapes — used most often */
326
+ primary: string[];
327
+ /** Supporting shapes — used less frequently */
328
+ supporting: string[];
329
+ /** Accent shapes — rare, for visual punctuation */
330
+ accents: string[];
331
+ }
332
+
333
+ /**
334
+ * Build a curated shape palette from a seed shape.
335
+ * Uses affinity data to select shapes that compose well together,
336
+ * filtering out low-tier shapes that don't work at the current scale.
337
+ */
338
+ export function buildShapePalette(
339
+ rng: () => number,
340
+ shapeNames: string[],
341
+ archetypeName: string,
342
+ ): ShapePalette {
343
+ const available = shapeNames.filter((s) => SHAPE_PROFILES[s]);
344
+
345
+ // Pick a seed shape — tier 1 shapes that are hero candidates
346
+ const heroPool = available.filter(
347
+ (s) => SHAPE_PROFILES[s].tier === 1 && SHAPE_PROFILES[s].heroCandidate,
348
+ );
349
+ const seedShape = heroPool.length > 0
350
+ ? heroPool[Math.floor(rng() * heroPool.length)]
351
+ : available[Math.floor(rng() * available.length)];
352
+
353
+ const seedProfile = SHAPE_PROFILES[seedShape];
354
+
355
+ // Primary: seed shape + its direct affinities (tier 1-2 only)
356
+ const primaryCandidates = [seedShape, ...seedProfile.affinities]
357
+ .filter((s) => available.includes(s))
358
+ .filter((s) => SHAPE_PROFILES[s].tier <= 2);
359
+ const primary = [...new Set(primaryCandidates)].slice(0, 5);
360
+
361
+ // Supporting: affinities of affinities, plus same-category shapes
362
+ const supportingSet = new Set<string>();
363
+ for (const p of primary) {
364
+ const profile = SHAPE_PROFILES[p];
365
+ if (!profile) continue;
366
+ for (const aff of profile.affinities) {
367
+ if (available.includes(aff) && !primary.includes(aff)) {
368
+ supportingSet.add(aff);
369
+ }
370
+ }
371
+ }
372
+ // Add same-category tier 1-2 shapes
373
+ for (const s of available) {
374
+ const p = SHAPE_PROFILES[s];
375
+ if (p.category === seedProfile.category && p.tier <= 2 && !primary.includes(s)) {
376
+ supportingSet.add(s);
377
+ }
378
+ }
379
+ const supporting = [...supportingSet].slice(0, 6);
380
+
381
+ // Accents: tier 1 shapes from other categories for contrast
382
+ const usedCategories = new Set(
383
+ [...primary, ...supporting].map((s) => SHAPE_PROFILES[s]?.category),
384
+ );
385
+ const accentCandidates = available.filter(
386
+ (s) =>
387
+ !primary.includes(s) &&
388
+ !supporting.includes(s) &&
389
+ SHAPE_PROFILES[s].tier <= 2 &&
390
+ !usedCategories.has(SHAPE_PROFILES[s].category),
391
+ );
392
+ // Shuffle and take a few
393
+ const accents: string[] = [];
394
+ const shuffled = [...accentCandidates];
395
+ for (let i = shuffled.length - 1; i > 0; i--) {
396
+ const j = Math.floor(rng() * (i + 1));
397
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
398
+ }
399
+ accents.push(...shuffled.slice(0, 3));
400
+
401
+ // For certain archetypes, bias the palette
402
+ if (archetypeName === "geometric-precision") {
403
+ // Remove blobs and organic shapes from primary
404
+ return {
405
+ primary: primary.filter((s) => SHAPE_PROFILES[s]?.category !== "procedural" || s === "ngon"),
406
+ supporting: supporting.filter((s) => s !== "blob"),
407
+ accents,
408
+ };
409
+ }
410
+ if (archetypeName === "organic-flow") {
411
+ // Boost procedural/organic shapes
412
+ const organicBoost = available.filter(
413
+ (s) => ["blob", "superellipse", "waveRing", "rose"].includes(s) && !primary.includes(s),
414
+ );
415
+ return {
416
+ primary: [...primary, ...organicBoost.slice(0, 2)],
417
+ supporting,
418
+ accents,
419
+ };
420
+ }
421
+
422
+ return { primary, supporting, accents };
423
+ }
424
+
425
+ /**
426
+ * Pick a shape from the palette with weighted probability.
427
+ * Primary: ~60%, Supporting: ~30%, Accent: ~10%.
428
+ * Also respects size constraints from the shape profile.
429
+ */
430
+ export function pickShapeFromPalette(
431
+ palette: ShapePalette,
432
+ rng: () => number,
433
+ sizeFraction: number,
434
+ ): string {
435
+ // Filter each tier by size constraints
436
+ const validPrimary = palette.primary.filter((s) => {
437
+ const p = SHAPE_PROFILES[s];
438
+ return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
439
+ });
440
+ const validSupporting = palette.supporting.filter((s) => {
441
+ const p = SHAPE_PROFILES[s];
442
+ return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
443
+ });
444
+ const validAccents = palette.accents.filter((s) => {
445
+ const p = SHAPE_PROFILES[s];
446
+ return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
447
+ });
448
+
449
+ const roll = rng();
450
+ if (roll < 0.60 && validPrimary.length > 0) {
451
+ return validPrimary[Math.floor(rng() * validPrimary.length)];
452
+ }
453
+ if (roll < 0.90 && validSupporting.length > 0) {
454
+ return validSupporting[Math.floor(rng() * validSupporting.length)];
455
+ }
456
+ if (validAccents.length > 0) {
457
+ return validAccents[Math.floor(rng() * validAccents.length)];
458
+ }
459
+ // Fallback: any valid primary or supporting
460
+ const fallback = [...validPrimary, ...validSupporting];
461
+ if (fallback.length > 0) return fallback[Math.floor(rng() * fallback.length)];
462
+ // Ultimate fallback
463
+ return palette.primary[0] || "circle";
464
+ }
465
+
466
+ /**
467
+ * Get the best render style for a shape, with some randomness.
468
+ * 70% chance of using one of the shape's best styles,
469
+ * 30% chance of using the layer's style.
470
+ */
471
+ export function pickStyleForShape(
472
+ shapeName: string,
473
+ layerStyle: string,
474
+ rng: () => number,
475
+ ): string {
476
+ const profile = SHAPE_PROFILES[shapeName];
477
+ if (!profile || rng() > 0.7) return layerStyle;
478
+ return profile.bestStyles[Math.floor(rng() * profile.bestStyles.length)];
479
+ }
@@ -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
+ };