git-hash-art 0.8.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 +10 -0
- package/bin/cli.js +17 -14
- package/bin/generateVersionComparison.js +353 -0
- package/dist/browser.js +1246 -36
- package/dist/browser.js.map +1 -1
- package/dist/main.js +1246 -36
- package/dist/main.js.map +1 -1
- package/dist/module.js +1246 -36
- package/dist/module.js.map +1 -1
- package/package.json +2 -1
- package/src/lib/archetypes.ts +68 -0
- package/src/lib/canvas/draw.ts +318 -1
- package/src/lib/canvas/shapes/affinity.ts +146 -1
- package/src/lib/canvas/shapes/procedural.ts +395 -32
- package/src/lib/render.ts +259 -13
|
@@ -14,18 +14,16 @@ type DrawFunction = (
|
|
|
14
14
|
) => void;
|
|
15
15
|
|
|
16
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
17
|
|
|
20
18
|
export const drawBlob: DrawFunction = (ctx, size, config) => {
|
|
21
19
|
const rng: () => number = config?.rng ?? Math.random;
|
|
22
20
|
const r = size / 2;
|
|
23
|
-
const numPoints = 5 + Math.floor(rng() * 5);
|
|
21
|
+
const numPoints = 5 + Math.floor(rng() * 5);
|
|
24
22
|
const points: Array<{ x: number; y: number }> = [];
|
|
25
23
|
|
|
26
24
|
for (let i = 0; i < numPoints; i++) {
|
|
27
25
|
const angle = (i / numPoints) * Math.PI * 2;
|
|
28
|
-
const jitter = 0.5 + rng() * 0.5;
|
|
26
|
+
const jitter = 0.5 + rng() * 0.5;
|
|
29
27
|
points.push({
|
|
30
28
|
x: Math.cos(angle) * r * jitter,
|
|
31
29
|
y: Math.sin(angle) * r * jitter,
|
|
@@ -33,7 +31,6 @@ export const drawBlob: DrawFunction = (ctx, size, config) => {
|
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
ctx.beginPath();
|
|
36
|
-
// Start at midpoint between last and first point
|
|
37
34
|
const last = points[points.length - 1];
|
|
38
35
|
const first = points[0];
|
|
39
36
|
ctx.moveTo((last.x + first.x) / 2, (last.y + first.y) / 2);
|
|
@@ -41,22 +38,18 @@ export const drawBlob: DrawFunction = (ctx, size, config) => {
|
|
|
41
38
|
for (let i = 0; i < numPoints; i++) {
|
|
42
39
|
const curr = points[i];
|
|
43
40
|
const next = points[(i + 1) % numPoints];
|
|
44
|
-
|
|
45
|
-
const midY = (curr.y + next.y) / 2;
|
|
46
|
-
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
|
|
41
|
+
ctx.quadraticCurveTo(curr.x, curr.y, (curr.x + next.x) / 2, (curr.y + next.y) / 2);
|
|
47
42
|
}
|
|
48
43
|
ctx.closePath();
|
|
49
44
|
};
|
|
50
45
|
|
|
51
46
|
// ── Ngon: irregular polygon with hash-controlled vertices ───────────
|
|
52
|
-
// Vertex count 3-12, each vertex has independent radius jitter
|
|
53
|
-
// producing irregular, organic polygons.
|
|
54
47
|
|
|
55
48
|
export const drawNgon: DrawFunction = (ctx, size, config) => {
|
|
56
49
|
const rng: () => number = config?.rng ?? Math.random;
|
|
57
50
|
const r = size / 2;
|
|
58
|
-
const sides = 3 + Math.floor(rng() * 10);
|
|
59
|
-
const jitterAmount = 0.1 + rng() * 0.4;
|
|
51
|
+
const sides = 3 + Math.floor(rng() * 10);
|
|
52
|
+
const jitterAmount = 0.1 + rng() * 0.4;
|
|
60
53
|
|
|
61
54
|
ctx.beginPath();
|
|
62
55
|
for (let i = 0; i < sides; i++) {
|
|
@@ -71,15 +64,13 @@ export const drawNgon: DrawFunction = (ctx, size, config) => {
|
|
|
71
64
|
};
|
|
72
65
|
|
|
73
66
|
// ── Lissajous: parametric curves with hash-derived frequency ratios ─
|
|
74
|
-
// Produces figure-8s, knots, and complex looping curves.
|
|
75
67
|
|
|
76
68
|
export const drawLissajous: DrawFunction = (ctx, size, config) => {
|
|
77
69
|
const rng: () => number = config?.rng ?? Math.random;
|
|
78
70
|
const r = size / 2;
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const phase = rng() * Math.PI; // phase offset
|
|
71
|
+
const freqA = 1 + Math.floor(rng() * 5);
|
|
72
|
+
const freqB = 1 + Math.floor(rng() * 5);
|
|
73
|
+
const phase = rng() * Math.PI;
|
|
83
74
|
const steps = 120;
|
|
84
75
|
|
|
85
76
|
ctx.beginPath();
|
|
@@ -94,12 +85,10 @@ export const drawLissajous: DrawFunction = (ctx, size, config) => {
|
|
|
94
85
|
};
|
|
95
86
|
|
|
96
87
|
// ── 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
88
|
|
|
99
89
|
export const drawSuperellipse: DrawFunction = (ctx, size, config) => {
|
|
100
90
|
const rng: () => number = config?.rng ?? Math.random;
|
|
101
91
|
const r = size / 2;
|
|
102
|
-
// Exponent range: 0.3 (spiky astroid) to 5 (rounded rectangle)
|
|
103
92
|
const n = 0.3 + rng() * 4.7;
|
|
104
93
|
const steps = 120;
|
|
105
94
|
|
|
@@ -108,7 +97,6 @@ export const drawSuperellipse: DrawFunction = (ctx, size, config) => {
|
|
|
108
97
|
const t = (i / steps) * Math.PI * 2;
|
|
109
98
|
const cosT = Math.cos(t);
|
|
110
99
|
const sinT = Math.sin(t);
|
|
111
|
-
// Superellipse parametric form
|
|
112
100
|
const x = Math.sign(cosT) * Math.pow(Math.abs(cosT), 2 / n) * r;
|
|
113
101
|
const y = Math.sign(sinT) * Math.pow(Math.abs(sinT), 2 / n) * r;
|
|
114
102
|
if (i === 0) ctx.moveTo(x, y);
|
|
@@ -118,16 +106,13 @@ export const drawSuperellipse: DrawFunction = (ctx, size, config) => {
|
|
|
118
106
|
};
|
|
119
107
|
|
|
120
108
|
// ── Spirograph: hypotrochoid curves ─────────────────────────────────
|
|
121
|
-
// Inner/outer radius ratios from hash produce unique looping patterns.
|
|
122
109
|
|
|
123
110
|
export const drawSpirograph: DrawFunction = (ctx, size, config) => {
|
|
124
111
|
const rng: () => number = config?.rng ?? Math.random;
|
|
125
112
|
const scale = size / 2;
|
|
126
|
-
// R = outer radius, r = inner radius, d = pen distance from inner center
|
|
127
113
|
const R = 1;
|
|
128
|
-
const r = 0.2 + rng() * 0.6;
|
|
129
|
-
const d = 0.3 + rng() * 0.7;
|
|
130
|
-
// Number of full rotations needed to close the curve
|
|
114
|
+
const r = 0.2 + rng() * 0.6;
|
|
115
|
+
const d = 0.3 + rng() * 0.7;
|
|
131
116
|
const gcd = (a: number, b: number): number => {
|
|
132
117
|
const ai = Math.round(a * 1000);
|
|
133
118
|
const bi = Math.round(b * 1000);
|
|
@@ -135,7 +120,7 @@ export const drawSpirograph: DrawFunction = (ctx, size, config) => {
|
|
|
135
120
|
return g(ai, bi) / 1000;
|
|
136
121
|
};
|
|
137
122
|
const period = r / gcd(R, r);
|
|
138
|
-
const maxT = Math.min(period, 10) * Math.PI * 2;
|
|
123
|
+
const maxT = Math.min(period, 10) * Math.PI * 2;
|
|
139
124
|
const steps = Math.min(600, Math.floor(maxT * 20));
|
|
140
125
|
|
|
141
126
|
ctx.beginPath();
|
|
@@ -150,14 +135,13 @@ export const drawSpirograph: DrawFunction = (ctx, size, config) => {
|
|
|
150
135
|
};
|
|
151
136
|
|
|
152
137
|
// ── Wave ring: concentric ring with sinusoidal displacement ─────────
|
|
153
|
-
// Hash controls frequency, amplitude, and number of rings.
|
|
154
138
|
|
|
155
139
|
export const drawWaveRing: DrawFunction = (ctx, size, config) => {
|
|
156
140
|
const rng: () => number = config?.rng ?? Math.random;
|
|
157
141
|
const r = size / 2;
|
|
158
|
-
const rings = 2 + Math.floor(rng() * 4);
|
|
159
|
-
const freq = 3 + Math.floor(rng() * 12);
|
|
160
|
-
const amp = 0.05 + rng() * 0.15;
|
|
142
|
+
const rings = 2 + Math.floor(rng() * 4);
|
|
143
|
+
const freq = 3 + Math.floor(rng() * 12);
|
|
144
|
+
const amp = 0.05 + rng() * 0.15;
|
|
161
145
|
|
|
162
146
|
ctx.beginPath();
|
|
163
147
|
for (let ring = 0; ring < rings; ring++) {
|
|
@@ -175,12 +159,11 @@ export const drawWaveRing: DrawFunction = (ctx, size, config) => {
|
|
|
175
159
|
};
|
|
176
160
|
|
|
177
161
|
// ── Rose curve: polar rose r = cos(k*theta) ────────────────────────
|
|
178
|
-
// k determines petal count. Integer k = k petals (odd) or 2k petals (even).
|
|
179
162
|
|
|
180
163
|
export const drawRose: DrawFunction = (ctx, size, config) => {
|
|
181
164
|
const rng: () => number = config?.rng ?? Math.random;
|
|
182
165
|
const r = size / 2;
|
|
183
|
-
const k = 2 + Math.floor(rng() * 6);
|
|
166
|
+
const k = 2 + Math.floor(rng() * 6);
|
|
184
167
|
const steps = 200;
|
|
185
168
|
|
|
186
169
|
ctx.beginPath();
|
|
@@ -195,6 +178,375 @@ export const drawRose: DrawFunction = (ctx, size, config) => {
|
|
|
195
178
|
ctx.closePath();
|
|
196
179
|
};
|
|
197
180
|
|
|
181
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
182
|
+
// NEW PROCEDURAL SHAPES
|
|
183
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
184
|
+
|
|
185
|
+
// ── ShardField: cluster of angular shards (broken glass / crystals) ─
|
|
186
|
+
// Generates 4-8 convex polygonal shards radiating from center.
|
|
187
|
+
|
|
188
|
+
export const drawShardField: DrawFunction = (ctx, size, config) => {
|
|
189
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
190
|
+
const r = size / 2;
|
|
191
|
+
const shardCount = 4 + Math.floor(rng() * 5); // 4-8 shards
|
|
192
|
+
|
|
193
|
+
ctx.beginPath();
|
|
194
|
+
for (let s = 0; s < shardCount; s++) {
|
|
195
|
+
const baseAngle = (s / shardCount) * Math.PI * 2 + (rng() - 0.5) * 0.3;
|
|
196
|
+
const dist = r * (0.15 + rng() * 0.35);
|
|
197
|
+
const cx = Math.cos(baseAngle) * dist;
|
|
198
|
+
const cy = Math.sin(baseAngle) * dist;
|
|
199
|
+
const shardSize = r * (0.2 + rng() * 0.4);
|
|
200
|
+
const verts = 3 + Math.floor(rng() * 3); // 3-5 vertices per shard
|
|
201
|
+
const shardAngleOffset = rng() * Math.PI * 2;
|
|
202
|
+
|
|
203
|
+
for (let v = 0; v < verts; v++) {
|
|
204
|
+
const angle = shardAngleOffset + (v / verts) * Math.PI * 2;
|
|
205
|
+
// Elongate shards along their radial direction
|
|
206
|
+
const stretch = v % 2 === 0 ? 1.0 : 0.3 + rng() * 0.4;
|
|
207
|
+
const px = cx + Math.cos(angle) * shardSize * stretch;
|
|
208
|
+
const py = cy + Math.sin(angle) * shardSize * stretch;
|
|
209
|
+
if (v === 0) ctx.moveTo(px, py);
|
|
210
|
+
else ctx.lineTo(px, py);
|
|
211
|
+
}
|
|
212
|
+
ctx.closePath();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── VoronoiCell: single organic cell with straight edges ────────────
|
|
217
|
+
// Simulates a Voronoi cell by generating a convex hull around
|
|
218
|
+
// a jittered set of midpoints between center and random neighbors.
|
|
219
|
+
|
|
220
|
+
export const drawVoronoiCell: DrawFunction = (ctx, size, config) => {
|
|
221
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
222
|
+
const r = size / 2;
|
|
223
|
+
const edgeCount = 5 + Math.floor(rng() * 4); // 5-8 edges
|
|
224
|
+
const points: Array<{ angle: number; x: number; y: number }> = [];
|
|
225
|
+
|
|
226
|
+
// Generate edge midpoints at varying distances
|
|
227
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
228
|
+
const angle = (i / edgeCount) * Math.PI * 2 + (rng() - 0.5) * 0.4;
|
|
229
|
+
const dist = r * (0.6 + rng() * 0.4);
|
|
230
|
+
points.push({
|
|
231
|
+
angle,
|
|
232
|
+
x: Math.cos(angle) * dist,
|
|
233
|
+
y: Math.sin(angle) * dist,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Sort by angle for proper winding
|
|
237
|
+
points.sort((a, b) => a.angle - b.angle);
|
|
238
|
+
|
|
239
|
+
ctx.beginPath();
|
|
240
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
241
|
+
for (let i = 1; i < points.length; i++) {
|
|
242
|
+
ctx.lineTo(points[i].x, points[i].y);
|
|
243
|
+
}
|
|
244
|
+
ctx.closePath();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// ── Crescent: two overlapping circles subtracted ────────────────────
|
|
248
|
+
// Hash controls bite size and angle of the crescent.
|
|
249
|
+
|
|
250
|
+
export const drawCrescent: DrawFunction = (ctx, size, config) => {
|
|
251
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
252
|
+
const r = size / 2;
|
|
253
|
+
const biteSize = 0.6 + rng() * 0.3; // 60-90% of radius
|
|
254
|
+
const biteOffset = r * (0.3 + rng() * 0.4);
|
|
255
|
+
const biteAngle = rng() * Math.PI * 2;
|
|
256
|
+
|
|
257
|
+
// Outer circle
|
|
258
|
+
ctx.beginPath();
|
|
259
|
+
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
|
260
|
+
|
|
261
|
+
// Subtract inner circle using even-odd rule
|
|
262
|
+
const bx = Math.cos(biteAngle) * biteOffset;
|
|
263
|
+
const by = Math.sin(biteAngle) * biteOffset;
|
|
264
|
+
// Draw inner circle counter-clockwise for subtraction
|
|
265
|
+
ctx.moveTo(bx + r * biteSize, by);
|
|
266
|
+
ctx.arc(bx, by, r * biteSize, 0, Math.PI * 2, true);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ── Tendril: tapered curving stroke that branches ───────────────────
|
|
270
|
+
// Like a vine or neural dendrite. Draws as a filled tapered path.
|
|
271
|
+
|
|
272
|
+
export const drawTendril: DrawFunction = (ctx, size, config) => {
|
|
273
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
274
|
+
const r = size / 2;
|
|
275
|
+
const segments = 12 + Math.floor(rng() * 8);
|
|
276
|
+
const startAngle = rng() * Math.PI * 2;
|
|
277
|
+
const curvature = (rng() - 0.5) * 0.4;
|
|
278
|
+
|
|
279
|
+
// Build spine points
|
|
280
|
+
const spine: Array<{ x: number; y: number }> = [];
|
|
281
|
+
let angle = startAngle;
|
|
282
|
+
let px = 0, py = 0;
|
|
283
|
+
for (let i = 0; i <= segments; i++) {
|
|
284
|
+
spine.push({ x: px, y: py });
|
|
285
|
+
const stepLen = (r / segments) * (1.5 + rng() * 0.5);
|
|
286
|
+
angle += curvature + (rng() - 0.5) * 0.6;
|
|
287
|
+
px += Math.cos(angle) * stepLen;
|
|
288
|
+
py += Math.sin(angle) * stepLen;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Build tapered outline by offsetting perpendicular to spine
|
|
292
|
+
ctx.beginPath();
|
|
293
|
+
const leftSide: Array<{ x: number; y: number }> = [];
|
|
294
|
+
const rightSide: Array<{ x: number; y: number }> = [];
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < spine.length; i++) {
|
|
297
|
+
const t = i / (spine.length - 1);
|
|
298
|
+
const width = r * 0.12 * (1 - t * 0.9); // taper from thick to thin
|
|
299
|
+
const next = spine[Math.min(i + 1, spine.length - 1)];
|
|
300
|
+
const dx = next.x - spine[i].x;
|
|
301
|
+
const dy = next.y - spine[i].y;
|
|
302
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
303
|
+
const nx = -dy / len;
|
|
304
|
+
const ny = dx / len;
|
|
305
|
+
leftSide.push({ x: spine[i].x + nx * width, y: spine[i].y + ny * width });
|
|
306
|
+
rightSide.push({ x: spine[i].x - nx * width, y: spine[i].y - ny * width });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
ctx.moveTo(leftSide[0].x, leftSide[0].y);
|
|
310
|
+
for (let i = 1; i < leftSide.length; i++) {
|
|
311
|
+
ctx.lineTo(leftSide[i].x, leftSide[i].y);
|
|
312
|
+
}
|
|
313
|
+
for (let i = rightSide.length - 1; i >= 0; i--) {
|
|
314
|
+
ctx.lineTo(rightSide[i].x, rightSide[i].y);
|
|
315
|
+
}
|
|
316
|
+
ctx.closePath();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// ── CloudForm: overlapping circles along a curved spine ─────────────
|
|
320
|
+
// Like cumulus clouds — soft, billowy, organic.
|
|
321
|
+
|
|
322
|
+
export const drawCloudForm: DrawFunction = (ctx, size, config) => {
|
|
323
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
324
|
+
const r = size / 2;
|
|
325
|
+
const lobeCount = 4 + Math.floor(rng() * 4); // 4-7 lobes
|
|
326
|
+
const spineAngle = rng() * Math.PI * 2;
|
|
327
|
+
const spineLen = r * 0.6;
|
|
328
|
+
|
|
329
|
+
ctx.beginPath();
|
|
330
|
+
for (let i = 0; i < lobeCount; i++) {
|
|
331
|
+
const t = (i / (lobeCount - 1)) - 0.5; // -0.5 to 0.5
|
|
332
|
+
const sx = Math.cos(spineAngle) * spineLen * t;
|
|
333
|
+
const sy = Math.sin(spineAngle) * spineLen * t;
|
|
334
|
+
// Offset perpendicular for cloud shape
|
|
335
|
+
const perpAngle = spineAngle + Math.PI / 2;
|
|
336
|
+
const perpOff = (rng() - 0.3) * r * 0.3;
|
|
337
|
+
const cx = sx + Math.cos(perpAngle) * perpOff;
|
|
338
|
+
const cy = sy + Math.sin(perpAngle) * perpOff;
|
|
339
|
+
const lobeR = r * (0.25 + rng() * 0.2);
|
|
340
|
+
ctx.moveTo(cx + lobeR, cy);
|
|
341
|
+
ctx.arc(cx, cy, lobeR, 0, Math.PI * 2);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// ── InkSplat: radial spikes with bezier curves between them ─────────
|
|
346
|
+
// Like an ink drop hitting paper — organic, explosive.
|
|
347
|
+
|
|
348
|
+
export const drawInkSplat: DrawFunction = (ctx, size, config) => {
|
|
349
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
350
|
+
const r = size / 2;
|
|
351
|
+
const spikeCount = 8 + Math.floor(rng() * 8); // 8-15 spikes
|
|
352
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < spikeCount; i++) {
|
|
355
|
+
const angle = (i / spikeCount) * Math.PI * 2;
|
|
356
|
+
const isSpike = i % 2 === 0;
|
|
357
|
+
const dist = isSpike
|
|
358
|
+
? r * (0.5 + rng() * 0.5) // spikes reach 50-100% of radius
|
|
359
|
+
: r * (0.15 + rng() * 0.2); // valleys at 15-35%
|
|
360
|
+
points.push({
|
|
361
|
+
x: Math.cos(angle) * dist,
|
|
362
|
+
y: Math.sin(angle) * dist,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
368
|
+
for (let i = 0; i < points.length; i++) {
|
|
369
|
+
const curr = points[i];
|
|
370
|
+
const next = points[(i + 1) % points.length];
|
|
371
|
+
const cpx = (curr.x + next.x) / 2 + (rng() - 0.5) * r * 0.15;
|
|
372
|
+
const cpy = (curr.y + next.y) / 2 + (rng() - 0.5) * r * 0.15;
|
|
373
|
+
ctx.quadraticCurveTo(cpx, cpy, next.x, next.y);
|
|
374
|
+
}
|
|
375
|
+
ctx.closePath();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// ── GeodesicDome: subdivided icosahedron projection ─────────────────
|
|
379
|
+
// Hash controls subdivision level (1-3). Projected to 2D.
|
|
380
|
+
|
|
381
|
+
export const drawGeodesicDome: DrawFunction = (ctx, size, config) => {
|
|
382
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
383
|
+
const r = size / 2;
|
|
384
|
+
const subdivisions = 1 + Math.floor(rng() * 3); // 1-3
|
|
385
|
+
|
|
386
|
+
// Start with icosahedron vertices projected to 2D
|
|
387
|
+
const baseVerts = 6 + subdivisions * 4;
|
|
388
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
389
|
+
for (let i = 0; i < baseVerts; i++) {
|
|
390
|
+
const angle = (i / baseVerts) * Math.PI * 2;
|
|
391
|
+
const ring = i % 2 === 0 ? 1.0 : 0.5 + rng() * 0.3;
|
|
392
|
+
points.push({
|
|
393
|
+
x: Math.cos(angle) * r * ring,
|
|
394
|
+
y: Math.sin(angle) * r * ring,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
ctx.beginPath();
|
|
399
|
+
// Draw triangulated mesh — connect each point to neighbors and center
|
|
400
|
+
for (let i = 0; i < points.length; i++) {
|
|
401
|
+
const next = points[(i + 1) % points.length];
|
|
402
|
+
ctx.moveTo(points[i].x, points[i].y);
|
|
403
|
+
ctx.lineTo(next.x, next.y);
|
|
404
|
+
// Connect to center
|
|
405
|
+
ctx.moveTo(points[i].x, points[i].y);
|
|
406
|
+
ctx.lineTo(0, 0);
|
|
407
|
+
// Cross-connect to create triangulation
|
|
408
|
+
if (i % 2 === 0 && i + 2 < points.length) {
|
|
409
|
+
ctx.moveTo(points[i].x, points[i].y);
|
|
410
|
+
ctx.lineTo(points[i + 2].x, points[i + 2].y);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Outer ring
|
|
414
|
+
ctx.moveTo(points[0].x, points[0].y);
|
|
415
|
+
for (let i = 1; i < points.length; i++) {
|
|
416
|
+
ctx.lineTo(points[i].x, points[i].y);
|
|
417
|
+
}
|
|
418
|
+
ctx.closePath();
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// ── PenroseTile: kite or dart shape from Penrose tiling ─────────────
|
|
422
|
+
// Hash selects kite vs dart and rotation.
|
|
423
|
+
|
|
424
|
+
export const drawPenroseTile: DrawFunction = (ctx, size, config) => {
|
|
425
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
426
|
+
const r = size / 2;
|
|
427
|
+
const phi = (1 + Math.sqrt(5)) / 2; // golden ratio
|
|
428
|
+
const isKite = rng() < 0.5;
|
|
429
|
+
|
|
430
|
+
ctx.beginPath();
|
|
431
|
+
if (isKite) {
|
|
432
|
+
// Kite: two golden triangles joined at base
|
|
433
|
+
const topY = -r;
|
|
434
|
+
const bottomY = r * (1 / phi);
|
|
435
|
+
const midY = r * (1 / phi - 1) * 0.3;
|
|
436
|
+
const wingX = r * 0.6;
|
|
437
|
+
ctx.moveTo(0, topY);
|
|
438
|
+
ctx.lineTo(wingX, midY);
|
|
439
|
+
ctx.lineTo(0, bottomY);
|
|
440
|
+
ctx.lineTo(-wingX, midY);
|
|
441
|
+
} else {
|
|
442
|
+
// Dart: concave quadrilateral
|
|
443
|
+
const topY = -r;
|
|
444
|
+
const bottomY = r * 0.3;
|
|
445
|
+
const midY = -r * 0.1;
|
|
446
|
+
const wingX = r * 0.5;
|
|
447
|
+
ctx.moveTo(0, topY);
|
|
448
|
+
ctx.lineTo(wingX, midY);
|
|
449
|
+
ctx.lineTo(0, bottomY);
|
|
450
|
+
ctx.lineTo(-wingX, midY);
|
|
451
|
+
}
|
|
452
|
+
ctx.closePath();
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// ── ReuleauxTriangle: constant-width curve ──────────────────────────
|
|
456
|
+
// Three circular arcs connecting the vertices of an equilateral triangle.
|
|
457
|
+
|
|
458
|
+
export const drawReuleauxTriangle: DrawFunction = (ctx, size, config) => {
|
|
459
|
+
const r = size / 2;
|
|
460
|
+
// Vertices of equilateral triangle
|
|
461
|
+
const verts = [];
|
|
462
|
+
for (let i = 0; i < 3; i++) {
|
|
463
|
+
const angle = (i / 3) * Math.PI * 2 - Math.PI / 2;
|
|
464
|
+
verts.push({ x: Math.cos(angle) * r * 0.7, y: Math.sin(angle) * r * 0.7 });
|
|
465
|
+
}
|
|
466
|
+
// Side length = distance between vertices
|
|
467
|
+
const sideLen = Math.hypot(verts[1].x - verts[0].x, verts[1].y - verts[0].y);
|
|
468
|
+
|
|
469
|
+
ctx.beginPath();
|
|
470
|
+
for (let i = 0; i < 3; i++) {
|
|
471
|
+
const from = verts[(i + 1) % 3];
|
|
472
|
+
const to = verts[(i + 2) % 3];
|
|
473
|
+
const center = verts[i];
|
|
474
|
+
const startAngle = Math.atan2(from.y - center.y, from.x - center.x);
|
|
475
|
+
const endAngle = Math.atan2(to.y - center.y, to.x - center.x);
|
|
476
|
+
if (i === 0) ctx.moveTo(from.x, from.y);
|
|
477
|
+
ctx.arc(center.x, center.y, sideLen, startAngle, endAngle);
|
|
478
|
+
}
|
|
479
|
+
ctx.closePath();
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// ── DotCluster: cloud of dots in a bounded region ───────────────────
|
|
483
|
+
// Hash controls density, spread, and clustering.
|
|
484
|
+
|
|
485
|
+
export const drawDotCluster: DrawFunction = (ctx, size, config) => {
|
|
486
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
487
|
+
const r = size / 2;
|
|
488
|
+
const dotCount = 15 + Math.floor(rng() * 25); // 15-39 dots
|
|
489
|
+
const clusterTightness = 0.3 + rng() * 0.5;
|
|
490
|
+
|
|
491
|
+
ctx.beginPath();
|
|
492
|
+
for (let i = 0; i < dotCount; i++) {
|
|
493
|
+
// Gaussian-ish distribution via Box-Muller approximation
|
|
494
|
+
const u1 = Math.max(0.001, rng());
|
|
495
|
+
const u2 = rng();
|
|
496
|
+
const mag = Math.sqrt(-2 * Math.log(u1)) * clusterTightness;
|
|
497
|
+
const angle = u2 * Math.PI * 2;
|
|
498
|
+
const dx = Math.cos(angle) * mag * r;
|
|
499
|
+
const dy = Math.sin(angle) * mag * r;
|
|
500
|
+
const dotR = r * (0.02 + rng() * 0.04);
|
|
501
|
+
ctx.moveTo(dx + dotR, dy);
|
|
502
|
+
ctx.arc(dx, dy, dotR, 0, Math.PI * 2);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// ── CrosshatchPatch: bounded region filled with directional lines ───
|
|
507
|
+
// Like an engraving or etching mark.
|
|
508
|
+
|
|
509
|
+
export const drawCrosshatchPatch: DrawFunction = (ctx, size, config) => {
|
|
510
|
+
const rng: () => number = config?.rng ?? Math.random;
|
|
511
|
+
const r = size / 2;
|
|
512
|
+
const angle1 = rng() * Math.PI;
|
|
513
|
+
const angle2 = angle1 + Math.PI / 2 + (rng() - 0.5) * 0.3;
|
|
514
|
+
const spacing = r * (0.08 + rng() * 0.08);
|
|
515
|
+
const hasCross = rng() < 0.6;
|
|
516
|
+
|
|
517
|
+
// Draw bounding shape (ellipse)
|
|
518
|
+
const rx = r * (0.7 + rng() * 0.3);
|
|
519
|
+
const ry = r * (0.5 + rng() * 0.3);
|
|
520
|
+
|
|
521
|
+
// Outer boundary
|
|
522
|
+
ctx.beginPath();
|
|
523
|
+
ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
|
|
524
|
+
|
|
525
|
+
// Hatch lines clipped to the ellipse
|
|
526
|
+
const cos1 = Math.cos(angle1);
|
|
527
|
+
const sin1 = Math.sin(angle1);
|
|
528
|
+
for (let d = -r; d <= r; d += spacing) {
|
|
529
|
+
const lx1 = d * cos1 - r * sin1;
|
|
530
|
+
const ly1 = d * sin1 + r * cos1;
|
|
531
|
+
const lx2 = d * cos1 + r * sin1;
|
|
532
|
+
const ly2 = d * sin1 - r * cos1;
|
|
533
|
+
ctx.moveTo(lx1, ly1);
|
|
534
|
+
ctx.lineTo(lx2, ly2);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (hasCross) {
|
|
538
|
+
const cos2 = Math.cos(angle2);
|
|
539
|
+
const sin2 = Math.sin(angle2);
|
|
540
|
+
for (let d = -r; d <= r; d += spacing * 1.3) {
|
|
541
|
+
const lx1 = d * cos2 - r * sin2;
|
|
542
|
+
const ly1 = d * sin2 + r * cos2;
|
|
543
|
+
const lx2 = d * cos2 + r * sin2;
|
|
544
|
+
const ly2 = d * sin2 - r * cos2;
|
|
545
|
+
ctx.moveTo(lx1, ly1);
|
|
546
|
+
ctx.lineTo(lx2, ly2);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
};
|
|
198
550
|
|
|
199
551
|
// ── Shape registry ──────────────────────────────────────────────────
|
|
200
552
|
|
|
@@ -206,4 +558,15 @@ export const proceduralShapes: Record<string, DrawFunction> = {
|
|
|
206
558
|
spirograph: drawSpirograph,
|
|
207
559
|
waveRing: drawWaveRing,
|
|
208
560
|
rose: drawRose,
|
|
561
|
+
shardField: drawShardField,
|
|
562
|
+
voronoiCell: drawVoronoiCell,
|
|
563
|
+
crescent: drawCrescent,
|
|
564
|
+
tendril: drawTendril,
|
|
565
|
+
cloudForm: drawCloudForm,
|
|
566
|
+
inkSplat: drawInkSplat,
|
|
567
|
+
geodesicDome: drawGeodesicDome,
|
|
568
|
+
penroseTile: drawPenroseTile,
|
|
569
|
+
reuleauxTriangle: drawReuleauxTriangle,
|
|
570
|
+
dotCluster: drawDotCluster,
|
|
571
|
+
crosshatchPatch: drawCrosshatchPatch,
|
|
209
572
|
};
|