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
|
@@ -0,0 +1,624 @@
|
|
|
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", "wood-grain"],
|
|
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
|
+
// ── New procedural shapes ─────────────────────────────────────
|
|
322
|
+
shardField: {
|
|
323
|
+
tier: 2,
|
|
324
|
+
minSizeFraction: 0.1,
|
|
325
|
+
maxSizeFraction: 0.7,
|
|
326
|
+
affinities: ["voronoiCell", "diamond", "triangle", "penroseTile"],
|
|
327
|
+
category: "procedural",
|
|
328
|
+
heroCandidate: false,
|
|
329
|
+
bestStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
|
|
330
|
+
},
|
|
331
|
+
voronoiCell: {
|
|
332
|
+
tier: 1,
|
|
333
|
+
minSizeFraction: 0.08,
|
|
334
|
+
maxSizeFraction: 0.9,
|
|
335
|
+
affinities: ["shardField", "ngon", "superellipse", "blob"],
|
|
336
|
+
category: "procedural",
|
|
337
|
+
heroCandidate: false,
|
|
338
|
+
bestStyles: ["fill-and-stroke", "fill-only", "watercolor", "marble-vein"],
|
|
339
|
+
},
|
|
340
|
+
crescent: {
|
|
341
|
+
tier: 1,
|
|
342
|
+
minSizeFraction: 0.1,
|
|
343
|
+
maxSizeFraction: 1.0,
|
|
344
|
+
affinities: ["circle", "blob", "cloudForm", "vesicaPiscis"],
|
|
345
|
+
category: "procedural",
|
|
346
|
+
heroCandidate: true,
|
|
347
|
+
bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
348
|
+
},
|
|
349
|
+
tendril: {
|
|
350
|
+
tier: 2,
|
|
351
|
+
minSizeFraction: 0.1,
|
|
352
|
+
maxSizeFraction: 0.8,
|
|
353
|
+
affinities: ["blob", "inkSplat", "lissajous", "fibonacciSpiral"],
|
|
354
|
+
category: "procedural",
|
|
355
|
+
heroCandidate: false,
|
|
356
|
+
bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
357
|
+
},
|
|
358
|
+
cloudForm: {
|
|
359
|
+
tier: 1,
|
|
360
|
+
minSizeFraction: 0.15,
|
|
361
|
+
maxSizeFraction: 1.0,
|
|
362
|
+
affinities: ["blob", "circle", "crescent", "superellipse"],
|
|
363
|
+
category: "procedural",
|
|
364
|
+
heroCandidate: false,
|
|
365
|
+
bestStyles: ["fill-only", "watercolor"],
|
|
366
|
+
},
|
|
367
|
+
inkSplat: {
|
|
368
|
+
tier: 2,
|
|
369
|
+
minSizeFraction: 0.1,
|
|
370
|
+
maxSizeFraction: 0.8,
|
|
371
|
+
affinities: ["blob", "tendril", "shardField", "star"],
|
|
372
|
+
category: "procedural",
|
|
373
|
+
heroCandidate: false,
|
|
374
|
+
bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
|
|
375
|
+
},
|
|
376
|
+
geodesicDome: {
|
|
377
|
+
tier: 2,
|
|
378
|
+
minSizeFraction: 0.2,
|
|
379
|
+
maxSizeFraction: 0.9,
|
|
380
|
+
affinities: ["metatronsCube", "platonicSolid", "hexagon", "triangle"],
|
|
381
|
+
category: "procedural",
|
|
382
|
+
heroCandidate: true,
|
|
383
|
+
bestStyles: ["stroke-only", "dashed", "double-stroke"],
|
|
384
|
+
},
|
|
385
|
+
penroseTile: {
|
|
386
|
+
tier: 2,
|
|
387
|
+
minSizeFraction: 0.06,
|
|
388
|
+
maxSizeFraction: 0.6,
|
|
389
|
+
affinities: ["diamond", "triangle", "shardField", "voronoiCell"],
|
|
390
|
+
category: "procedural",
|
|
391
|
+
heroCandidate: false,
|
|
392
|
+
bestStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
|
|
393
|
+
},
|
|
394
|
+
reuleauxTriangle: {
|
|
395
|
+
tier: 1,
|
|
396
|
+
minSizeFraction: 0.08,
|
|
397
|
+
maxSizeFraction: 1.0,
|
|
398
|
+
affinities: ["triangle", "circle", "superellipse", "vesicaPiscis"],
|
|
399
|
+
category: "procedural",
|
|
400
|
+
heroCandidate: true,
|
|
401
|
+
bestStyles: ["fill-and-stroke", "fill-only", "watercolor"],
|
|
402
|
+
},
|
|
403
|
+
dotCluster: {
|
|
404
|
+
tier: 3,
|
|
405
|
+
minSizeFraction: 0.05,
|
|
406
|
+
maxSizeFraction: 0.5,
|
|
407
|
+
affinities: ["cloudForm", "inkSplat", "blob"],
|
|
408
|
+
category: "procedural",
|
|
409
|
+
heroCandidate: false,
|
|
410
|
+
bestStyles: ["fill-only", "stipple"],
|
|
411
|
+
},
|
|
412
|
+
crosshatchPatch: {
|
|
413
|
+
tier: 3,
|
|
414
|
+
minSizeFraction: 0.1,
|
|
415
|
+
maxSizeFraction: 0.6,
|
|
416
|
+
affinities: ["voronoiCell", "ngon", "superellipse"],
|
|
417
|
+
category: "procedural",
|
|
418
|
+
heroCandidate: false,
|
|
419
|
+
bestStyles: ["stroke-only", "hatched", "fabric-weave"],
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ── Shape palette: curated sets of shapes that work well together ────
|
|
424
|
+
|
|
425
|
+
export interface ShapePalette {
|
|
426
|
+
/** Primary shapes — used most often */
|
|
427
|
+
primary: string[];
|
|
428
|
+
/** Supporting shapes — used less frequently */
|
|
429
|
+
supporting: string[];
|
|
430
|
+
/** Accent shapes — rare, for visual punctuation */
|
|
431
|
+
accents: string[];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Build a curated shape palette from a seed shape.
|
|
436
|
+
* Uses affinity data to select shapes that compose well together,
|
|
437
|
+
* filtering out low-tier shapes that don't work at the current scale.
|
|
438
|
+
*/
|
|
439
|
+
export function buildShapePalette(
|
|
440
|
+
rng: () => number,
|
|
441
|
+
shapeNames: string[],
|
|
442
|
+
archetypeName: string,
|
|
443
|
+
): ShapePalette {
|
|
444
|
+
const available = shapeNames.filter((s) => SHAPE_PROFILES[s]);
|
|
445
|
+
|
|
446
|
+
// Pick a seed shape — tier 1 shapes that are hero candidates
|
|
447
|
+
const heroPool = available.filter(
|
|
448
|
+
(s) => SHAPE_PROFILES[s].tier === 1 && SHAPE_PROFILES[s].heroCandidate,
|
|
449
|
+
);
|
|
450
|
+
const seedShape = heroPool.length > 0
|
|
451
|
+
? heroPool[Math.floor(rng() * heroPool.length)]
|
|
452
|
+
: available[Math.floor(rng() * available.length)];
|
|
453
|
+
|
|
454
|
+
const seedProfile = SHAPE_PROFILES[seedShape];
|
|
455
|
+
|
|
456
|
+
// Primary: seed shape + its direct affinities (tier 1-2 only)
|
|
457
|
+
const primaryCandidates = [seedShape, ...seedProfile.affinities]
|
|
458
|
+
.filter((s) => available.includes(s))
|
|
459
|
+
.filter((s) => SHAPE_PROFILES[s].tier <= 2);
|
|
460
|
+
const primary = [...new Set(primaryCandidates)].slice(0, 5);
|
|
461
|
+
|
|
462
|
+
// Supporting: affinities of affinities, plus same-category shapes
|
|
463
|
+
const supportingSet = new Set<string>();
|
|
464
|
+
for (const p of primary) {
|
|
465
|
+
const profile = SHAPE_PROFILES[p];
|
|
466
|
+
if (!profile) continue;
|
|
467
|
+
for (const aff of profile.affinities) {
|
|
468
|
+
if (available.includes(aff) && !primary.includes(aff)) {
|
|
469
|
+
supportingSet.add(aff);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Add same-category tier 1-2 shapes
|
|
474
|
+
for (const s of available) {
|
|
475
|
+
const p = SHAPE_PROFILES[s];
|
|
476
|
+
if (p.category === seedProfile.category && p.tier <= 2 && !primary.includes(s)) {
|
|
477
|
+
supportingSet.add(s);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const supporting = [...supportingSet].slice(0, 6);
|
|
481
|
+
|
|
482
|
+
// Accents: tier 1 shapes from other categories for contrast
|
|
483
|
+
const usedCategories = new Set(
|
|
484
|
+
[...primary, ...supporting].map((s) => SHAPE_PROFILES[s]?.category),
|
|
485
|
+
);
|
|
486
|
+
const accentCandidates = available.filter(
|
|
487
|
+
(s) =>
|
|
488
|
+
!primary.includes(s) &&
|
|
489
|
+
!supporting.includes(s) &&
|
|
490
|
+
SHAPE_PROFILES[s].tier <= 2 &&
|
|
491
|
+
!usedCategories.has(SHAPE_PROFILES[s].category),
|
|
492
|
+
);
|
|
493
|
+
// Shuffle and take a few
|
|
494
|
+
const accents: string[] = [];
|
|
495
|
+
const shuffled = [...accentCandidates];
|
|
496
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
497
|
+
const j = Math.floor(rng() * (i + 1));
|
|
498
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
499
|
+
}
|
|
500
|
+
accents.push(...shuffled.slice(0, 3));
|
|
501
|
+
|
|
502
|
+
// For certain archetypes, bias the palette
|
|
503
|
+
if (archetypeName === "geometric-precision") {
|
|
504
|
+
// Remove blobs and organic shapes from primary
|
|
505
|
+
return {
|
|
506
|
+
primary: primary.filter((s) => SHAPE_PROFILES[s]?.category !== "procedural" || s === "ngon"),
|
|
507
|
+
supporting: supporting.filter((s) => s !== "blob"),
|
|
508
|
+
accents,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (archetypeName === "organic-flow") {
|
|
512
|
+
// Boost procedural/organic shapes
|
|
513
|
+
const organicBoost = available.filter(
|
|
514
|
+
(s) => ["blob", "superellipse", "waveRing", "rose"].includes(s) && !primary.includes(s),
|
|
515
|
+
);
|
|
516
|
+
return {
|
|
517
|
+
primary: [...primary, ...organicBoost.slice(0, 2)],
|
|
518
|
+
supporting,
|
|
519
|
+
accents,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
if (archetypeName === "shattered-glass") {
|
|
523
|
+
// Favor angular, fragmented shapes
|
|
524
|
+
const shardBoost = available.filter(
|
|
525
|
+
(s) => ["shardField", "voronoiCell", "penroseTile", "diamond", "triangle", "ngon"].includes(s) && !primary.includes(s),
|
|
526
|
+
);
|
|
527
|
+
return {
|
|
528
|
+
primary: [...primary.filter((s) => s !== "blob" && s !== "cloudForm"), ...shardBoost.slice(0, 3)],
|
|
529
|
+
supporting: supporting.filter((s) => s !== "blob" && s !== "cloudForm"),
|
|
530
|
+
accents,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (archetypeName === "botanical") {
|
|
534
|
+
// Favor organic, flowing shapes
|
|
535
|
+
const botanicalBoost = available.filter(
|
|
536
|
+
(s) => ["tendril", "cloudForm", "blob", "crescent", "rose", "inkSplat"].includes(s) && !primary.includes(s),
|
|
537
|
+
);
|
|
538
|
+
return {
|
|
539
|
+
primary: [...primary, ...botanicalBoost.slice(0, 3)],
|
|
540
|
+
supporting,
|
|
541
|
+
accents,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (archetypeName === "stipple-portrait") {
|
|
545
|
+
// Favor small, dot-friendly shapes
|
|
546
|
+
const stippleBoost = available.filter(
|
|
547
|
+
(s) => ["dotCluster", "circle", "crosshatchPatch", "voronoiCell", "blob"].includes(s) && !primary.includes(s),
|
|
548
|
+
);
|
|
549
|
+
return {
|
|
550
|
+
primary: [...primary, ...stippleBoost.slice(0, 3)],
|
|
551
|
+
supporting,
|
|
552
|
+
accents,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (archetypeName === "celestial") {
|
|
556
|
+
// Favor sacred geometry and cosmic shapes
|
|
557
|
+
const celestialBoost = available.filter(
|
|
558
|
+
(s) => ["crescent", "geodesicDome", "mandala", "flowerOfLife", "spirograph", "fibonacciSpiral"].includes(s) && !primary.includes(s),
|
|
559
|
+
);
|
|
560
|
+
return {
|
|
561
|
+
primary: [...primary, ...celestialBoost.slice(0, 3)],
|
|
562
|
+
supporting,
|
|
563
|
+
accents,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return { primary, supporting, accents };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Pick a shape from the palette with weighted probability.
|
|
572
|
+
* Primary: ~60%, Supporting: ~30%, Accent: ~10%.
|
|
573
|
+
* Also respects size constraints from the shape profile.
|
|
574
|
+
*/
|
|
575
|
+
export function pickShapeFromPalette(
|
|
576
|
+
palette: ShapePalette,
|
|
577
|
+
rng: () => number,
|
|
578
|
+
sizeFraction: number,
|
|
579
|
+
): string {
|
|
580
|
+
// Filter each tier by size constraints
|
|
581
|
+
const validPrimary = palette.primary.filter((s) => {
|
|
582
|
+
const p = SHAPE_PROFILES[s];
|
|
583
|
+
return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
|
|
584
|
+
});
|
|
585
|
+
const validSupporting = palette.supporting.filter((s) => {
|
|
586
|
+
const p = SHAPE_PROFILES[s];
|
|
587
|
+
return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
|
|
588
|
+
});
|
|
589
|
+
const validAccents = palette.accents.filter((s) => {
|
|
590
|
+
const p = SHAPE_PROFILES[s];
|
|
591
|
+
return p && sizeFraction >= p.minSizeFraction && sizeFraction <= p.maxSizeFraction;
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const roll = rng();
|
|
595
|
+
if (roll < 0.60 && validPrimary.length > 0) {
|
|
596
|
+
return validPrimary[Math.floor(rng() * validPrimary.length)];
|
|
597
|
+
}
|
|
598
|
+
if (roll < 0.90 && validSupporting.length > 0) {
|
|
599
|
+
return validSupporting[Math.floor(rng() * validSupporting.length)];
|
|
600
|
+
}
|
|
601
|
+
if (validAccents.length > 0) {
|
|
602
|
+
return validAccents[Math.floor(rng() * validAccents.length)];
|
|
603
|
+
}
|
|
604
|
+
// Fallback: any valid primary or supporting
|
|
605
|
+
const fallback = [...validPrimary, ...validSupporting];
|
|
606
|
+
if (fallback.length > 0) return fallback[Math.floor(rng() * fallback.length)];
|
|
607
|
+
// Ultimate fallback
|
|
608
|
+
return palette.primary[0] || "circle";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Get the best render style for a shape, with some randomness.
|
|
613
|
+
* 70% chance of using one of the shape's best styles,
|
|
614
|
+
* 30% chance of using the layer's style.
|
|
615
|
+
*/
|
|
616
|
+
export function pickStyleForShape(
|
|
617
|
+
shapeName: string,
|
|
618
|
+
layerStyle: string,
|
|
619
|
+
rng: () => number,
|
|
620
|
+
): string {
|
|
621
|
+
const profile = SHAPE_PROFILES[shapeName];
|
|
622
|
+
if (!profile || rng() > 0.7) return layerStyle;
|
|
623
|
+
return profile.bestStyles[Math.floor(rng() * profile.bestStyles.length)];
|
|
624
|
+
}
|