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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "git-hash-art",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "author": "gfargo <ghfargo@gmail.com>",
5
5
  "scripts": {
6
6
  "watch": "parcel watch",
7
7
  "prebuild": "rm -rf .parcel-cache",
8
8
  "build": "parcel build",
9
9
  "build:examples": "node bin/generateExamples.js",
10
+ "build:versions": "node bin/generateVersionComparison.js",
10
11
  "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
11
12
  "format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
12
13
  "release": "release-it",
@@ -287,6 +287,74 @@ const ARCHETYPES: Archetype[] = [
287
287
  sizePower: 1.8,
288
288
  invertForeground: false,
289
289
  },
290
+ {
291
+ name: "shattered-glass",
292
+ gridSize: 8,
293
+ layers: 3,
294
+ baseOpacity: 0.85,
295
+ opacityReduction: 0.1,
296
+ minShapeSize: 15,
297
+ maxShapeSize: 250,
298
+ backgroundStyle: "solid-dark",
299
+ paletteMode: "high-contrast",
300
+ preferredStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
301
+ flowLineMultiplier: 0,
302
+ heroShape: false,
303
+ glowMultiplier: 0.3,
304
+ sizePower: 1.0,
305
+ invertForeground: false,
306
+ },
307
+ {
308
+ name: "botanical",
309
+ gridSize: 4,
310
+ layers: 4,
311
+ baseOpacity: 0.5,
312
+ opacityReduction: 0.06,
313
+ minShapeSize: 30,
314
+ maxShapeSize: 400,
315
+ backgroundStyle: "radial-light",
316
+ paletteMode: "earth",
317
+ preferredStyles: ["watercolor", "fill-only", "incomplete"],
318
+ flowLineMultiplier: 3,
319
+ heroShape: true,
320
+ glowMultiplier: 0.2,
321
+ sizePower: 1.6,
322
+ invertForeground: false,
323
+ },
324
+ {
325
+ name: "stipple-portrait",
326
+ gridSize: 9,
327
+ layers: 2,
328
+ baseOpacity: 0.8,
329
+ opacityReduction: 0.05,
330
+ minShapeSize: 5,
331
+ maxShapeSize: 120,
332
+ backgroundStyle: "solid-light",
333
+ paletteMode: "monochrome",
334
+ preferredStyles: ["stipple", "fill-only", "hatched"],
335
+ flowLineMultiplier: 0,
336
+ heroShape: false,
337
+ glowMultiplier: 0,
338
+ sizePower: 2.8,
339
+ invertForeground: false,
340
+ },
341
+ {
342
+ name: "celestial",
343
+ gridSize: 7,
344
+ layers: 5,
345
+ baseOpacity: 0.45,
346
+ opacityReduction: 0.04,
347
+ minShapeSize: 8,
348
+ maxShapeSize: 450,
349
+ backgroundStyle: "radial-dark",
350
+ paletteMode: "neon",
351
+ preferredStyles: ["fill-only", "watercolor", "stroke-only", "incomplete"],
352
+ flowLineMultiplier: 2,
353
+ heroShape: true,
354
+ glowMultiplier: 2.5,
355
+ sizePower: 2.2,
356
+ invertForeground: false,
357
+ },
290
358
  ];
291
359
 
292
360
  /**
@@ -35,7 +35,13 @@ export type RenderStyle =
35
35
  | "dashed" // dashed outline
36
36
  | "watercolor" // multiple offset passes at low opacity
37
37
  | "hatched" // cross-hatch texture fill
38
- | "incomplete"; // draw only 60-85% of the stroke path
38
+ | "incomplete" // draw only 60-85% of the stroke path
39
+ | "stipple" // dot-fill texture
40
+ | "stencil" // negative-space cutout effect
41
+ | "noise-grain" // procedural noise grain texture clipped to shape
42
+ | "wood-grain" // parallel wavy lines simulating wood
43
+ | "marble-vein" // branching vein lines on a soft fill
44
+ | "fabric-weave"; // interlocking horizontal/vertical threads
39
45
 
40
46
  const RENDER_STYLES: RenderStyle[] = [
41
47
  "fill-and-stroke",
@@ -47,6 +53,12 @@ const RENDER_STYLES: RenderStyle[] = [
47
53
  "watercolor",
48
54
  "hatched",
49
55
  "incomplete",
56
+ "stipple",
57
+ "stencil",
58
+ "noise-grain",
59
+ "wood-grain",
60
+ "marble-vein",
61
+ "fabric-weave",
50
62
  ];
51
63
 
52
64
  export function pickRenderStyle(rng: () => number): RenderStyle {
@@ -274,6 +286,226 @@ function applyRenderStyle(
274
286
  break;
275
287
  }
276
288
 
289
+ case "stipple": {
290
+ // Dot-fill texture — clip to shape, then scatter dots
291
+ const savedAlphaS = ctx.globalAlpha;
292
+ ctx.globalAlpha = savedAlphaS * 0.15;
293
+ ctx.fill(); // ghost fill
294
+ ctx.globalAlpha = savedAlphaS;
295
+
296
+ ctx.save();
297
+ ctx.clip();
298
+ const dotSpacing = Math.max(2, size * 0.03);
299
+ const extent = size * 0.55;
300
+ ctx.globalAlpha = savedAlphaS * 0.7;
301
+ for (let dx = -extent; dx <= extent; dx += dotSpacing) {
302
+ for (let dy = -extent; dy <= extent; dy += dotSpacing) {
303
+ // Jitter each dot position for organic feel
304
+ const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
305
+ const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
306
+ const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
307
+ ctx.beginPath();
308
+ ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
309
+ ctx.fill();
310
+ }
311
+ }
312
+ ctx.restore();
313
+ ctx.globalAlpha = savedAlphaS;
314
+ // Outline
315
+ ctx.globalAlpha *= 0.4;
316
+ ctx.stroke();
317
+ ctx.globalAlpha /= 0.4;
318
+ break;
319
+ }
320
+
321
+ case "stencil": {
322
+ // Negative-space cutout — fill a rectangle, then erase the shape
323
+ const savedAlphaSt = ctx.globalAlpha;
324
+ // Fill a bounding area with the stroke color
325
+ ctx.globalAlpha = savedAlphaSt * 0.5;
326
+ ctx.fillStyle = strokeColor;
327
+ ctx.fillRect(-size * 0.6, -size * 0.6, size * 1.2, size * 1.2);
328
+ // Cut out the shape using destination-out
329
+ ctx.globalCompositeOperation = "destination-out";
330
+ ctx.globalAlpha = 1;
331
+ ctx.fill();
332
+ ctx.globalCompositeOperation = "source-over";
333
+ ctx.globalAlpha = savedAlphaSt;
334
+ // Subtle outline of the cutout
335
+ ctx.globalAlpha *= 0.3;
336
+ ctx.stroke();
337
+ ctx.globalAlpha /= 0.3;
338
+ break;
339
+ }
340
+
341
+ case "noise-grain": {
342
+ // Procedural noise grain texture clipped to shape boundary
343
+ const savedAlphaN = ctx.globalAlpha;
344
+ ctx.globalAlpha = savedAlphaN * 0.25;
345
+ ctx.fill(); // base tint
346
+ ctx.globalAlpha = savedAlphaN;
347
+
348
+ ctx.save();
349
+ ctx.clip();
350
+ const grainSpacing = Math.max(1.5, size * 0.015);
351
+ const extentN = size * 0.55;
352
+ ctx.globalAlpha = savedAlphaN * 0.6;
353
+ for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
354
+ for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
355
+ if (!rng) break;
356
+ const jx = (rng() - 0.5) * grainSpacing * 1.2;
357
+ const jy = (rng() - 0.5) * grainSpacing * 1.2;
358
+ const brightness = rng() > 0.5 ? 255 : 0;
359
+ const dotAlpha = 0.15 + rng() * 0.35;
360
+ ctx.globalAlpha = savedAlphaN * dotAlpha;
361
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
362
+ const dotSize = grainSpacing * (0.3 + rng() * 0.5);
363
+ ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
364
+ }
365
+ }
366
+ ctx.restore();
367
+ ctx.fillStyle = fillColor;
368
+ ctx.globalAlpha = savedAlphaN;
369
+ ctx.globalAlpha *= 0.4;
370
+ ctx.stroke();
371
+ ctx.globalAlpha /= 0.4;
372
+ break;
373
+ }
374
+
375
+ case "wood-grain": {
376
+ // Parallel wavy lines simulating wood grain, clipped to shape
377
+ const savedAlphaW = ctx.globalAlpha;
378
+ ctx.globalAlpha = savedAlphaW * 0.2;
379
+ ctx.fill(); // base tint
380
+ ctx.globalAlpha = savedAlphaW;
381
+
382
+ ctx.save();
383
+ ctx.clip();
384
+ const grainLineSpacing = Math.max(2, size * 0.035);
385
+ const extentW = size * 0.55;
386
+ const waveFreq = rng ? 3 + rng() * 5 : 5;
387
+ const waveAmp = rng ? size * (0.01 + rng() * 0.03) : size * 0.02;
388
+ const grainAngle = rng ? rng() * Math.PI : Math.PI * 0.25;
389
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.3);
390
+ ctx.globalAlpha = savedAlphaW * 0.5;
391
+
392
+ const cosG = Math.cos(grainAngle);
393
+ const sinG = Math.sin(grainAngle);
394
+ for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
395
+ ctx.beginPath();
396
+ for (let t = -extentW; t <= extentW; t += 2) {
397
+ const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
398
+ const px = t * cosG - (d + wave) * sinG;
399
+ const py = t * sinG + (d + wave) * cosG;
400
+ if (t === -extentW) ctx.moveTo(px, py);
401
+ else ctx.lineTo(px, py);
402
+ }
403
+ ctx.stroke();
404
+ }
405
+ ctx.restore();
406
+ ctx.globalAlpha = savedAlphaW;
407
+ ctx.globalAlpha *= 0.35;
408
+ ctx.stroke();
409
+ ctx.globalAlpha /= 0.35;
410
+ break;
411
+ }
412
+
413
+ case "marble-vein": {
414
+ // Branching vein lines on a soft fill, clipped to shape
415
+ const savedAlphaM = ctx.globalAlpha;
416
+ ctx.globalAlpha = savedAlphaM * 0.35;
417
+ ctx.fill(); // soft base
418
+ ctx.globalAlpha = savedAlphaM;
419
+
420
+ ctx.save();
421
+ ctx.clip();
422
+ const veinCount = rng ? 2 + Math.floor(rng() * 3) : 3;
423
+ const extentM = size * 0.45;
424
+ ctx.lineWidth = Math.max(0.5, strokeWidth * 0.5);
425
+ ctx.globalAlpha = savedAlphaM * 0.4;
426
+
427
+ for (let v = 0; v < veinCount; v++) {
428
+ const startX = rng ? (rng() - 0.5) * extentM * 2 : 0;
429
+ const startY = rng ? -extentM + rng() * extentM * 0.5 : -extentM;
430
+ let vx = startX;
431
+ let vy = startY;
432
+ const steps = 15 + (rng ? Math.floor(rng() * 15) : 10);
433
+ const stepLen = size * 0.04;
434
+
435
+ ctx.beginPath();
436
+ ctx.moveTo(vx, vy);
437
+ for (let s = 0; s < steps; s++) {
438
+ const drift = rng ? (rng() - 0.5) * stepLen * 1.5 : 0;
439
+ vx += drift;
440
+ vy += stepLen;
441
+ ctx.lineTo(vx, vy);
442
+ // Branch ~20% of the time
443
+ if (rng && rng() < 0.2 && s > 2 && s < steps - 3) {
444
+ const branchDir = rng() < 0.5 ? -1 : 1;
445
+ let bx = vx;
446
+ let by = vy;
447
+ const bSteps = 3 + Math.floor(rng() * 5);
448
+ ctx.moveTo(bx, by);
449
+ for (let bs = 0; bs < bSteps; bs++) {
450
+ bx += branchDir * stepLen * (0.5 + rng() * 0.5);
451
+ by += stepLen * 0.6;
452
+ ctx.lineTo(bx, by);
453
+ }
454
+ ctx.moveTo(vx, vy); // return to main vein
455
+ }
456
+ }
457
+ ctx.stroke();
458
+ }
459
+ ctx.restore();
460
+ ctx.globalAlpha = savedAlphaM;
461
+ ctx.globalAlpha *= 0.3;
462
+ ctx.stroke();
463
+ ctx.globalAlpha /= 0.3;
464
+ break;
465
+ }
466
+
467
+ case "fabric-weave": {
468
+ // Interlocking horizontal/vertical threads clipped to shape
469
+ const savedAlphaF = ctx.globalAlpha;
470
+ ctx.globalAlpha = savedAlphaF * 0.15;
471
+ ctx.fill(); // ghost base
472
+ ctx.globalAlpha = savedAlphaF;
473
+
474
+ ctx.save();
475
+ ctx.clip();
476
+ const threadSpacing = Math.max(2, size * 0.04);
477
+ const extentF = size * 0.55;
478
+ ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
479
+ ctx.globalAlpha = savedAlphaF * 0.55;
480
+
481
+ // Horizontal threads
482
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
483
+ ctx.beginPath();
484
+ ctx.moveTo(-extentF, y);
485
+ ctx.lineTo(extentF, y);
486
+ ctx.stroke();
487
+ }
488
+ // Vertical threads (offset by half spacing for weave effect)
489
+ ctx.globalAlpha = savedAlphaF * 0.45;
490
+ ctx.strokeStyle = fillColor;
491
+ for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
492
+ ctx.beginPath();
493
+ for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
494
+ // Over-under: draw segment, skip segment
495
+ ctx.moveTo(x, y);
496
+ ctx.lineTo(x, y + threadSpacing);
497
+ }
498
+ ctx.stroke();
499
+ }
500
+ ctx.strokeStyle = strokeColor;
501
+ ctx.restore();
502
+ ctx.globalAlpha = savedAlphaF;
503
+ ctx.globalAlpha *= 0.3;
504
+ ctx.stroke();
505
+ ctx.globalAlpha /= 0.3;
506
+ break;
507
+ }
508
+
277
509
  case "fill-and-stroke":
278
510
  default:
279
511
  ctx.fill();
@@ -358,3 +590,88 @@ export function enhanceShapeGeneration(
358
590
 
359
591
  ctx.restore();
360
592
  }
593
+
594
+ // ── Shape mirroring effect ──────────────────────────────────────────
595
+ // Draws a shape and its mirror (reflected across an axis) for visual
596
+ // symmetry. Works especially well with basic shapes like triangles,
597
+ // crescents, and penrose tiles.
598
+
599
+ export type MirrorAxis = "horizontal" | "vertical" | "diagonal" | "radial-4";
600
+
601
+ /**
602
+ * Draw a shape with a mirrored reflection.
603
+ * The mirror is drawn at reduced opacity with optional offset.
604
+ */
605
+ export function drawMirroredShape(
606
+ ctx: CanvasRenderingContext2D,
607
+ shape: string,
608
+ x: number,
609
+ y: number,
610
+ config: EnhanceShapeConfig & { mirrorAxis?: MirrorAxis; mirrorGap?: number },
611
+ ): void {
612
+ const { mirrorAxis = "horizontal", mirrorGap = 0 } = config;
613
+
614
+ // Draw the primary shape
615
+ enhanceShapeGeneration(ctx, shape, x, y, config);
616
+
617
+ // Draw the mirrored copy
618
+ ctx.save();
619
+ const savedAlpha = ctx.globalAlpha;
620
+ ctx.globalAlpha = savedAlpha * 0.7; // mirror is slightly softer
621
+
622
+ switch (mirrorAxis) {
623
+ case "horizontal":
624
+ // Reflect across vertical axis at shape position
625
+ enhanceShapeGeneration(ctx, shape, x, y + mirrorGap, {
626
+ ...config,
627
+ rotation: -(config.rotation || 0),
628
+ size: config.size * 0.95,
629
+ });
630
+ break;
631
+ case "vertical":
632
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap, y, {
633
+ ...config,
634
+ rotation: 180 - (config.rotation || 0),
635
+ size: config.size * 0.95,
636
+ });
637
+ break;
638
+ case "diagonal":
639
+ // Reflect across 45° axis
640
+ enhanceShapeGeneration(ctx, shape, x + mirrorGap * 0.7, y + mirrorGap * 0.7, {
641
+ ...config,
642
+ rotation: 90 - (config.rotation || 0),
643
+ size: config.size * 0.9,
644
+ });
645
+ break;
646
+ case "radial-4":
647
+ // Four-way radial mirror
648
+ for (let i = 1; i < 4; i++) {
649
+ const angle = (i / 4) * Math.PI * 2;
650
+ const mx = x + Math.cos(angle) * mirrorGap;
651
+ const my = y + Math.sin(angle) * mirrorGap;
652
+ ctx.globalAlpha = savedAlpha * (0.7 - i * 0.1);
653
+ enhanceShapeGeneration(ctx, shape, mx, my, {
654
+ ...config,
655
+ rotation: (config.rotation || 0) + i * 90,
656
+ size: config.size * (0.95 - i * 0.05),
657
+ });
658
+ }
659
+ break;
660
+ }
661
+
662
+ ctx.globalAlpha = savedAlpha;
663
+ ctx.restore();
664
+ }
665
+
666
+ /**
667
+ * Pick a mirror axis deterministically.
668
+ * Returns null ~60% of the time (no mirroring).
669
+ */
670
+ export function pickMirrorAxis(rng: () => number): MirrorAxis | null {
671
+ const roll = rng();
672
+ if (roll < 0.60) return null;
673
+ if (roll < 0.75) return "horizontal";
674
+ if (roll < 0.87) return "vertical";
675
+ if (roll < 0.95) return "diagonal";
676
+ return "radial-4";
677
+ }
@@ -288,7 +288,7 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
288
288
  affinities: ["circle", "square", "blob", "hexagon"],
289
289
  category: "procedural",
290
290
  heroCandidate: false,
291
- bestStyles: ["fill-only", "watercolor", "fill-and-stroke"],
291
+ bestStyles: ["fill-only", "watercolor", "fill-and-stroke", "wood-grain"],
292
292
  },
293
293
  spirograph: {
294
294
  tier: 1,
@@ -317,6 +317,107 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
317
317
  heroCandidate: true,
318
318
  bestStyles: ["stroke-only", "fill-only", "watercolor"],
319
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
+ },
320
421
  };
321
422
 
322
423
  // ── Shape palette: curated sets of shapes that work well together ────
@@ -418,6 +519,50 @@ export function buildShapePalette(
418
519
  accents,
419
520
  };
420
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
+ }
421
566
 
422
567
  return { primary, supporting, accents };
423
568
  }