git-hash-art 0.10.1 → 0.12.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/dist/browser.js CHANGED
@@ -508,13 +508,21 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
508
508
  }
509
509
  }
510
510
  // ── Standalone color utilities ──────────────────────────────────────
511
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
512
- const c = hex.replace("#", "");
513
- return [
511
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
512
+ const $b5a262d09b87e373$var$_rgbCache = new Map();
513
+ const $b5a262d09b87e373$var$_RGB_CACHE_MAX = 512;
514
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
515
+ let cached = $b5a262d09b87e373$var$_rgbCache.get(hex);
516
+ if (cached) return cached;
517
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
518
+ cached = [
514
519
  parseInt(c.substring(0, 2), 16),
515
520
  parseInt(c.substring(2, 4), 16),
516
521
  parseInt(c.substring(4, 6), 16)
517
522
  ];
523
+ if ($b5a262d09b87e373$var$_rgbCache.size >= $b5a262d09b87e373$var$_RGB_CACHE_MAX) $b5a262d09b87e373$var$_rgbCache.clear();
524
+ $b5a262d09b87e373$var$_rgbCache.set(hex, cached);
525
+ return cached;
518
526
  }
519
527
  /** Format [r, g, b] back to #RRGGBB. */ function $b5a262d09b87e373$var$rgbToHex(r, g, b) {
520
528
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -571,7 +579,9 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
571
579
  }
572
580
  function $b5a262d09b87e373$export$f2121afcad3d553f(hex, alpha) {
573
581
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex);
574
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
582
+ // Quantize alpha to 3 decimal places without toFixed overhead
583
+ const a = Math.round(alpha * 1000) / 1000;
584
+ return `rgba(${r},${g},${b},${a})`;
575
585
  }
576
586
  function $b5a262d09b87e373$export$fabac4600b87056(colors, rng) {
577
587
  if (colors.length < 3) return {
@@ -580,15 +590,17 @@ function $b5a262d09b87e373$export$fabac4600b87056(colors, rng) {
580
590
  accent: colors[colors.length - 1] || "#888888",
581
591
  all: colors
582
592
  };
583
- // Pick dominant as the color closest to the palette's average hue
593
+ // Pick dominant as the color with the highest chroma (saturation × distance from gray)
594
+ // This selects the most visually prominent color rather than the average
584
595
  const hsls = colors.map((c)=>$b5a262d09b87e373$var$hexToHsl(c));
585
- const avgHue = hsls.reduce((s, h)=>s + h[0], 0) / hsls.length;
586
596
  let dominantIdx = 0;
587
- let minDist = 360;
597
+ let maxChroma = -1;
588
598
  for(let i = 0; i < hsls.length; i++){
589
- const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
590
- if (d < minDist) {
591
- minDist = d;
599
+ // Chroma approximation: saturation × how far lightness is from 50% (gray)
600
+ const lightnessVibrancy = 1 - Math.abs(hsls[i][2] - 0.5) * 2; // peaks at L=0.5
601
+ const chroma = hsls[i][1] * lightnessVibrancy;
602
+ if (chroma > maxChroma) {
603
+ maxChroma = chroma;
592
604
  dominantIdx = i;
593
605
  }
594
606
  }
@@ -649,12 +661,21 @@ function $b5a262d09b87e373$export$51ea55f869b7e0d3(hex, target, amount) {
649
661
  const [h, s, l] = $b5a262d09b87e373$var$hexToHsl(hex);
650
662
  return $b5a262d09b87e373$var$hslToHex($b5a262d09b87e373$var$shiftHueToward(h, target, amount), s, l);
651
663
  }
664
+ /**
665
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
666
+ * Uses the sRGB luminance formula from WCAG. Cached.
667
+ */ const $b5a262d09b87e373$var$_lumCache = new Map();
652
668
  function $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(hex) {
669
+ let cached = $b5a262d09b87e373$var$_lumCache.get(hex);
670
+ if (cached !== undefined) return cached;
653
671
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex).map((c)=>{
654
672
  const s = c / 255;
655
673
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
656
674
  });
657
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
675
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
676
+ if ($b5a262d09b87e373$var$_lumCache.size >= 512) $b5a262d09b87e373$var$_lumCache.clear();
677
+ $b5a262d09b87e373$var$_lumCache.set(hex, cached);
678
+ return cached;
658
679
  }
659
680
  function $b5a262d09b87e373$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
660
681
  const fgLum = $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1090,21 +1111,31 @@ const $f0f1a7293548e501$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1090
1111
  (0, $ce2c52df8af02e62$export$e46c5570db033611)(ctx, size, finalConfig);
1091
1112
  const gridSize = 8;
1092
1113
  const unit = size / gridSize;
1114
+ const radius = unit / 2;
1115
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1116
+ const starPoints = [];
1117
+ for(let k = 0; k < 8; k++){
1118
+ const angle = Math.PI / 4 * k;
1119
+ const angle2 = angle + Math.PI / 4;
1120
+ starPoints.push({
1121
+ c1: Math.cos(angle) * radius,
1122
+ s1: Math.sin(angle) * radius,
1123
+ c2: Math.cos(angle2) * radius,
1124
+ s2: Math.sin(angle2) * radius
1125
+ });
1126
+ }
1093
1127
  ctx.beginPath();
1094
1128
  // Create base grid
1095
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1129
+ for(let i = 0; i <= gridSize; i++){
1096
1130
  const x = (i - gridSize / 2) * unit;
1097
- const y = (j - gridSize / 2) * unit;
1098
- // Draw star pattern at each intersection
1099
- const radius = unit / 2;
1100
- for(let k = 0; k < 8; k++){
1101
- const angle = Math.PI / 4 * k;
1102
- const x1 = x + radius * Math.cos(angle);
1103
- const y1 = y + radius * Math.sin(angle);
1104
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1105
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1106
- ctx.moveTo(x1, y1);
1107
- ctx.lineTo(x2, y2);
1131
+ for(let j = 0; j <= gridSize; j++){
1132
+ const y = (j - gridSize / 2) * unit;
1133
+ // Draw star pattern at each intersection using pre-computed offsets
1134
+ for(let k = 0; k < 8; k++){
1135
+ const sp = starPoints[k];
1136
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1137
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1138
+ }
1108
1139
  }
1109
1140
  }
1110
1141
  ctx.stroke();
@@ -1424,20 +1455,23 @@ const $77711f013715e6da$export$eeae7765f05012e2 = (ctx, size)=>{
1424
1455
  const $77711f013715e6da$export$3355220a8108efc3 = (ctx, size)=>{
1425
1456
  const outerRadius = size / 2;
1426
1457
  const innerRadius = size / 4;
1427
- const steps = 36;
1458
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1459
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1460
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1461
+ const TWO_PI = Math.PI * 2;
1462
+ const angleStep = TWO_PI / steps;
1428
1463
  ctx.beginPath();
1429
1464
  for(let i = 0; i < steps; i++){
1430
- const angle1 = i / steps * Math.PI * 2;
1431
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1465
+ const angle1 = i * angleStep;
1466
+ const cosA = Math.cos(angle1);
1467
+ const sinA = Math.sin(angle1);
1432
1468
  for(let j = 0; j < steps; j++){
1433
- const phi1 = j / steps * Math.PI * 2;
1434
- const phi2 = (j + 1) / steps * Math.PI * 2;
1435
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1436
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1437
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1438
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1439
- ctx.moveTo(x1, y1);
1440
- ctx.lineTo(x2, y2);
1469
+ const phi1 = j * angleStep;
1470
+ const phi2 = phi1 + angleStep;
1471
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1472
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1473
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1474
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1441
1475
  }
1442
1476
  }
1443
1477
  };
@@ -2000,6 +2034,43 @@ const $e0f99502ff383dd8$var$RENDER_STYLES = [
2000
2034
  function $e0f99502ff383dd8$export$9fd4e64b2acd410e(rng) {
2001
2035
  return $e0f99502ff383dd8$var$RENDER_STYLES[Math.floor(rng() * $e0f99502ff383dd8$var$RENDER_STYLES.length)];
2002
2036
  }
2037
+ const $e0f99502ff383dd8$export$2f738f61a8c15e07 = {
2038
+ "fill-and-stroke": 1,
2039
+ "fill-only": 0.5,
2040
+ "stroke-only": 1,
2041
+ "double-stroke": 1.5,
2042
+ "dashed": 1,
2043
+ "watercolor": 7,
2044
+ "hatched": 3,
2045
+ "incomplete": 1,
2046
+ "stipple": 90,
2047
+ "stencil": 2,
2048
+ "noise-grain": 400,
2049
+ "wood-grain": 10,
2050
+ "marble-vein": 4,
2051
+ "fabric-weave": 6,
2052
+ "hand-drawn": 5
2053
+ };
2054
+ function $e0f99502ff383dd8$export$909ab0580e273f19(style) {
2055
+ switch(style){
2056
+ case "noise-grain":
2057
+ return "hatched";
2058
+ case "stipple":
2059
+ return "dashed";
2060
+ case "wood-grain":
2061
+ return "hatched";
2062
+ case "watercolor":
2063
+ return "fill-and-stroke";
2064
+ case "fabric-weave":
2065
+ return "hatched";
2066
+ case "hand-drawn":
2067
+ return "fill-and-stroke";
2068
+ case "marble-vein":
2069
+ return "stroke-only";
2070
+ default:
2071
+ return style;
2072
+ }
2073
+ }
2003
2074
  function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2004
2075
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2005
2076
  ctx.save();
@@ -2119,6 +2190,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2119
2190
  case "hatched":
2120
2191
  {
2121
2192
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2193
+ // Optimized: batch all parallel lines into a single path per pass
2122
2194
  const savedAlphaH = ctx.globalAlpha;
2123
2195
  ctx.globalAlpha = savedAlphaH * 0.3;
2124
2196
  ctx.fill();
@@ -2130,28 +2202,28 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2130
2202
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2131
2203
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2132
2204
  ctx.globalAlpha = savedAlphaH * 0.6;
2133
- // Draw parallel lines across the bounding box
2205
+ // Draw parallel lines across the bounding box — batched into single path
2134
2206
  const extent = size * 0.8;
2135
2207
  const cos = Math.cos(hatchAngle);
2136
2208
  const sin = Math.sin(hatchAngle);
2209
+ ctx.beginPath();
2137
2210
  for(let d = -extent; d <= extent; d += hatchSpacing){
2138
- ctx.beginPath();
2139
2211
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2140
2212
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2141
- ctx.stroke();
2142
2213
  }
2214
+ ctx.stroke();
2143
2215
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2144
2216
  if (!rng || rng() < 0.5) {
2145
2217
  const crossAngle = hatchAngle + Math.PI / 2;
2146
2218
  const cos2 = Math.cos(crossAngle);
2147
2219
  const sin2 = Math.sin(crossAngle);
2148
2220
  ctx.globalAlpha = savedAlphaH * 0.35;
2221
+ ctx.beginPath();
2149
2222
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2150
- ctx.beginPath();
2151
2223
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2152
2224
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2153
- ctx.stroke();
2154
2225
  }
2226
+ ctx.stroke();
2155
2227
  }
2156
2228
  ctx.restore();
2157
2229
  ctx.globalAlpha = savedAlphaH;
@@ -2189,6 +2261,8 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2189
2261
  case "stipple":
2190
2262
  {
2191
2263
  // Dot-fill texture — clip to shape, then scatter dots
2264
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2265
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2192
2266
  const savedAlphaS = ctx.globalAlpha;
2193
2267
  ctx.globalAlpha = savedAlphaS * 0.15;
2194
2268
  ctx.fill(); // ghost fill
@@ -2196,16 +2270,20 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2196
2270
  ctx.save();
2197
2271
  ctx.clip();
2198
2272
  const dotSpacing = Math.max(2, size * 0.03);
2199
- const extent = size * 0.55;
2273
+ const extentS = size * 0.55;
2274
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2275
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2276
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2200
2277
  ctx.globalAlpha = savedAlphaS * 0.7;
2201
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2202
- // Jitter each dot position for organic feel
2203
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2204
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2205
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2206
- ctx.beginPath();
2207
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2208
- ctx.fill();
2278
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2279
+ const dx = -extentS + xi * actualSpacing;
2280
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2281
+ const dy = -extentS + yi * actualSpacing;
2282
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2283
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2284
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2285
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2286
+ }
2209
2287
  }
2210
2288
  ctx.restore();
2211
2289
  ctx.globalAlpha = savedAlphaS;
@@ -2238,6 +2316,9 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2238
2316
  case "noise-grain":
2239
2317
  {
2240
2318
  // Procedural noise grain texture clipped to shape boundary
2319
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2320
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2321
+ // and batch dots by brightness (black/white) × alpha bucket
2241
2322
  const savedAlphaN = ctx.globalAlpha;
2242
2323
  ctx.globalAlpha = savedAlphaN * 0.25;
2243
2324
  ctx.fill(); // base tint
@@ -2246,17 +2327,47 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2246
2327
  ctx.clip();
2247
2328
  const grainSpacing = Math.max(1.5, size * 0.015);
2248
2329
  const extentN = size * 0.55;
2249
- ctx.globalAlpha = savedAlphaN * 0.6;
2250
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2251
- if (!rng) break;
2252
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2253
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2254
- const brightness = rng() > 0.5 ? 255 : 0;
2255
- const dotAlpha = 0.15 + rng() * 0.35;
2256
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2257
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2258
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2259
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2330
+ if (rng) {
2331
+ // Cap grid to max 40 dots per axis beyond this the grain is
2332
+ // visually indistinguishable but cost scales quadratically.
2333
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2334
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2335
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2336
+ const BUCKETS = 4;
2337
+ const bucketMin = 0.15;
2338
+ const bucketRange = 0.35;
2339
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2340
+ const buckets = [];
2341
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2342
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2343
+ const gx = -extentN + xi * actualGrainSpacing;
2344
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2345
+ const gy = -extentN + yi * actualGrainSpacing;
2346
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2347
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2348
+ const isWhite = rng() > 0.5;
2349
+ const dotAlpha = bucketMin + rng() * bucketRange;
2350
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2351
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2352
+ const offset = isWhite ? BUCKETS : 0;
2353
+ buckets[offset + bucketIdx].push({
2354
+ x: gx + jx,
2355
+ y: gy + jy,
2356
+ s: dotSize
2357
+ });
2358
+ }
2359
+ }
2360
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2361
+ for(let color = 0; color < 2; color++){
2362
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2363
+ for(let b = 0; b < BUCKETS; b++){
2364
+ const dots = buckets[color * BUCKETS + b];
2365
+ if (dots.length === 0) continue;
2366
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2367
+ ctx.globalAlpha = savedAlphaN * alpha;
2368
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2369
+ }
2370
+ }
2260
2371
  }
2261
2372
  ctx.restore();
2262
2373
  ctx.fillStyle = fillColor;
@@ -2269,6 +2380,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2269
2380
  case "wood-grain":
2270
2381
  {
2271
2382
  // Parallel wavy lines simulating wood grain, clipped to shape
2383
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2272
2384
  const savedAlphaW = ctx.globalAlpha;
2273
2385
  ctx.globalAlpha = savedAlphaW * 0.2;
2274
2386
  ctx.fill(); // base tint
@@ -2284,17 +2396,19 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2284
2396
  ctx.globalAlpha = savedAlphaW * 0.5;
2285
2397
  const cosG = Math.cos(grainAngle);
2286
2398
  const sinG = Math.sin(grainAngle);
2399
+ const waveCoeff = waveFreq * Math.PI;
2400
+ const invExtentW = 1 / extentW;
2401
+ // Batch all grain lines into a single path
2402
+ ctx.beginPath();
2287
2403
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2288
- ctx.beginPath();
2289
- for(let t = -extentW; t <= extentW; t += 2){
2290
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2291
- const px = t * cosG - (d + wave) * sinG;
2292
- const py = t * sinG + (d + wave) * cosG;
2293
- if (t === -extentW) ctx.moveTo(px, py);
2294
- else ctx.lineTo(px, py);
2404
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2405
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2406
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2407
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2408
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2295
2409
  }
2296
- ctx.stroke();
2297
2410
  }
2411
+ ctx.stroke();
2298
2412
  ctx.restore();
2299
2413
  ctx.globalAlpha = savedAlphaW;
2300
2414
  ctx.globalAlpha *= 0.35;
@@ -2356,6 +2470,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2356
2470
  case "fabric-weave":
2357
2471
  {
2358
2472
  // Interlocking horizontal/vertical threads clipped to shape
2473
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2359
2474
  const savedAlphaF = ctx.globalAlpha;
2360
2475
  ctx.globalAlpha = savedAlphaF * 0.15;
2361
2476
  ctx.fill(); // ghost base
@@ -2365,26 +2480,24 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2365
2480
  const threadSpacing = Math.max(2, size * 0.04);
2366
2481
  const extentF = size * 0.55;
2367
2482
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2483
+ // Horizontal threads — batched
2368
2484
  ctx.globalAlpha = savedAlphaF * 0.55;
2369
- // Horizontal threads
2485
+ ctx.beginPath();
2370
2486
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2371
- ctx.beginPath();
2372
2487
  ctx.moveTo(-extentF, y);
2373
2488
  ctx.lineTo(extentF, y);
2374
- ctx.stroke();
2375
2489
  }
2376
- // Vertical threads (offset by half spacing for weave effect)
2490
+ ctx.stroke();
2491
+ // Vertical threads (offset by half spacing for weave effect) — batched
2377
2492
  ctx.globalAlpha = savedAlphaF * 0.45;
2378
2493
  ctx.strokeStyle = fillColor;
2379
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2380
- ctx.beginPath();
2381
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2382
- // Over-under: draw segment, skip segment
2383
- ctx.moveTo(x, y);
2384
- ctx.lineTo(x, y + threadSpacing);
2385
- }
2386
- ctx.stroke();
2494
+ ctx.beginPath();
2495
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2496
+ // Over-under: draw segment, skip segment
2497
+ ctx.moveTo(x, y);
2498
+ ctx.lineTo(x, y + threadSpacing);
2387
2499
  }
2500
+ ctx.stroke();
2388
2501
  ctx.strokeStyle = strokeColor;
2389
2502
  ctx.restore();
2390
2503
  ctx.globalAlpha = savedAlphaF;
@@ -2450,14 +2563,17 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2450
2563
  ctx.translate(x, y);
2451
2564
  ctx.rotate(rotation * Math.PI / 180);
2452
2565
  // ── Drop shadow — soft colored shadow offset along light direction ──
2453
- if (lightAngle !== undefined && size > 10) {
2566
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2567
+ // and visually imperceptible at that scale.
2568
+ const useShadow = size >= 20;
2569
+ if (useShadow && lightAngle !== undefined) {
2454
2570
  const shadowDist = size * 0.035;
2455
2571
  const shadowBlurR = size * 0.06;
2456
2572
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2457
2573
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2458
2574
  ctx.shadowBlur = shadowBlurR;
2459
2575
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2460
- } else if (glowRadius > 0) {
2576
+ } else if (useShadow && glowRadius > 0) {
2461
2577
  // Glow / shadow effect (legacy path)
2462
2578
  ctx.shadowBlur = glowRadius;
2463
2579
  ctx.shadowColor = glowColor || fillColor;
@@ -2481,17 +2597,24 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2481
2597
  $e0f99502ff383dd8$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2482
2598
  }
2483
2599
  // Reset shadow so patterns and highlight aren't double-shadowed
2484
- ctx.shadowBlur = 0;
2485
- ctx.shadowOffsetX = 0;
2486
- ctx.shadowOffsetY = 0;
2487
- ctx.shadowColor = "transparent";
2488
- // ── Specular highlight — bright arc on the light-facing side ──
2489
- if (lightAngle !== undefined && size > 15 && rng) {
2600
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2601
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2602
+ ctx.shadowBlur = 0;
2603
+ ctx.shadowOffsetX = 0;
2604
+ ctx.shadowOffsetY = 0;
2605
+ ctx.shadowColor = "transparent";
2606
+ }
2607
+ // ── Specular highlight — tinted arc on the light-facing side ──
2608
+ // Skip for small shapes (< 30px) — gradient creation + composite op
2609
+ // switch is expensive and the highlight is invisible at small sizes.
2610
+ if (lightAngle !== undefined && size > 30 && rng) {
2490
2611
  const hlRadius = size * 0.35;
2491
2612
  const hlDist = size * 0.15;
2492
2613
  const hlX = Math.cos(lightAngle) * hlDist;
2493
2614
  const hlY = Math.sin(lightAngle) * hlDist;
2494
2615
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2616
+ // Use a simple white highlight — the per-shape hex parse was expensive
2617
+ // and the visual difference from tinted highlights is negligible.
2495
2618
  hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2496
2619
  hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2497
2620
  hlGrad.addColorStop(1, "rgba(255,255,255,0)");
@@ -3556,6 +3679,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3556
3679
  "watercolor",
3557
3680
  "fill-only"
3558
3681
  ],
3682
+ preferredCompositions: [
3683
+ "clustered",
3684
+ "flow-field",
3685
+ "radial"
3686
+ ],
3559
3687
  flowLineMultiplier: 2.5,
3560
3688
  heroShape: false,
3561
3689
  glowMultiplier: 0.5,
@@ -3577,6 +3705,10 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3577
3705
  "stroke-only",
3578
3706
  "incomplete"
3579
3707
  ],
3708
+ preferredCompositions: [
3709
+ "golden-spiral",
3710
+ "grid-subdivision"
3711
+ ],
3580
3712
  flowLineMultiplier: 0.3,
3581
3713
  heroShape: true,
3582
3714
  glowMultiplier: 0,
@@ -3598,6 +3730,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3598
3730
  "fill-only",
3599
3731
  "incomplete"
3600
3732
  ],
3733
+ preferredCompositions: [
3734
+ "flow-field",
3735
+ "golden-spiral",
3736
+ "spiral"
3737
+ ],
3601
3738
  flowLineMultiplier: 4,
3602
3739
  heroShape: false,
3603
3740
  glowMultiplier: 0.3,
@@ -3620,6 +3757,10 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3620
3757
  "double-stroke",
3621
3758
  "hatched"
3622
3759
  ],
3760
+ preferredCompositions: [
3761
+ "grid-subdivision",
3762
+ "radial"
3763
+ ],
3623
3764
  flowLineMultiplier: 0,
3624
3765
  heroShape: false,
3625
3766
  glowMultiplier: 0,
@@ -3641,6 +3782,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3641
3782
  "incomplete",
3642
3783
  "fill-only"
3643
3784
  ],
3785
+ preferredCompositions: [
3786
+ "golden-spiral",
3787
+ "radial",
3788
+ "spiral"
3789
+ ],
3644
3790
  flowLineMultiplier: 1.5,
3645
3791
  heroShape: true,
3646
3792
  glowMultiplier: 2,
@@ -3661,6 +3807,10 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3661
3807
  "fill-and-stroke",
3662
3808
  "double-stroke"
3663
3809
  ],
3810
+ preferredCompositions: [
3811
+ "grid-subdivision",
3812
+ "golden-spiral"
3813
+ ],
3664
3814
  flowLineMultiplier: 0,
3665
3815
  heroShape: true,
3666
3816
  glowMultiplier: 0,
@@ -3682,6 +3832,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3682
3832
  "double-stroke",
3683
3833
  "dashed"
3684
3834
  ],
3835
+ preferredCompositions: [
3836
+ "radial",
3837
+ "spiral",
3838
+ "clustered"
3839
+ ],
3685
3840
  flowLineMultiplier: 2,
3686
3841
  heroShape: true,
3687
3842
  glowMultiplier: 3,
@@ -3704,6 +3859,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3704
3859
  "stroke-only",
3705
3860
  "dashed"
3706
3861
  ],
3862
+ preferredCompositions: [
3863
+ "flow-field",
3864
+ "grid-subdivision",
3865
+ "clustered"
3866
+ ],
3707
3867
  flowLineMultiplier: 1.5,
3708
3868
  heroShape: false,
3709
3869
  glowMultiplier: 0,
@@ -3725,6 +3885,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3725
3885
  "watercolor",
3726
3886
  "fill-and-stroke"
3727
3887
  ],
3888
+ preferredCompositions: [
3889
+ "radial",
3890
+ "spiral",
3891
+ "golden-spiral"
3892
+ ],
3728
3893
  flowLineMultiplier: 3,
3729
3894
  heroShape: true,
3730
3895
  glowMultiplier: 2.5,
@@ -3746,6 +3911,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3746
3911
  "fill-only",
3747
3912
  "incomplete"
3748
3913
  ],
3914
+ preferredCompositions: [
3915
+ "golden-spiral",
3916
+ "flow-field",
3917
+ "radial"
3918
+ ],
3749
3919
  flowLineMultiplier: 0.5,
3750
3920
  heroShape: false,
3751
3921
  glowMultiplier: 0.3,
@@ -3767,6 +3937,10 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3767
3937
  "stroke-only",
3768
3938
  "dashed"
3769
3939
  ],
3940
+ preferredCompositions: [
3941
+ "grid-subdivision",
3942
+ "radial"
3943
+ ],
3770
3944
  flowLineMultiplier: 0,
3771
3945
  heroShape: false,
3772
3946
  glowMultiplier: 0,
@@ -3788,6 +3962,10 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3788
3962
  "fill-only",
3789
3963
  "double-stroke"
3790
3964
  ],
3965
+ preferredCompositions: [
3966
+ "grid-subdivision",
3967
+ "clustered"
3968
+ ],
3791
3969
  flowLineMultiplier: 0,
3792
3970
  heroShape: true,
3793
3971
  glowMultiplier: 0,
@@ -3809,6 +3987,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3809
3987
  "watercolor",
3810
3988
  "fill-only"
3811
3989
  ],
3990
+ preferredCompositions: [
3991
+ "radial",
3992
+ "golden-spiral",
3993
+ "flow-field"
3994
+ ],
3812
3995
  flowLineMultiplier: 1,
3813
3996
  heroShape: true,
3814
3997
  glowMultiplier: 1,
@@ -3830,6 +4013,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3830
4013
  "stroke-only",
3831
4014
  "fill-only"
3832
4015
  ],
4016
+ preferredCompositions: [
4017
+ "clustered",
4018
+ "grid-subdivision",
4019
+ "radial"
4020
+ ],
3833
4021
  flowLineMultiplier: 0,
3834
4022
  heroShape: false,
3835
4023
  glowMultiplier: 0.3,
@@ -3851,6 +4039,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3851
4039
  "fill-only",
3852
4040
  "incomplete"
3853
4041
  ],
4042
+ preferredCompositions: [
4043
+ "flow-field",
4044
+ "golden-spiral",
4045
+ "spiral"
4046
+ ],
3854
4047
  flowLineMultiplier: 3,
3855
4048
  heroShape: true,
3856
4049
  glowMultiplier: 0.2,
@@ -3872,6 +4065,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3872
4065
  "fill-only",
3873
4066
  "hatched"
3874
4067
  ],
4068
+ preferredCompositions: [
4069
+ "radial",
4070
+ "clustered",
4071
+ "flow-field"
4072
+ ],
3875
4073
  flowLineMultiplier: 0,
3876
4074
  heroShape: false,
3877
4075
  glowMultiplier: 0,
@@ -3894,6 +4092,11 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3894
4092
  "stroke-only",
3895
4093
  "incomplete"
3896
4094
  ],
4095
+ preferredCompositions: [
4096
+ "spiral",
4097
+ "radial",
4098
+ "golden-spiral"
4099
+ ],
3897
4100
  flowLineMultiplier: 2,
3898
4101
  heroShape: true,
3899
4102
  glowMultiplier: 2.5,
@@ -3917,6 +4120,12 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3917
4120
  ...b.preferredStyles
3918
4121
  ])
3919
4122
  ];
4123
+ const mergedCompositions = [
4124
+ ...new Set([
4125
+ ...a.preferredCompositions,
4126
+ ...b.preferredCompositions
4127
+ ])
4128
+ ];
3920
4129
  return {
3921
4130
  name: `${a.name}+${b.name}`,
3922
4131
  gridSize: Math.round($68a238ccd77f2bcd$var$lerpNum(a.gridSize, b.gridSize, t)),
@@ -3928,6 +4137,7 @@ const $68a238ccd77f2bcd$var$ARCHETYPES = [
3928
4137
  backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
3929
4138
  paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
3930
4139
  preferredStyles: mergedStyles,
4140
+ preferredCompositions: mergedCompositions,
3931
4141
  flowLineMultiplier: $68a238ccd77f2bcd$var$lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
3932
4142
  heroShape: t < 0.5 ? a.heroShape : b.heroShape,
3933
4143
  glowMultiplier: $68a238ccd77f2bcd$var$lerpNum(a.glowMultiplier, b.glowMultiplier, t),
@@ -3949,6 +4159,46 @@ function $68a238ccd77f2bcd$export$f1142fd7da4d6590(rng) {
3949
4159
  }
3950
4160
 
3951
4161
 
4162
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4163
+ // Based on benchmark measurements. Used by the complexity budget to
4164
+ // cap total rendering work and downgrade expensive styles when needed.
4165
+ const $1f63dc64b5593c73$var$RENDER_STYLE_COST = {
4166
+ "fill-and-stroke": 1,
4167
+ "fill-only": 0.5,
4168
+ "stroke-only": 1,
4169
+ "double-stroke": 1.5,
4170
+ "dashed": 1,
4171
+ "watercolor": 7,
4172
+ "hatched": 3,
4173
+ "incomplete": 1,
4174
+ "stipple": 90,
4175
+ "stencil": 2,
4176
+ "noise-grain": 400,
4177
+ "wood-grain": 10,
4178
+ "marble-vein": 4,
4179
+ "fabric-weave": 6,
4180
+ "hand-drawn": 5
4181
+ };
4182
+ function $1f63dc64b5593c73$var$downgradeRenderStyle(style) {
4183
+ switch(style){
4184
+ case "noise-grain":
4185
+ return "hatched";
4186
+ case "stipple":
4187
+ return "dashed";
4188
+ case "wood-grain":
4189
+ return "hatched";
4190
+ case "watercolor":
4191
+ return "fill-and-stroke";
4192
+ case "fabric-weave":
4193
+ return "hatched";
4194
+ case "hand-drawn":
4195
+ return "fill-and-stroke";
4196
+ case "marble-vein":
4197
+ return "stroke-only";
4198
+ default:
4199
+ return style;
4200
+ }
4201
+ }
3952
4202
  // ── Shape categories for weighted selection (legacy fallback) ───────
3953
4203
  const $1f63dc64b5593c73$var$SACRED_SHAPES = [
3954
4204
  "mandala",
@@ -3961,7 +4211,8 @@ const $1f63dc64b5593c73$var$SACRED_SHAPES = [
3961
4211
  "torus",
3962
4212
  "eggOfLife"
3963
4213
  ];
3964
- const $1f63dc64b5593c73$var$COMPOSITION_MODES = [
4214
+ // ── Composition modes ───────────────────────────────────────────────
4215
+ const $1f63dc64b5593c73$var$ALL_COMPOSITION_MODES = [
3965
4216
  "radial",
3966
4217
  "flow-field",
3967
4218
  "spiral",
@@ -4063,7 +4314,67 @@ function $1f63dc64b5593c73$var$isInVoidZone(x, y, voidZones) {
4063
4314
  }
4064
4315
  return false;
4065
4316
  }
4066
- // ── Helper: density check ───────────────────────────────────────────
4317
+ // ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
4318
+ class $1f63dc64b5593c73$var$SpatialGrid {
4319
+ constructor(cellSize){
4320
+ this.cells = new Map();
4321
+ this.cellSize = cellSize;
4322
+ }
4323
+ key(cx, cy) {
4324
+ return `${cx},${cy}`;
4325
+ }
4326
+ insert(item) {
4327
+ const cx = Math.floor(item.x / this.cellSize);
4328
+ const cy = Math.floor(item.y / this.cellSize);
4329
+ const k = this.key(cx, cy);
4330
+ const cell = this.cells.get(k);
4331
+ if (cell) cell.push(item);
4332
+ else this.cells.set(k, [
4333
+ item
4334
+ ]);
4335
+ }
4336
+ /** Count items within radius of (x, y) */ countNear(x, y, radius) {
4337
+ const r2 = radius * radius;
4338
+ const minCx = Math.floor((x - radius) / this.cellSize);
4339
+ const maxCx = Math.floor((x + radius) / this.cellSize);
4340
+ const minCy = Math.floor((y - radius) / this.cellSize);
4341
+ const maxCy = Math.floor((y + radius) / this.cellSize);
4342
+ let count = 0;
4343
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4344
+ const cell = this.cells.get(this.key(cx, cy));
4345
+ if (!cell) continue;
4346
+ for (const p of cell){
4347
+ const dx = x - p.x;
4348
+ const dy = y - p.y;
4349
+ if (dx * dx + dy * dy < r2) count++;
4350
+ }
4351
+ }
4352
+ return count;
4353
+ }
4354
+ /** Find nearest item to (x, y) */ findNearest(x, y, searchRadius) {
4355
+ const minCx = Math.floor((x - searchRadius) / this.cellSize);
4356
+ const maxCx = Math.floor((x + searchRadius) / this.cellSize);
4357
+ const minCy = Math.floor((y - searchRadius) / this.cellSize);
4358
+ const maxCy = Math.floor((y + searchRadius) / this.cellSize);
4359
+ let nearest = null;
4360
+ let bestDist2 = Infinity;
4361
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4362
+ const cell = this.cells.get(this.key(cx, cy));
4363
+ if (!cell) continue;
4364
+ for (const p of cell){
4365
+ const dx = x - p.x;
4366
+ const dy = y - p.y;
4367
+ const d2 = dx * dx + dy * dy;
4368
+ if (d2 > 0 && d2 < bestDist2) {
4369
+ bestDist2 = d2;
4370
+ nearest = p;
4371
+ }
4372
+ }
4373
+ }
4374
+ return nearest;
4375
+ }
4376
+ }
4377
+ // ── Helper: density check (legacy wrapper) ──────────────────────────
4067
4378
  function $1f63dc64b5593c73$var$localDensity(x, y, positions, radius) {
4068
4379
  let count = 0;
4069
4380
  for (const p of positions)if (Math.hypot(x - p.x, y - p.y) < radius) count++;
@@ -4362,42 +4673,43 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4362
4673
  const patternOpacity = 0.02 + rng() * 0.04;
4363
4674
  const patternColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4364
4675
  if (bgPatternRoll < 0.2) {
4365
- // Dot grid
4676
+ // Dot grid — batched into a single path
4366
4677
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4367
4678
  const dotR = dotSpacing * 0.08;
4368
4679
  ctx.globalAlpha = patternOpacity;
4369
4680
  ctx.fillStyle = patternColor;
4681
+ ctx.beginPath();
4370
4682
  for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4371
- ctx.beginPath();
4683
+ ctx.moveTo(px + dotR, py);
4372
4684
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
4373
- ctx.fill();
4374
4685
  }
4686
+ ctx.fill();
4375
4687
  } else if (bgPatternRoll < 0.4) {
4376
- // Diagonal lines
4688
+ // Diagonal lines — batched into a single path
4377
4689
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4378
4690
  ctx.globalAlpha = patternOpacity;
4379
4691
  ctx.strokeStyle = patternColor;
4380
4692
  ctx.lineWidth = 0.5 * scaleFactor;
4381
4693
  const diag = Math.hypot(width, height);
4694
+ ctx.beginPath();
4382
4695
  for(let d = -diag; d < diag; d += lineSpacing){
4383
- ctx.beginPath();
4384
4696
  ctx.moveTo(d, 0);
4385
4697
  ctx.lineTo(d + height, height);
4386
- ctx.stroke();
4387
4698
  }
4699
+ ctx.stroke();
4388
4700
  } else {
4389
- // Tessellation — hexagonal grid of tiny shapes
4701
+ // Tessellation — hexagonal grid, batched into a single path
4390
4702
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4391
4703
  const tessH = tessSize * Math.sqrt(3);
4392
4704
  ctx.globalAlpha = patternOpacity * 0.7;
4393
4705
  ctx.strokeStyle = patternColor;
4394
4706
  ctx.lineWidth = 0.4 * scaleFactor;
4707
+ ctx.beginPath();
4395
4708
  for(let row = 0; row * tessH < height + tessH; row++){
4396
4709
  const offsetX = row % 2 * tessSize * 0.75;
4397
4710
  for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4398
4711
  const hx = col * tessSize * 1.5 + offsetX;
4399
4712
  const hy = row * tessH;
4400
- ctx.beginPath();
4401
4713
  for(let s = 0; s < 6; s++){
4402
4714
  const angle = Math.PI / 3 * s - Math.PI / 6;
4403
4715
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -4406,18 +4718,18 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4406
4718
  else ctx.lineTo(vx, vy);
4407
4719
  }
4408
4720
  ctx.closePath();
4409
- ctx.stroke();
4410
4721
  }
4411
4722
  }
4723
+ ctx.stroke();
4412
4724
  }
4413
4725
  ctx.restore();
4414
4726
  }
4415
4727
  ctx.globalCompositeOperation = "source-over";
4416
- // ── 2. Composition mode ────────────────────────────────────────
4417
- const compositionMode = $1f63dc64b5593c73$var$COMPOSITION_MODES[Math.floor(rng() * $1f63dc64b5593c73$var$COMPOSITION_MODES.length)];
4728
+ // ── 2. Composition mode — archetype-aware selection ──────────────
4729
+ const compositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : $1f63dc64b5593c73$var$ALL_COMPOSITION_MODES[Math.floor(rng() * $1f63dc64b5593c73$var$ALL_COMPOSITION_MODES.length)];
4418
4730
  const symRoll = rng();
4419
4731
  const symmetryMode = symRoll < 0.10 ? "bilateral-x" : symRoll < 0.20 ? "bilateral-y" : symRoll < 0.25 ? "quad" : "none";
4420
- // ── 3. Focal points + void zones ───────────────────────────────
4732
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
4421
4733
  const THIRDS_POINTS = [
4422
4734
  {
4423
4735
  x: 1 / 3,
@@ -4450,9 +4762,23 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4450
4762
  y: height * (0.2 + rng() * 0.6),
4451
4763
  strength: 0.3 + rng() * 0.4
4452
4764
  });
4453
- const numVoids = Math.floor(rng() * 2) + 1;
4765
+ // Archetype-aware void zones: dense archetypes get fewer/no voids,
4766
+ // minimal archetypes get golden-ratio positioned voids
4767
+ const PHI = (1 + Math.sqrt(5)) / 2;
4768
+ const isMinimalArchetype = archetype.gridSize <= 3;
4769
+ const isDenseArchetype = archetype.gridSize >= 8;
4770
+ const numVoids = isDenseArchetype ? 0 : Math.floor(rng() * 2) + 1;
4454
4771
  const voidZones = [];
4455
- for(let v = 0; v < numVoids; v++)voidZones.push({
4772
+ for(let v = 0; v < numVoids; v++)if (isMinimalArchetype) {
4773
+ // Place voids at golden-ratio positions for intentional negative space
4774
+ const gx = v === 0 ? 1 / PHI : 1 - 1 / PHI;
4775
+ const gy = v === 0 ? 1 - 1 / PHI : 1 / PHI;
4776
+ voidZones.push({
4777
+ x: width * (gx + (rng() - 0.5) * 0.05),
4778
+ y: height * (gy + (rng() - 0.5) * 0.05),
4779
+ radius: Math.min(width, height) * (0.08 + rng() * 0.08)
4780
+ });
4781
+ } else voidZones.push({
4456
4782
  x: width * (0.15 + rng() * 0.7),
4457
4783
  y: height * (0.15 + rng() * 0.7),
4458
4784
  radius: Math.min(width, height) * (0.06 + rng() * 0.1)
@@ -4482,19 +4808,20 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4482
4808
  ctx.beginPath();
4483
4809
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4484
4810
  ctx.stroke();
4485
- // ~50% chance: scatter tiny dots inside the void
4811
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4486
4812
  if (rng() < 0.5) {
4487
4813
  const dotCount = 3 + Math.floor(rng() * 6);
4488
4814
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4489
4815
  ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4816
+ ctx.beginPath();
4490
4817
  for(let d = 0; d < dotCount; d++){
4491
4818
  const angle = rng() * Math.PI * 2;
4492
4819
  const dist = rng() * zone.radius * 0.7;
4493
4820
  const dotR = (1 + rng() * 3) * scaleFactor;
4494
- ctx.beginPath();
4821
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4495
4822
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4496
- ctx.fill();
4497
4823
  }
4824
+ ctx.fill();
4498
4825
  }
4499
4826
  // ~30% chance: thin concentric ring inside
4500
4827
  if (rng() < 0.3) {
@@ -4529,6 +4856,9 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4529
4856
  }
4530
4857
  // Track all placed shapes for density checks and connecting curves
4531
4858
  const shapePositions = [];
4859
+ // Spatial grid for O(1) density and nearest-neighbor lookups
4860
+ const densityCheckRadius = Math.min(width, height) * 0.08;
4861
+ const spatialGrid = new $1f63dc64b5593c73$var$SpatialGrid(densityCheckRadius);
4532
4862
  // Hero avoidance radius — shapes near the hero orient toward it
4533
4863
  let heroCenter = null;
4534
4864
  // ── 4b. Hero shape — a dominant focal element ───────────────────
@@ -4574,10 +4904,35 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4574
4904
  size: heroSize,
4575
4905
  shape: heroShape
4576
4906
  });
4907
+ spatialGrid.insert({
4908
+ x: heroFocal.x,
4909
+ y: heroFocal.y,
4910
+ size: heroSize,
4911
+ shape: heroShape
4912
+ });
4577
4913
  }
4578
4914
  // ── 5. Shape layers ────────────────────────────────────────────
4579
- const densityCheckRadius = Math.min(width, height) * 0.08;
4580
4915
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4916
+ // ── Complexity budget — caps total rendering work ──────────────
4917
+ // Budget scales with pixel area so larger canvases get proportionally
4918
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4919
+ // constellations, rhythm) are gated behind the budget; when it runs
4920
+ // low they are skipped. When it's exhausted, expensive render styles
4921
+ // are downgraded to cheaper alternatives.
4922
+ //
4923
+ // RNG values are always consumed even when skipping, so the
4924
+ // deterministic sequence for shapes that *do* render is preserved.
4925
+ const pixelArea = width * height;
4926
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4927
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4928
+ const totalBudget = complexityBudget;
4929
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4930
+ let extrasSpent = 0;
4931
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4932
+ // These generate O(size²) fillRect calls per shape and dominate
4933
+ // worst-case render time. Cap scales with pixel area.
4934
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4935
+ let clipHeavyCount = 0;
4581
4936
  for(let layer = 0; layer < layers; layer++){
4582
4937
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4583
4938
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4615,7 +4970,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4615
4970
  if ($1f63dc64b5593c73$var$isInVoidZone(x, y, voidZones)) {
4616
4971
  if (rng() < 0.85) continue;
4617
4972
  }
4618
- if ($1f63dc64b5593c73$var$localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
4973
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
4619
4974
  if (rng() < 0.6) continue;
4620
4975
  }
4621
4976
  // Power distribution for size — archetype controls the curve
@@ -4666,7 +5021,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4666
5021
  const shapeRenderStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4667
5022
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4668
5023
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4669
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5024
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5025
+ // Budget check: downgrade expensive styles proportionally —
5026
+ // the more expensive the style, the earlier it gets downgraded.
5027
+ // noise-grain (400) downgrades when budget < 20% remaining,
5028
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5029
+ let styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5030
+ if (styleCost > 3) {
5031
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5032
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5033
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5034
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5035
+ }
5036
+ }
5037
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5038
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5039
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5040
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5041
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5042
+ }
5043
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4670
5044
  // Consistent light direction — subtle shadow offset
4671
5045
  const shadowDist = hasGlow ? 0 : size * 0.02;
4672
5046
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4675,17 +5049,11 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4675
5049
  let finalX = x;
4676
5050
  let finalY = y;
4677
5051
  if (shapePositions.length > 0 && rng() < 0.25) {
4678
- // Find nearest placed shape
4679
- let nearestDist = Infinity;
4680
- let nearestPos = null;
4681
- for (const sp of shapePositions){
4682
- const d = Math.hypot(x - sp.x, y - sp.y);
4683
- if (d < nearestDist && d > 0) {
4684
- nearestDist = d;
4685
- nearestPos = sp;
4686
- }
4687
- }
5052
+ // Use spatial grid for O(1) nearest-neighbor lookup
5053
+ const searchRadius = adjustedMaxSize * 3;
5054
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
4688
5055
  if (nearestPos) {
5056
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
4689
5057
  // Target distance: edges kissing (sum of half-sizes)
4690
5058
  const targetDist = (size + nearestPos.size) * 0.5;
4691
5059
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -4727,30 +5095,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4727
5095
  lightAngle: lightAngle,
4728
5096
  scaleFactor: scaleFactor
4729
5097
  };
4730
- if (shouldMirror) (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4731
- ...shapeConfig,
4732
- mirrorAxis: mirrorAxis,
4733
- mirrorGap: size * (0.1 + rng() * 0.3)
4734
- });
4735
- else (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5098
+ if (shouldMirror) {
5099
+ (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5100
+ ...shapeConfig,
5101
+ mirrorAxis: mirrorAxis,
5102
+ mirrorGap: size * (0.1 + rng() * 0.3)
5103
+ });
5104
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5105
+ } else {
5106
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5107
+ complexityBudget -= styleCost;
5108
+ }
5109
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5110
+ const extrasAllowed = extrasSpent < budgetForExtras;
4736
5111
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4737
5112
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4738
5113
  const glazePasses = 2 + Math.floor(rng() * 2);
4739
- for(let g = 0; g < glazePasses; g++){
4740
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4741
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4742
- ctx.globalAlpha = glazeAlpha;
4743
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4744
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4745
- strokeColor: "rgba(0,0,0,0)",
4746
- strokeWidth: 0,
4747
- size: size * glazeScale,
4748
- rotation: rotation,
4749
- proportionType: "GOLDEN_RATIO",
4750
- renderStyle: "fill-only",
4751
- rng: rng
4752
- });
5114
+ if (extrasAllowed) {
5115
+ for(let g = 0; g < glazePasses; g++){
5116
+ const glazeScale = 1 - (g + 1) * 0.12;
5117
+ const glazeAlpha = 0.08 + g * 0.04;
5118
+ ctx.globalAlpha = glazeAlpha;
5119
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5120
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5121
+ strokeColor: "rgba(0,0,0,0)",
5122
+ strokeWidth: 0,
5123
+ size: size * glazeScale,
5124
+ rotation: rotation,
5125
+ proportionType: "GOLDEN_RATIO",
5126
+ renderStyle: "fill-only",
5127
+ rng: rng
5128
+ });
5129
+ }
5130
+ extrasSpent += glazePasses;
4753
5131
  }
5132
+ // RNG consumed by glazePasses calculation above regardless
4754
5133
  }
4755
5134
  shapePositions.push({
4756
5135
  x: finalX,
@@ -4758,35 +5137,51 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4758
5137
  size: size,
4759
5138
  shape: shape
4760
5139
  });
5140
+ spatialGrid.insert({
5141
+ x: finalX,
5142
+ y: finalY,
5143
+ size: size,
5144
+ shape: shape
5145
+ });
4761
5146
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
4762
5147
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4763
5148
  const echoCount = 2 + Math.floor(rng() * 2);
4764
5149
  const echoAngle = rng() * Math.PI * 2;
4765
- for(let e = 0; e < echoCount; e++){
4766
- const echoScale = 0.3 - e * 0.08;
4767
- const echoDist = size * (0.6 + e * 0.4);
4768
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4769
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4770
- const echoSize = size * Math.max(0.1, echoScale);
4771
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4772
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4773
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4774
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4775
- strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
4776
- strokeWidth: strokeWidth * 0.6,
4777
- size: echoSize,
4778
- rotation: rotation + (e + 1) * 15,
4779
- proportionType: "GOLDEN_RATIO",
4780
- renderStyle: finalRenderStyle,
4781
- rng: rng
4782
- });
4783
- shapePositions.push({
4784
- x: echoX,
4785
- y: echoY,
4786
- size: echoSize,
4787
- shape: shape
4788
- });
5150
+ if (extrasAllowed) {
5151
+ for(let e = 0; e < echoCount; e++){
5152
+ const echoScale = 0.3 - e * 0.08;
5153
+ const echoDist = size * (0.6 + e * 0.4);
5154
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5155
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5156
+ const echoSize = size * Math.max(0.1, echoScale);
5157
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5158
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5159
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5160
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5161
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
5162
+ strokeWidth: strokeWidth * 0.6,
5163
+ size: echoSize,
5164
+ rotation: rotation + (e + 1) * 15,
5165
+ proportionType: "GOLDEN_RATIO",
5166
+ renderStyle: finalRenderStyle,
5167
+ rng: rng
5168
+ });
5169
+ shapePositions.push({
5170
+ x: echoX,
5171
+ y: echoY,
5172
+ size: echoSize,
5173
+ shape: shape
5174
+ });
5175
+ spatialGrid.insert({
5176
+ x: echoX,
5177
+ y: echoY,
5178
+ size: echoSize,
5179
+ shape: shape
5180
+ });
5181
+ }
5182
+ extrasSpent += echoCount * styleCost;
4789
5183
  }
5184
+ // RNG for echoCount + echoAngle consumed above regardless
4790
5185
  }
4791
5186
  // ── 5d. Recursive nesting ──────────────────────────────────
4792
5187
  // Focal depth: shapes near focal points get more detail
@@ -4794,7 +5189,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4794
5189
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4795
5190
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4796
5191
  const innerCount = 1 + Math.floor(rng() * 3);
4797
- for(let n = 0; n < innerCount; n++){
5192
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4798
5193
  // Pick inner shape from palette affinities
4799
5194
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
4800
5195
  const innerShape = (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -4803,6 +5198,10 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4803
5198
  const innerOffY = (rng() - 0.5) * size * 0.4;
4804
5199
  const innerRot = rng() * 360;
4805
5200
  const innerFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5201
+ let innerStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5202
+ // Apply clip-heavy cap to nested shapes too
5203
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(innerStyle);
5204
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
4806
5205
  ctx.globalAlpha = layerOpacity * 0.7;
4807
5206
  (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
4808
5207
  fillColor: innerFill,
@@ -4811,9 +5210,21 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4811
5210
  size: innerSize,
4812
5211
  rotation: innerRot,
4813
5212
  proportionType: "GOLDEN_RATIO",
4814
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5213
+ renderStyle: innerStyle,
4815
5214
  rng: rng
4816
5215
  });
5216
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5217
+ }
5218
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5219
+ for(let n = 0; n < innerCount; n++){
5220
+ rng();
5221
+ rng();
5222
+ rng();
5223
+ rng();
5224
+ rng();
5225
+ rng();
5226
+ rng();
5227
+ rng();
4817
5228
  }
4818
5229
  }
4819
5230
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -4822,41 +5233,113 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4822
5233
  const constellation = $1f63dc64b5593c73$var$CONSTELLATIONS[Math.floor(rng() * $1f63dc64b5593c73$var$CONSTELLATIONS.length)];
4823
5234
  const members = constellation.build(rng, size);
4824
5235
  const groupRotation = rng() * Math.PI * 2;
4825
- const cosR = Math.cos(groupRotation);
4826
- const sinR = Math.sin(groupRotation);
4827
- for (const member of members){
4828
- // Rotate the group offset by the group rotation
4829
- const mx = finalX + member.dx * cosR - member.dy * sinR;
4830
- const my = finalY + member.dx * sinR + member.dy * cosR;
4831
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
4832
- const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
4833
- const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
4834
- ctx.globalAlpha = layerOpacity * 0.6;
4835
- // Use the member's shape if available, otherwise fall back to palette
4836
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
4837
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
4838
- fillColor: memberFill,
4839
- strokeColor: memberStroke,
4840
- strokeWidth: strokeWidth * 0.7,
4841
- size: member.size,
4842
- rotation: member.rotation + groupRotation * 180 / Math.PI,
4843
- proportionType: "GOLDEN_RATIO",
4844
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
4845
- rng: rng
4846
- });
4847
- shapePositions.push({
4848
- x: mx,
4849
- y: my,
4850
- size: member.size,
4851
- shape: memberShape
4852
- });
5236
+ if (extrasAllowed) {
5237
+ const cosR = Math.cos(groupRotation);
5238
+ const sinR = Math.sin(groupRotation);
5239
+ for (const member of members){
5240
+ // Rotate the group offset by the group rotation
5241
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5242
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5243
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5244
+ const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5245
+ const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5246
+ ctx.globalAlpha = layerOpacity * 0.6;
5247
+ // Use the member's shape if available, otherwise fall back to palette
5248
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5249
+ let memberStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5250
+ // Apply clip-heavy cap to constellation members too
5251
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(memberStyle);
5252
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5253
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5254
+ fillColor: memberFill,
5255
+ strokeColor: memberStroke,
5256
+ strokeWidth: strokeWidth * 0.7,
5257
+ size: member.size,
5258
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5259
+ proportionType: "GOLDEN_RATIO",
5260
+ renderStyle: memberStyle,
5261
+ rng: rng
5262
+ });
5263
+ shapePositions.push({
5264
+ x: mx,
5265
+ y: my,
5266
+ size: member.size,
5267
+ shape: memberShape
5268
+ });
5269
+ spatialGrid.insert({
5270
+ x: mx,
5271
+ y: my,
5272
+ size: member.size,
5273
+ shape: memberShape
5274
+ });
5275
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5276
+ }
5277
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5278
+ for(let m = 0; m < members.length; m++){
5279
+ rng();
5280
+ rng();
5281
+ rng();
5282
+ rng();
5283
+ rng();
5284
+ rng();
5285
+ }
5286
+ }
5287
+ // ── 5f. Rhythm placement — deliberate geometric progressions ──
5288
+ // ~12% of medium-large shapes spawn a rhythmic sequence
5289
+ if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
5290
+ const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
5291
+ const rhythmAngle = rng() * Math.PI * 2;
5292
+ const rhythmSpacing = size * (0.8 + rng() * 0.6);
5293
+ const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5294
+ const rhythmShape = shape; // same shape for visual rhythm
5295
+ if (extrasAllowed) {
5296
+ let rhythmSize = size * 0.6;
5297
+ for(let r = 0; r < rhythmCount; r++){
5298
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5299
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5300
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5301
+ if ($1f63dc64b5593c73$var$isInVoidZone(rx, ry, voidZones)) break;
5302
+ rhythmSize *= rhythmDecay;
5303
+ if (rhythmSize < adjustedMinSize) break;
5304
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5305
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5306
+ const rhythmFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5307
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5308
+ fillColor: rhythmFill,
5309
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.5),
5310
+ strokeWidth: strokeWidth * 0.7,
5311
+ size: rhythmSize,
5312
+ rotation: rotation + (r + 1) * 12,
5313
+ proportionType: "GOLDEN_RATIO",
5314
+ renderStyle: finalRenderStyle,
5315
+ rng: rng
5316
+ });
5317
+ shapePositions.push({
5318
+ x: rx,
5319
+ y: ry,
5320
+ size: rhythmSize,
5321
+ shape: rhythmShape
5322
+ });
5323
+ spatialGrid.insert({
5324
+ x: rx,
5325
+ y: ry,
5326
+ size: rhythmSize,
5327
+ shape: rhythmShape
5328
+ });
5329
+ }
5330
+ extrasSpent += rhythmCount * styleCost;
5331
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5332
+ for(let r = 0; r < rhythmCount; r++){
5333
+ rng();
5334
+ rng();
5335
+ rng();
4853
5336
  }
4854
5337
  }
4855
5338
  }
4856
5339
  }
4857
5340
  // Reset blend mode for post-processing passes
4858
5341
  ctx.globalCompositeOperation = "source-over";
4859
- // ── 5f. Layered masking / cutout portals ───────────────────────
5342
+ // ── 5g. Layered masking / cutout portals ───────────────────────
4860
5343
  // ~18% of images get 1-3 portal windows that paint over foreground
4861
5344
  // with a tinted background wash, creating a "peek through" effect.
4862
5345
  if (rng() < 0.18 && shapePositions.length > 3) {
@@ -4915,14 +5398,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4915
5398
  }
4916
5399
  }
4917
5400
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5401
+ // Optimized: collect all segments into width-quantized buckets, then
5402
+ // render each bucket as a single batched path. This reduces
5403
+ // beginPath/stroke calls from O(segments) to O(buckets).
4918
5404
  const baseFlowLines = 6 + Math.floor(rng() * 10);
4919
5405
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5406
+ // Width buckets — 6 buckets cover the taper×pressure range
5407
+ const FLOW_WIDTH_BUCKETS = 6;
5408
+ const flowBuckets = [];
5409
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5410
+ // Track the representative width for each bucket
5411
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5412
+ // Pre-compute max possible width for bucket assignment
5413
+ let globalMaxFlowWidth = 0;
4920
5414
  for(let i = 0; i < numFlowLines; i++){
4921
5415
  let fx = rng() * width;
4922
5416
  let fy = rng() * height;
4923
5417
  const steps = 30 + Math.floor(rng() * 40);
4924
5418
  const stepLen = (3 + rng() * 5) * scaleFactor;
4925
5419
  const startWidth = (1 + rng() * 3) * scaleFactor;
5420
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
4926
5421
  // Variable color: interpolate between two hierarchy colors along the stroke
4927
5422
  const lineColorStart = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
4928
5423
  const lineColorEnd = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -4937,20 +5432,29 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4937
5432
  fx += Math.cos(angle) * stepLen;
4938
5433
  fy += Math.sin(angle) * stepLen;
4939
5434
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
5435
+ // Skip segments that pass through void zones
5436
+ if ($1f63dc64b5593c73$var$isInVoidZone(fx, fy, voidZones)) {
5437
+ prevX = fx;
5438
+ prevY = fy;
5439
+ continue;
5440
+ }
4940
5441
  const t = s / steps;
4941
- // Taper + pressure
4942
5442
  const taper = 1 - t * 0.8;
4943
5443
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
4944
- ctx.globalAlpha = lineAlpha * taper;
4945
- // Interpolate color along stroke
5444
+ const segWidth = startWidth * taper * pressure;
5445
+ const segAlpha = lineAlpha * taper;
4946
5446
  const lineColor = t < 0.5 ? (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
4947
- ctx.strokeStyle = lineColor;
4948
- ctx.lineWidth = startWidth * taper * pressure;
4949
- ctx.lineCap = "round";
4950
- ctx.beginPath();
4951
- ctx.moveTo(prevX, prevY);
4952
- ctx.lineTo(fx, fy);
4953
- ctx.stroke();
5447
+ // Quantize width into bucket
5448
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5449
+ flowBuckets[bucketIdx].push({
5450
+ x1: prevX,
5451
+ y1: prevY,
5452
+ x2: fx,
5453
+ y2: fy,
5454
+ color: lineColor,
5455
+ alpha: segAlpha
5456
+ });
5457
+ flowBucketWidths[bucketIdx] = segWidth;
4954
5458
  // Branching: ~12% chance per step to spawn a thinner child stroke
4955
5459
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
4956
5460
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -4966,12 +5470,18 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4966
5470
  by += Math.sin(bAngle) * stepLen * 0.8;
4967
5471
  if (bx < 0 || bx > width || by < 0 || by > height) break;
4968
5472
  const bTaper = 1 - bs / branchSteps * 0.9;
4969
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
4970
- ctx.lineWidth = branchWidth * bTaper;
4971
- ctx.beginPath();
4972
- ctx.moveTo(bPrevX, bPrevY);
4973
- ctx.lineTo(bx, by);
4974
- ctx.stroke();
5473
+ const bSegWidth = branchWidth * bTaper;
5474
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5475
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5476
+ flowBuckets[bBucket].push({
5477
+ x1: bPrevX,
5478
+ y1: bPrevY,
5479
+ x2: bx,
5480
+ y2: by,
5481
+ color: lineColor,
5482
+ alpha: bAlpha
5483
+ });
5484
+ flowBucketWidths[bBucket] = bSegWidth;
4975
5485
  bPrevX = bx;
4976
5486
  bPrevY = by;
4977
5487
  }
@@ -4980,7 +5490,40 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4980
5490
  prevY = fy;
4981
5491
  }
4982
5492
  }
5493
+ // Render flow line buckets — one batched path per width bucket
5494
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5495
+ ctx.lineCap = "round";
5496
+ const FLOW_ALPHA_BUCKETS = 4;
5497
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5498
+ const segs = flowBuckets[wb];
5499
+ if (segs.length === 0) continue;
5500
+ ctx.lineWidth = flowBucketWidths[wb];
5501
+ // Sub-bucket by alpha
5502
+ const alphaSubs = [];
5503
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5504
+ let maxAlpha = 0;
5505
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5506
+ for(let j = 0; j < segs.length; j++){
5507
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5508
+ alphaSubs[ai].push(segs[j]);
5509
+ }
5510
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5511
+ const sub = alphaSubs[ai];
5512
+ if (sub.length === 0) continue;
5513
+ // Use the median segment's alpha and color as representative
5514
+ const rep = sub[Math.floor(sub.length / 2)];
5515
+ ctx.globalAlpha = rep.alpha;
5516
+ ctx.strokeStyle = rep.color;
5517
+ ctx.beginPath();
5518
+ for(let j = 0; j < sub.length; j++){
5519
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5520
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5521
+ }
5522
+ ctx.stroke();
5523
+ }
5524
+ }
4983
5525
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5526
+ // Optimized: collect all burst segments, then batch by quantized alpha
4984
5527
  const energyArchetypes = [
4985
5528
  "dense-chaotic",
4986
5529
  "cosmic",
@@ -4991,8 +5534,12 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4991
5534
  if (hasEnergyLines && shapePositions.length > 0) {
4992
5535
  const energyCount = 5 + Math.floor(rng() * 10);
4993
5536
  ctx.lineCap = "round";
5537
+ // Collect all energy segments with their computed state
5538
+ const ENERGY_ALPHA_BUCKETS = 3;
5539
+ const energyBuckets = [];
5540
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5541
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
4994
5542
  for(let e = 0; e < energyCount; e++){
4995
- // Pick a random shape to radiate from
4996
5543
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
4997
5544
  const burstCount = 2 + Math.floor(rng() * 4);
4998
5545
  const baseAngle = flowAngle(source.x, source.y);
@@ -5004,14 +5551,37 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5004
5551
  const sy = source.y + Math.sin(angle) * startDist;
5005
5552
  const ex = sx + Math.cos(angle) * lineLen;
5006
5553
  const ey = sy + Math.sin(angle) * lineLen;
5007
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5008
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5009
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5010
- ctx.beginPath();
5011
- ctx.moveTo(sx, sy);
5012
- ctx.lineTo(ex, ey);
5013
- ctx.stroke();
5554
+ const eAlpha = 0.04 + rng() * 0.06;
5555
+ const eColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5556
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5557
+ // Quantize alpha into bucket
5558
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5559
+ energyBuckets[bi].push({
5560
+ x1: sx,
5561
+ y1: sy,
5562
+ x2: ex,
5563
+ y2: ey,
5564
+ color: eColor,
5565
+ lw: eLw
5566
+ });
5567
+ energyAlphas[bi] = eAlpha;
5568
+ }
5569
+ }
5570
+ // Render batched energy lines
5571
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5572
+ const segs = energyBuckets[bi];
5573
+ if (segs.length === 0) continue;
5574
+ ctx.globalAlpha = energyAlphas[bi];
5575
+ // Use median segment's color and width as representative
5576
+ const rep = segs[Math.floor(segs.length / 2)];
5577
+ ctx.strokeStyle = rep.color;
5578
+ ctx.lineWidth = rep.lw;
5579
+ ctx.beginPath();
5580
+ for(let j = 0; j < segs.length; j++){
5581
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5582
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5014
5583
  }
5584
+ ctx.stroke();
5015
5585
  }
5016
5586
  }
5017
5587
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
@@ -5034,50 +5604,128 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5034
5604
  }
5035
5605
  ctx.restore();
5036
5606
  }
5037
- // ── 7. Noise texture overlay ───────────────────────────────────
5607
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
5608
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
5609
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
5038
5610
  const noiseRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 777));
5039
- const noiseDensity = Math.floor(width * height / 800);
5040
- for(let i = 0; i < noiseDensity; i++){
5041
- const nx = noiseRng() * width;
5042
- const ny = noiseRng() * height;
5043
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5044
- const alpha = 0.01 + noiseRng() * 0.03;
5045
- ctx.globalAlpha = alpha;
5046
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5047
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5611
+ const rawNoiseDensity = Math.floor(width * height / 800);
5612
+ // Cap at 2500 dots beyond this the visual effect is indistinguishable
5613
+ // but getImageData/putImageData cost scales with canvas size
5614
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5615
+ try {
5616
+ const imageData = ctx.getImageData(0, 0, width, height);
5617
+ const data = imageData.data;
5618
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5619
+ if (pixelScale === 1) // Fast path — no inner loop, direct pixel write
5620
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
5621
+ for(let i = 0; i < noiseDensity; i++){
5622
+ const nx = Math.floor(noiseRng() * width);
5623
+ const ny = Math.floor(noiseRng() * height);
5624
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5625
+ // srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
5626
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5627
+ const invA256 = 256 - srcA256;
5628
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
5629
+ const idx = ny * width + nx << 2;
5630
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5631
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5632
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5633
+ }
5634
+ else for(let i = 0; i < noiseDensity; i++){
5635
+ const nx = Math.floor(noiseRng() * width);
5636
+ const ny = Math.floor(noiseRng() * height);
5637
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5638
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5639
+ const invA256 = 256 - srcA256;
5640
+ const bSrc = brightness * srcA256;
5641
+ for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5642
+ const idx = (ny + dy) * width + (nx + dx) << 2;
5643
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5644
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5645
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5646
+ }
5647
+ }
5648
+ ctx.putImageData(imageData, 0, 0);
5649
+ } catch {
5650
+ // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5651
+ for(let i = 0; i < noiseDensity; i++){
5652
+ const nx = noiseRng() * width;
5653
+ const ny = noiseRng() * height;
5654
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5655
+ const alpha = 0.01 + noiseRng() * 0.03;
5656
+ ctx.globalAlpha = alpha;
5657
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5658
+ ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5659
+ }
5048
5660
  }
5049
5661
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5050
5662
  ctx.globalAlpha = 1;
5051
5663
  const vignetteStrength = 0.25 + rng() * 0.2;
5052
5664
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
5665
+ // Tint vignette based on background: warm sepia for light, cool blue for dark
5666
+ const isLightBg = bgLum > 0.5;
5667
+ const vignetteColor = isLightBg ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
5668
+ : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
5053
5669
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
5054
5670
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
5055
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
5671
+ vigGrad.addColorStop(1, vignetteColor);
5056
5672
  ctx.fillStyle = vigGrad;
5057
5673
  ctx.fillRect(0, 0, width, height);
5058
- // ── 9. Organic connecting curves ───────────────────────────────
5674
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
5675
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5676
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5059
5677
  if (shapePositions.length > 1) {
5060
5678
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5679
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5061
5680
  ctx.lineWidth = 0.8 * scaleFactor;
5681
+ // Collect curves into 3 alpha buckets
5682
+ const CURVE_ALPHA_BUCKETS = 3;
5683
+ const curveBuckets = [];
5684
+ const curveColors = [];
5685
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5686
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5062
5687
  for(let i = 0; i < numCurves; i++){
5063
5688
  const idxA = Math.floor(rng() * shapePositions.length);
5064
5689
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
5065
5690
  const idxB = (idxA + offset) % shapePositions.length;
5066
5691
  const a = shapePositions[idxA];
5067
5692
  const b = shapePositions[idxB];
5068
- const mx = (a.x + b.x) / 2;
5069
- const my = (a.y + b.y) / 2;
5070
5693
  const dx = b.x - a.x;
5071
5694
  const dy = b.y - a.y;
5072
5695
  const dist = Math.hypot(dx, dy);
5696
+ // Skip connections between distant shapes
5697
+ if (dist > maxCurveDist) continue;
5698
+ const mx = (a.x + b.x) / 2;
5699
+ const my = (a.y + b.y) / 2;
5073
5700
  const bulge = (rng() - 0.5) * dist * 0.4;
5074
5701
  const cpx = mx + -dy / (dist || 1) * bulge;
5075
5702
  const cpy = my + dx / (dist || 1) * bulge;
5076
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5077
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5703
+ const curveAlpha = 0.06 + rng() * 0.1;
5704
+ const curveColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5705
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5706
+ curveBuckets[bi].push({
5707
+ ax: a.x,
5708
+ ay: a.y,
5709
+ cpx: cpx,
5710
+ cpy: cpy,
5711
+ bx: b.x,
5712
+ by: b.y
5713
+ });
5714
+ curveAlphas[bi] = curveAlpha;
5715
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5716
+ }
5717
+ // Render batched curves
5718
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5719
+ const curves = curveBuckets[bi];
5720
+ if (curves.length === 0) continue;
5721
+ ctx.globalAlpha = curveAlphas[bi];
5722
+ ctx.strokeStyle = curveColors[bi];
5078
5723
  ctx.beginPath();
5079
- ctx.moveTo(a.x, a.y);
5080
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5724
+ for(let j = 0; j < curves.length; j++){
5725
+ const c = curves[j];
5726
+ ctx.moveTo(c.ax, c.ay);
5727
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5728
+ }
5081
5729
  ctx.stroke();
5082
5730
  }
5083
5731
  }
@@ -5195,11 +5843,14 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5195
5843
  }
5196
5844
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5197
5845
  // Vine tendrils — organic curving lines along edges
5846
+ // Optimized: batch all tendrils into a single path
5198
5847
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5199
5848
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5200
5849
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5201
5850
  ctx.lineCap = "round";
5202
5851
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5852
+ ctx.beginPath();
5853
+ const leafPositions = [];
5203
5854
  for(let t = 0; t < tendrilCount; t++){
5204
5855
  // Start from a random edge point
5205
5856
  const edge = Math.floor(borderRng() * 4);
@@ -5217,7 +5868,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5217
5868
  tx = width - borderPad;
5218
5869
  ty = borderRng() * height;
5219
5870
  }
5220
- ctx.beginPath();
5221
5871
  ctx.moveTo(tx, ty);
5222
5872
  const segs = 3 + Math.floor(borderRng() * 4);
5223
5873
  for(let s = 0; s < segs; s++){
@@ -5231,14 +5881,23 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5231
5881
  ty = cpy3;
5232
5882
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5233
5883
  }
5234
- ctx.stroke();
5235
- // Small leaf/dot at tendril end
5236
- if (borderRng() < 0.6) {
5237
- ctx.beginPath();
5238
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5239
- ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5240
- ctx.fill();
5884
+ // Collect leaf positions for batch fill
5885
+ if (borderRng() < 0.6) leafPositions.push({
5886
+ x: tx,
5887
+ y: ty,
5888
+ r: borderPad * (0.15 + borderRng() * 0.2)
5889
+ });
5890
+ }
5891
+ ctx.stroke();
5892
+ // Batch all leaf dots into a single fill
5893
+ if (leafPositions.length > 0) {
5894
+ ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5895
+ ctx.beginPath();
5896
+ for (const leaf of leafPositions){
5897
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5898
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5241
5899
  }
5900
+ ctx.fill();
5242
5901
  }
5243
5902
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5244
5903
  // Star-studded arcs along edges
@@ -5253,8 +5912,9 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5253
5912
  ctx.beginPath();
5254
5913
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5255
5914
  ctx.stroke();
5256
- // Scatter small stars along the border region
5915
+ // Scatter small stars along the border region — batched into single path
5257
5916
  const starCount = 15 + Math.floor(borderRng() * 15);
5917
+ ctx.beginPath();
5258
5918
  for(let s = 0; s < starCount; s++){
5259
5919
  const edge = Math.floor(borderRng() * 4);
5260
5920
  let sx, sy;
@@ -5273,7 +5933,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5273
5933
  }
5274
5934
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5275
5935
  // 4-point star
5276
- ctx.beginPath();
5277
5936
  for(let p = 0; p < 8; p++){
5278
5937
  const a = p / 8 * Math.PI * 2;
5279
5938
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5283,8 +5942,8 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5283
5942
  else ctx.lineTo(px2, py2);
5284
5943
  }
5285
5944
  ctx.closePath();
5286
- ctx.fill();
5287
5945
  }
5946
+ ctx.fill();
5288
5947
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5289
5948
  // Thin single rule — understated elegance
5290
5949
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5295,13 +5954,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5295
5954
  // Other archetypes: no border (intentional — not every image needs one)
5296
5955
  ctx.restore();
5297
5956
  }
5298
- // ── 11. Signature mark — unique geometric chop from hash prefix ──
5957
+ // ── 11. Signature mark — placed in the least-dense corner ──────
5299
5958
  {
5300
5959
  const sigRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 42));
5301
5960
  const sigSize = Math.min(width, height) * 0.025;
5302
- // Bottom-right corner with padding
5303
- const sigX = width - sigSize * 2.5;
5304
- const sigY = height - sigSize * 2.5;
5961
+ const sigMargin = sigSize * 2.5;
5962
+ // Find the corner with the lowest local density
5963
+ const cornerCandidates = [
5964
+ {
5965
+ x: sigMargin,
5966
+ y: sigMargin
5967
+ },
5968
+ {
5969
+ x: width - sigMargin,
5970
+ y: sigMargin
5971
+ },
5972
+ {
5973
+ x: sigMargin,
5974
+ y: height - sigMargin
5975
+ },
5976
+ {
5977
+ x: width - sigMargin,
5978
+ y: height - sigMargin
5979
+ }
5980
+ ];
5981
+ let bestCorner = cornerCandidates[3]; // default: bottom-right
5982
+ let minDensity = Infinity;
5983
+ for (const corner of cornerCandidates){
5984
+ const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
5985
+ if (density < minDensity) {
5986
+ minDensity = density;
5987
+ bestCorner = corner;
5988
+ }
5989
+ }
5990
+ const sigX = bestCorner.x;
5991
+ const sigY = bestCorner.y;
5305
5992
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
5306
5993
  const sigColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.accent, 0.15);
5307
5994
  ctx.save();