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/main.js CHANGED
@@ -531,13 +531,21 @@ class $d016ad53434219a1$export$ab958c550f521376 {
531
531
  }
532
532
  }
533
533
  // ── Standalone color utilities ──────────────────────────────────────
534
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $d016ad53434219a1$var$hexToRgb(hex) {
535
- const c = hex.replace("#", "");
536
- return [
534
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
535
+ const $d016ad53434219a1$var$_rgbCache = new Map();
536
+ const $d016ad53434219a1$var$_RGB_CACHE_MAX = 512;
537
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $d016ad53434219a1$var$hexToRgb(hex) {
538
+ let cached = $d016ad53434219a1$var$_rgbCache.get(hex);
539
+ if (cached) return cached;
540
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
541
+ cached = [
537
542
  parseInt(c.substring(0, 2), 16),
538
543
  parseInt(c.substring(2, 4), 16),
539
544
  parseInt(c.substring(4, 6), 16)
540
545
  ];
546
+ if ($d016ad53434219a1$var$_rgbCache.size >= $d016ad53434219a1$var$_RGB_CACHE_MAX) $d016ad53434219a1$var$_rgbCache.clear();
547
+ $d016ad53434219a1$var$_rgbCache.set(hex, cached);
548
+ return cached;
541
549
  }
542
550
  /** Format [r, g, b] back to #RRGGBB. */ function $d016ad53434219a1$var$rgbToHex(r, g, b) {
543
551
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -594,7 +602,9 @@ class $d016ad53434219a1$export$ab958c550f521376 {
594
602
  }
595
603
  function $d016ad53434219a1$export$f2121afcad3d553f(hex, alpha) {
596
604
  const [r, g, b] = $d016ad53434219a1$var$hexToRgb(hex);
597
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
605
+ // Quantize alpha to 3 decimal places without toFixed overhead
606
+ const a = Math.round(alpha * 1000) / 1000;
607
+ return `rgba(${r},${g},${b},${a})`;
598
608
  }
599
609
  function $d016ad53434219a1$export$fabac4600b87056(colors, rng) {
600
610
  if (colors.length < 3) return {
@@ -603,15 +613,17 @@ function $d016ad53434219a1$export$fabac4600b87056(colors, rng) {
603
613
  accent: colors[colors.length - 1] || "#888888",
604
614
  all: colors
605
615
  };
606
- // Pick dominant as the color closest to the palette's average hue
616
+ // Pick dominant as the color with the highest chroma (saturation × distance from gray)
617
+ // This selects the most visually prominent color rather than the average
607
618
  const hsls = colors.map((c)=>$d016ad53434219a1$var$hexToHsl(c));
608
- const avgHue = hsls.reduce((s, h)=>s + h[0], 0) / hsls.length;
609
619
  let dominantIdx = 0;
610
- let minDist = 360;
620
+ let maxChroma = -1;
611
621
  for(let i = 0; i < hsls.length; i++){
612
- const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
613
- if (d < minDist) {
614
- minDist = d;
622
+ // Chroma approximation: saturation × how far lightness is from 50% (gray)
623
+ const lightnessVibrancy = 1 - Math.abs(hsls[i][2] - 0.5) * 2; // peaks at L=0.5
624
+ const chroma = hsls[i][1] * lightnessVibrancy;
625
+ if (chroma > maxChroma) {
626
+ maxChroma = chroma;
615
627
  dominantIdx = i;
616
628
  }
617
629
  }
@@ -672,12 +684,21 @@ function $d016ad53434219a1$export$51ea55f869b7e0d3(hex, target, amount) {
672
684
  const [h, s, l] = $d016ad53434219a1$var$hexToHsl(hex);
673
685
  return $d016ad53434219a1$var$hslToHex($d016ad53434219a1$var$shiftHueToward(h, target, amount), s, l);
674
686
  }
687
+ /**
688
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
689
+ * Uses the sRGB luminance formula from WCAG. Cached.
690
+ */ const $d016ad53434219a1$var$_lumCache = new Map();
675
691
  function $d016ad53434219a1$export$5c6e3c2b59b7fbbe(hex) {
692
+ let cached = $d016ad53434219a1$var$_lumCache.get(hex);
693
+ if (cached !== undefined) return cached;
676
694
  const [r, g, b] = $d016ad53434219a1$var$hexToRgb(hex).map((c)=>{
677
695
  const s = c / 255;
678
696
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
679
697
  });
680
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
698
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
699
+ if ($d016ad53434219a1$var$_lumCache.size >= 512) $d016ad53434219a1$var$_lumCache.clear();
700
+ $d016ad53434219a1$var$_lumCache.set(hex, cached);
701
+ return cached;
681
702
  }
682
703
  function $d016ad53434219a1$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
683
704
  const fgLum = $d016ad53434219a1$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1113,21 +1134,31 @@ const $4bf3d69be49ad55c$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1113
1134
  (0, $efc5b85ac9840d51$export$e46c5570db033611)(ctx, size, finalConfig);
1114
1135
  const gridSize = 8;
1115
1136
  const unit = size / gridSize;
1137
+ const radius = unit / 2;
1138
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1139
+ const starPoints = [];
1140
+ for(let k = 0; k < 8; k++){
1141
+ const angle = Math.PI / 4 * k;
1142
+ const angle2 = angle + Math.PI / 4;
1143
+ starPoints.push({
1144
+ c1: Math.cos(angle) * radius,
1145
+ s1: Math.sin(angle) * radius,
1146
+ c2: Math.cos(angle2) * radius,
1147
+ s2: Math.sin(angle2) * radius
1148
+ });
1149
+ }
1116
1150
  ctx.beginPath();
1117
1151
  // Create base grid
1118
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1152
+ for(let i = 0; i <= gridSize; i++){
1119
1153
  const x = (i - gridSize / 2) * unit;
1120
- const y = (j - gridSize / 2) * unit;
1121
- // Draw star pattern at each intersection
1122
- const radius = unit / 2;
1123
- for(let k = 0; k < 8; k++){
1124
- const angle = Math.PI / 4 * k;
1125
- const x1 = x + radius * Math.cos(angle);
1126
- const y1 = y + radius * Math.sin(angle);
1127
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1128
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1129
- ctx.moveTo(x1, y1);
1130
- ctx.lineTo(x2, y2);
1154
+ for(let j = 0; j <= gridSize; j++){
1155
+ const y = (j - gridSize / 2) * unit;
1156
+ // Draw star pattern at each intersection using pre-computed offsets
1157
+ for(let k = 0; k < 8; k++){
1158
+ const sp = starPoints[k];
1159
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1160
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1161
+ }
1131
1162
  }
1132
1163
  }
1133
1164
  ctx.stroke();
@@ -1447,20 +1478,23 @@ const $dd5df256f00f6199$export$eeae7765f05012e2 = (ctx, size)=>{
1447
1478
  const $dd5df256f00f6199$export$3355220a8108efc3 = (ctx, size)=>{
1448
1479
  const outerRadius = size / 2;
1449
1480
  const innerRadius = size / 4;
1450
- const steps = 36;
1481
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1482
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1483
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1484
+ const TWO_PI = Math.PI * 2;
1485
+ const angleStep = TWO_PI / steps;
1451
1486
  ctx.beginPath();
1452
1487
  for(let i = 0; i < steps; i++){
1453
- const angle1 = i / steps * Math.PI * 2;
1454
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1488
+ const angle1 = i * angleStep;
1489
+ const cosA = Math.cos(angle1);
1490
+ const sinA = Math.sin(angle1);
1455
1491
  for(let j = 0; j < steps; j++){
1456
- const phi1 = j / steps * Math.PI * 2;
1457
- const phi2 = (j + 1) / steps * Math.PI * 2;
1458
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1459
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1460
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1461
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1462
- ctx.moveTo(x1, y1);
1463
- ctx.lineTo(x2, y2);
1492
+ const phi1 = j * angleStep;
1493
+ const phi2 = phi1 + angleStep;
1494
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1495
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1496
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1497
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1464
1498
  }
1465
1499
  }
1466
1500
  };
@@ -2023,6 +2057,43 @@ const $c3de8257a8baa3b0$var$RENDER_STYLES = [
2023
2057
  function $c3de8257a8baa3b0$export$9fd4e64b2acd410e(rng) {
2024
2058
  return $c3de8257a8baa3b0$var$RENDER_STYLES[Math.floor(rng() * $c3de8257a8baa3b0$var$RENDER_STYLES.length)];
2025
2059
  }
2060
+ const $c3de8257a8baa3b0$export$2f738f61a8c15e07 = {
2061
+ "fill-and-stroke": 1,
2062
+ "fill-only": 0.5,
2063
+ "stroke-only": 1,
2064
+ "double-stroke": 1.5,
2065
+ "dashed": 1,
2066
+ "watercolor": 7,
2067
+ "hatched": 3,
2068
+ "incomplete": 1,
2069
+ "stipple": 90,
2070
+ "stencil": 2,
2071
+ "noise-grain": 400,
2072
+ "wood-grain": 10,
2073
+ "marble-vein": 4,
2074
+ "fabric-weave": 6,
2075
+ "hand-drawn": 5
2076
+ };
2077
+ function $c3de8257a8baa3b0$export$909ab0580e273f19(style) {
2078
+ switch(style){
2079
+ case "noise-grain":
2080
+ return "hatched";
2081
+ case "stipple":
2082
+ return "dashed";
2083
+ case "wood-grain":
2084
+ return "hatched";
2085
+ case "watercolor":
2086
+ return "fill-and-stroke";
2087
+ case "fabric-weave":
2088
+ return "hatched";
2089
+ case "hand-drawn":
2090
+ return "fill-and-stroke";
2091
+ case "marble-vein":
2092
+ return "stroke-only";
2093
+ default:
2094
+ return style;
2095
+ }
2096
+ }
2026
2097
  function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2027
2098
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2028
2099
  ctx.save();
@@ -2142,6 +2213,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2142
2213
  case "hatched":
2143
2214
  {
2144
2215
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2216
+ // Optimized: batch all parallel lines into a single path per pass
2145
2217
  const savedAlphaH = ctx.globalAlpha;
2146
2218
  ctx.globalAlpha = savedAlphaH * 0.3;
2147
2219
  ctx.fill();
@@ -2153,28 +2225,28 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2153
2225
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2154
2226
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2155
2227
  ctx.globalAlpha = savedAlphaH * 0.6;
2156
- // Draw parallel lines across the bounding box
2228
+ // Draw parallel lines across the bounding box — batched into single path
2157
2229
  const extent = size * 0.8;
2158
2230
  const cos = Math.cos(hatchAngle);
2159
2231
  const sin = Math.sin(hatchAngle);
2232
+ ctx.beginPath();
2160
2233
  for(let d = -extent; d <= extent; d += hatchSpacing){
2161
- ctx.beginPath();
2162
2234
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2163
2235
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2164
- ctx.stroke();
2165
2236
  }
2237
+ ctx.stroke();
2166
2238
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2167
2239
  if (!rng || rng() < 0.5) {
2168
2240
  const crossAngle = hatchAngle + Math.PI / 2;
2169
2241
  const cos2 = Math.cos(crossAngle);
2170
2242
  const sin2 = Math.sin(crossAngle);
2171
2243
  ctx.globalAlpha = savedAlphaH * 0.35;
2244
+ ctx.beginPath();
2172
2245
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2173
- ctx.beginPath();
2174
2246
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2175
2247
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2176
- ctx.stroke();
2177
2248
  }
2249
+ ctx.stroke();
2178
2250
  }
2179
2251
  ctx.restore();
2180
2252
  ctx.globalAlpha = savedAlphaH;
@@ -2212,6 +2284,8 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2212
2284
  case "stipple":
2213
2285
  {
2214
2286
  // Dot-fill texture — clip to shape, then scatter dots
2287
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2288
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2215
2289
  const savedAlphaS = ctx.globalAlpha;
2216
2290
  ctx.globalAlpha = savedAlphaS * 0.15;
2217
2291
  ctx.fill(); // ghost fill
@@ -2219,16 +2293,20 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2219
2293
  ctx.save();
2220
2294
  ctx.clip();
2221
2295
  const dotSpacing = Math.max(2, size * 0.03);
2222
- const extent = size * 0.55;
2296
+ const extentS = size * 0.55;
2297
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2298
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2299
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2223
2300
  ctx.globalAlpha = savedAlphaS * 0.7;
2224
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2225
- // Jitter each dot position for organic feel
2226
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2227
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2228
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2229
- ctx.beginPath();
2230
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2231
- ctx.fill();
2301
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2302
+ const dx = -extentS + xi * actualSpacing;
2303
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2304
+ const dy = -extentS + yi * actualSpacing;
2305
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2306
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2307
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2308
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2309
+ }
2232
2310
  }
2233
2311
  ctx.restore();
2234
2312
  ctx.globalAlpha = savedAlphaS;
@@ -2261,6 +2339,9 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2261
2339
  case "noise-grain":
2262
2340
  {
2263
2341
  // Procedural noise grain texture clipped to shape boundary
2342
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2343
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2344
+ // and batch dots by brightness (black/white) × alpha bucket
2264
2345
  const savedAlphaN = ctx.globalAlpha;
2265
2346
  ctx.globalAlpha = savedAlphaN * 0.25;
2266
2347
  ctx.fill(); // base tint
@@ -2269,17 +2350,47 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2269
2350
  ctx.clip();
2270
2351
  const grainSpacing = Math.max(1.5, size * 0.015);
2271
2352
  const extentN = size * 0.55;
2272
- ctx.globalAlpha = savedAlphaN * 0.6;
2273
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2274
- if (!rng) break;
2275
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2276
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2277
- const brightness = rng() > 0.5 ? 255 : 0;
2278
- const dotAlpha = 0.15 + rng() * 0.35;
2279
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2280
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2281
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2282
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2353
+ if (rng) {
2354
+ // Cap grid to max 40 dots per axis beyond this the grain is
2355
+ // visually indistinguishable but cost scales quadratically.
2356
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2357
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2358
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2359
+ const BUCKETS = 4;
2360
+ const bucketMin = 0.15;
2361
+ const bucketRange = 0.35;
2362
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2363
+ const buckets = [];
2364
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2365
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2366
+ const gx = -extentN + xi * actualGrainSpacing;
2367
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2368
+ const gy = -extentN + yi * actualGrainSpacing;
2369
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2370
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2371
+ const isWhite = rng() > 0.5;
2372
+ const dotAlpha = bucketMin + rng() * bucketRange;
2373
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2374
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2375
+ const offset = isWhite ? BUCKETS : 0;
2376
+ buckets[offset + bucketIdx].push({
2377
+ x: gx + jx,
2378
+ y: gy + jy,
2379
+ s: dotSize
2380
+ });
2381
+ }
2382
+ }
2383
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2384
+ for(let color = 0; color < 2; color++){
2385
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2386
+ for(let b = 0; b < BUCKETS; b++){
2387
+ const dots = buckets[color * BUCKETS + b];
2388
+ if (dots.length === 0) continue;
2389
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2390
+ ctx.globalAlpha = savedAlphaN * alpha;
2391
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2392
+ }
2393
+ }
2283
2394
  }
2284
2395
  ctx.restore();
2285
2396
  ctx.fillStyle = fillColor;
@@ -2292,6 +2403,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2292
2403
  case "wood-grain":
2293
2404
  {
2294
2405
  // Parallel wavy lines simulating wood grain, clipped to shape
2406
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2295
2407
  const savedAlphaW = ctx.globalAlpha;
2296
2408
  ctx.globalAlpha = savedAlphaW * 0.2;
2297
2409
  ctx.fill(); // base tint
@@ -2307,17 +2419,19 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2307
2419
  ctx.globalAlpha = savedAlphaW * 0.5;
2308
2420
  const cosG = Math.cos(grainAngle);
2309
2421
  const sinG = Math.sin(grainAngle);
2422
+ const waveCoeff = waveFreq * Math.PI;
2423
+ const invExtentW = 1 / extentW;
2424
+ // Batch all grain lines into a single path
2425
+ ctx.beginPath();
2310
2426
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2311
- ctx.beginPath();
2312
- for(let t = -extentW; t <= extentW; t += 2){
2313
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2314
- const px = t * cosG - (d + wave) * sinG;
2315
- const py = t * sinG + (d + wave) * cosG;
2316
- if (t === -extentW) ctx.moveTo(px, py);
2317
- else ctx.lineTo(px, py);
2427
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2428
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2429
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2430
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2431
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2318
2432
  }
2319
- ctx.stroke();
2320
2433
  }
2434
+ ctx.stroke();
2321
2435
  ctx.restore();
2322
2436
  ctx.globalAlpha = savedAlphaW;
2323
2437
  ctx.globalAlpha *= 0.35;
@@ -2379,6 +2493,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2379
2493
  case "fabric-weave":
2380
2494
  {
2381
2495
  // Interlocking horizontal/vertical threads clipped to shape
2496
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2382
2497
  const savedAlphaF = ctx.globalAlpha;
2383
2498
  ctx.globalAlpha = savedAlphaF * 0.15;
2384
2499
  ctx.fill(); // ghost base
@@ -2388,26 +2503,24 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2388
2503
  const threadSpacing = Math.max(2, size * 0.04);
2389
2504
  const extentF = size * 0.55;
2390
2505
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2506
+ // Horizontal threads — batched
2391
2507
  ctx.globalAlpha = savedAlphaF * 0.55;
2392
- // Horizontal threads
2508
+ ctx.beginPath();
2393
2509
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2394
- ctx.beginPath();
2395
2510
  ctx.moveTo(-extentF, y);
2396
2511
  ctx.lineTo(extentF, y);
2397
- ctx.stroke();
2398
2512
  }
2399
- // Vertical threads (offset by half spacing for weave effect)
2513
+ ctx.stroke();
2514
+ // Vertical threads (offset by half spacing for weave effect) — batched
2400
2515
  ctx.globalAlpha = savedAlphaF * 0.45;
2401
2516
  ctx.strokeStyle = fillColor;
2402
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2403
- ctx.beginPath();
2404
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2405
- // Over-under: draw segment, skip segment
2406
- ctx.moveTo(x, y);
2407
- ctx.lineTo(x, y + threadSpacing);
2408
- }
2409
- ctx.stroke();
2517
+ ctx.beginPath();
2518
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2519
+ // Over-under: draw segment, skip segment
2520
+ ctx.moveTo(x, y);
2521
+ ctx.lineTo(x, y + threadSpacing);
2410
2522
  }
2523
+ ctx.stroke();
2411
2524
  ctx.strokeStyle = strokeColor;
2412
2525
  ctx.restore();
2413
2526
  ctx.globalAlpha = savedAlphaF;
@@ -2473,14 +2586,17 @@ function $c3de8257a8baa3b0$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2473
2586
  ctx.translate(x, y);
2474
2587
  ctx.rotate(rotation * Math.PI / 180);
2475
2588
  // ── Drop shadow — soft colored shadow offset along light direction ──
2476
- if (lightAngle !== undefined && size > 10) {
2589
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2590
+ // and visually imperceptible at that scale.
2591
+ const useShadow = size >= 20;
2592
+ if (useShadow && lightAngle !== undefined) {
2477
2593
  const shadowDist = size * 0.035;
2478
2594
  const shadowBlurR = size * 0.06;
2479
2595
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2480
2596
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2481
2597
  ctx.shadowBlur = shadowBlurR;
2482
2598
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2483
- } else if (glowRadius > 0) {
2599
+ } else if (useShadow && glowRadius > 0) {
2484
2600
  // Glow / shadow effect (legacy path)
2485
2601
  ctx.shadowBlur = glowRadius;
2486
2602
  ctx.shadowColor = glowColor || fillColor;
@@ -2504,17 +2620,24 @@ function $c3de8257a8baa3b0$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2504
2620
  $c3de8257a8baa3b0$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2505
2621
  }
2506
2622
  // Reset shadow so patterns and highlight aren't double-shadowed
2507
- ctx.shadowBlur = 0;
2508
- ctx.shadowOffsetX = 0;
2509
- ctx.shadowOffsetY = 0;
2510
- ctx.shadowColor = "transparent";
2511
- // ── Specular highlight — bright arc on the light-facing side ──
2512
- if (lightAngle !== undefined && size > 15 && rng) {
2623
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2624
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2625
+ ctx.shadowBlur = 0;
2626
+ ctx.shadowOffsetX = 0;
2627
+ ctx.shadowOffsetY = 0;
2628
+ ctx.shadowColor = "transparent";
2629
+ }
2630
+ // ── Specular highlight — tinted arc on the light-facing side ──
2631
+ // Skip for small shapes (< 30px) — gradient creation + composite op
2632
+ // switch is expensive and the highlight is invisible at small sizes.
2633
+ if (lightAngle !== undefined && size > 30 && rng) {
2513
2634
  const hlRadius = size * 0.35;
2514
2635
  const hlDist = size * 0.15;
2515
2636
  const hlX = Math.cos(lightAngle) * hlDist;
2516
2637
  const hlY = Math.sin(lightAngle) * hlDist;
2517
2638
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2639
+ // Use a simple white highlight — the per-shape hex parse was expensive
2640
+ // and the visual difference from tinted highlights is negligible.
2518
2641
  hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2519
2642
  hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2520
2643
  hlGrad.addColorStop(1, "rgba(255,255,255,0)");
@@ -3579,6 +3702,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3579
3702
  "watercolor",
3580
3703
  "fill-only"
3581
3704
  ],
3705
+ preferredCompositions: [
3706
+ "clustered",
3707
+ "flow-field",
3708
+ "radial"
3709
+ ],
3582
3710
  flowLineMultiplier: 2.5,
3583
3711
  heroShape: false,
3584
3712
  glowMultiplier: 0.5,
@@ -3600,6 +3728,10 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3600
3728
  "stroke-only",
3601
3729
  "incomplete"
3602
3730
  ],
3731
+ preferredCompositions: [
3732
+ "golden-spiral",
3733
+ "grid-subdivision"
3734
+ ],
3603
3735
  flowLineMultiplier: 0.3,
3604
3736
  heroShape: true,
3605
3737
  glowMultiplier: 0,
@@ -3621,6 +3753,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3621
3753
  "fill-only",
3622
3754
  "incomplete"
3623
3755
  ],
3756
+ preferredCompositions: [
3757
+ "flow-field",
3758
+ "golden-spiral",
3759
+ "spiral"
3760
+ ],
3624
3761
  flowLineMultiplier: 4,
3625
3762
  heroShape: false,
3626
3763
  glowMultiplier: 0.3,
@@ -3643,6 +3780,10 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3643
3780
  "double-stroke",
3644
3781
  "hatched"
3645
3782
  ],
3783
+ preferredCompositions: [
3784
+ "grid-subdivision",
3785
+ "radial"
3786
+ ],
3646
3787
  flowLineMultiplier: 0,
3647
3788
  heroShape: false,
3648
3789
  glowMultiplier: 0,
@@ -3664,6 +3805,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3664
3805
  "incomplete",
3665
3806
  "fill-only"
3666
3807
  ],
3808
+ preferredCompositions: [
3809
+ "golden-spiral",
3810
+ "radial",
3811
+ "spiral"
3812
+ ],
3667
3813
  flowLineMultiplier: 1.5,
3668
3814
  heroShape: true,
3669
3815
  glowMultiplier: 2,
@@ -3684,6 +3830,10 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3684
3830
  "fill-and-stroke",
3685
3831
  "double-stroke"
3686
3832
  ],
3833
+ preferredCompositions: [
3834
+ "grid-subdivision",
3835
+ "golden-spiral"
3836
+ ],
3687
3837
  flowLineMultiplier: 0,
3688
3838
  heroShape: true,
3689
3839
  glowMultiplier: 0,
@@ -3705,6 +3855,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3705
3855
  "double-stroke",
3706
3856
  "dashed"
3707
3857
  ],
3858
+ preferredCompositions: [
3859
+ "radial",
3860
+ "spiral",
3861
+ "clustered"
3862
+ ],
3708
3863
  flowLineMultiplier: 2,
3709
3864
  heroShape: true,
3710
3865
  glowMultiplier: 3,
@@ -3727,6 +3882,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3727
3882
  "stroke-only",
3728
3883
  "dashed"
3729
3884
  ],
3885
+ preferredCompositions: [
3886
+ "flow-field",
3887
+ "grid-subdivision",
3888
+ "clustered"
3889
+ ],
3730
3890
  flowLineMultiplier: 1.5,
3731
3891
  heroShape: false,
3732
3892
  glowMultiplier: 0,
@@ -3748,6 +3908,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3748
3908
  "watercolor",
3749
3909
  "fill-and-stroke"
3750
3910
  ],
3911
+ preferredCompositions: [
3912
+ "radial",
3913
+ "spiral",
3914
+ "golden-spiral"
3915
+ ],
3751
3916
  flowLineMultiplier: 3,
3752
3917
  heroShape: true,
3753
3918
  glowMultiplier: 2.5,
@@ -3769,6 +3934,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3769
3934
  "fill-only",
3770
3935
  "incomplete"
3771
3936
  ],
3937
+ preferredCompositions: [
3938
+ "golden-spiral",
3939
+ "flow-field",
3940
+ "radial"
3941
+ ],
3772
3942
  flowLineMultiplier: 0.5,
3773
3943
  heroShape: false,
3774
3944
  glowMultiplier: 0.3,
@@ -3790,6 +3960,10 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3790
3960
  "stroke-only",
3791
3961
  "dashed"
3792
3962
  ],
3963
+ preferredCompositions: [
3964
+ "grid-subdivision",
3965
+ "radial"
3966
+ ],
3793
3967
  flowLineMultiplier: 0,
3794
3968
  heroShape: false,
3795
3969
  glowMultiplier: 0,
@@ -3811,6 +3985,10 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3811
3985
  "fill-only",
3812
3986
  "double-stroke"
3813
3987
  ],
3988
+ preferredCompositions: [
3989
+ "grid-subdivision",
3990
+ "clustered"
3991
+ ],
3814
3992
  flowLineMultiplier: 0,
3815
3993
  heroShape: true,
3816
3994
  glowMultiplier: 0,
@@ -3832,6 +4010,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3832
4010
  "watercolor",
3833
4011
  "fill-only"
3834
4012
  ],
4013
+ preferredCompositions: [
4014
+ "radial",
4015
+ "golden-spiral",
4016
+ "flow-field"
4017
+ ],
3835
4018
  flowLineMultiplier: 1,
3836
4019
  heroShape: true,
3837
4020
  glowMultiplier: 1,
@@ -3853,6 +4036,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3853
4036
  "stroke-only",
3854
4037
  "fill-only"
3855
4038
  ],
4039
+ preferredCompositions: [
4040
+ "clustered",
4041
+ "grid-subdivision",
4042
+ "radial"
4043
+ ],
3856
4044
  flowLineMultiplier: 0,
3857
4045
  heroShape: false,
3858
4046
  glowMultiplier: 0.3,
@@ -3874,6 +4062,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3874
4062
  "fill-only",
3875
4063
  "incomplete"
3876
4064
  ],
4065
+ preferredCompositions: [
4066
+ "flow-field",
4067
+ "golden-spiral",
4068
+ "spiral"
4069
+ ],
3877
4070
  flowLineMultiplier: 3,
3878
4071
  heroShape: true,
3879
4072
  glowMultiplier: 0.2,
@@ -3895,6 +4088,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3895
4088
  "fill-only",
3896
4089
  "hatched"
3897
4090
  ],
4091
+ preferredCompositions: [
4092
+ "radial",
4093
+ "clustered",
4094
+ "flow-field"
4095
+ ],
3898
4096
  flowLineMultiplier: 0,
3899
4097
  heroShape: false,
3900
4098
  glowMultiplier: 0,
@@ -3917,6 +4115,11 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3917
4115
  "stroke-only",
3918
4116
  "incomplete"
3919
4117
  ],
4118
+ preferredCompositions: [
4119
+ "spiral",
4120
+ "radial",
4121
+ "golden-spiral"
4122
+ ],
3920
4123
  flowLineMultiplier: 2,
3921
4124
  heroShape: true,
3922
4125
  glowMultiplier: 2.5,
@@ -3940,6 +4143,12 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3940
4143
  ...b.preferredStyles
3941
4144
  ])
3942
4145
  ];
4146
+ const mergedCompositions = [
4147
+ ...new Set([
4148
+ ...a.preferredCompositions,
4149
+ ...b.preferredCompositions
4150
+ ])
4151
+ ];
3943
4152
  return {
3944
4153
  name: `${a.name}+${b.name}`,
3945
4154
  gridSize: Math.round($f89bc858f7202849$var$lerpNum(a.gridSize, b.gridSize, t)),
@@ -3951,6 +4160,7 @@ const $f89bc858f7202849$var$ARCHETYPES = [
3951
4160
  backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
3952
4161
  paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
3953
4162
  preferredStyles: mergedStyles,
4163
+ preferredCompositions: mergedCompositions,
3954
4164
  flowLineMultiplier: $f89bc858f7202849$var$lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
3955
4165
  heroShape: t < 0.5 ? a.heroShape : b.heroShape,
3956
4166
  glowMultiplier: $f89bc858f7202849$var$lerpNum(a.glowMultiplier, b.glowMultiplier, t),
@@ -3972,6 +4182,46 @@ function $f89bc858f7202849$export$f1142fd7da4d6590(rng) {
3972
4182
  }
3973
4183
 
3974
4184
 
4185
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4186
+ // Based on benchmark measurements. Used by the complexity budget to
4187
+ // cap total rendering work and downgrade expensive styles when needed.
4188
+ const $4f72c5a314eddf25$var$RENDER_STYLE_COST = {
4189
+ "fill-and-stroke": 1,
4190
+ "fill-only": 0.5,
4191
+ "stroke-only": 1,
4192
+ "double-stroke": 1.5,
4193
+ "dashed": 1,
4194
+ "watercolor": 7,
4195
+ "hatched": 3,
4196
+ "incomplete": 1,
4197
+ "stipple": 90,
4198
+ "stencil": 2,
4199
+ "noise-grain": 400,
4200
+ "wood-grain": 10,
4201
+ "marble-vein": 4,
4202
+ "fabric-weave": 6,
4203
+ "hand-drawn": 5
4204
+ };
4205
+ function $4f72c5a314eddf25$var$downgradeRenderStyle(style) {
4206
+ switch(style){
4207
+ case "noise-grain":
4208
+ return "hatched";
4209
+ case "stipple":
4210
+ return "dashed";
4211
+ case "wood-grain":
4212
+ return "hatched";
4213
+ case "watercolor":
4214
+ return "fill-and-stroke";
4215
+ case "fabric-weave":
4216
+ return "hatched";
4217
+ case "hand-drawn":
4218
+ return "fill-and-stroke";
4219
+ case "marble-vein":
4220
+ return "stroke-only";
4221
+ default:
4222
+ return style;
4223
+ }
4224
+ }
3975
4225
  // ── Shape categories for weighted selection (legacy fallback) ───────
3976
4226
  const $4f72c5a314eddf25$var$SACRED_SHAPES = [
3977
4227
  "mandala",
@@ -3984,7 +4234,8 @@ const $4f72c5a314eddf25$var$SACRED_SHAPES = [
3984
4234
  "torus",
3985
4235
  "eggOfLife"
3986
4236
  ];
3987
- const $4f72c5a314eddf25$var$COMPOSITION_MODES = [
4237
+ // ── Composition modes ───────────────────────────────────────────────
4238
+ const $4f72c5a314eddf25$var$ALL_COMPOSITION_MODES = [
3988
4239
  "radial",
3989
4240
  "flow-field",
3990
4241
  "spiral",
@@ -4086,7 +4337,69 @@ function $4f72c5a314eddf25$var$isInVoidZone(x, y, voidZones) {
4086
4337
  }
4087
4338
  return false;
4088
4339
  }
4089
- // ── Helper: density check ───────────────────────────────────────────
4340
+ // ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
4341
+ class $4f72c5a314eddf25$var$SpatialGrid {
4342
+ cells;
4343
+ cellSize;
4344
+ constructor(cellSize){
4345
+ this.cells = new Map();
4346
+ this.cellSize = cellSize;
4347
+ }
4348
+ key(cx, cy) {
4349
+ return `${cx},${cy}`;
4350
+ }
4351
+ insert(item) {
4352
+ const cx = Math.floor(item.x / this.cellSize);
4353
+ const cy = Math.floor(item.y / this.cellSize);
4354
+ const k = this.key(cx, cy);
4355
+ const cell = this.cells.get(k);
4356
+ if (cell) cell.push(item);
4357
+ else this.cells.set(k, [
4358
+ item
4359
+ ]);
4360
+ }
4361
+ /** Count items within radius of (x, y) */ countNear(x, y, radius) {
4362
+ const r2 = radius * radius;
4363
+ const minCx = Math.floor((x - radius) / this.cellSize);
4364
+ const maxCx = Math.floor((x + radius) / this.cellSize);
4365
+ const minCy = Math.floor((y - radius) / this.cellSize);
4366
+ const maxCy = Math.floor((y + radius) / this.cellSize);
4367
+ let count = 0;
4368
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4369
+ const cell = this.cells.get(this.key(cx, cy));
4370
+ if (!cell) continue;
4371
+ for (const p of cell){
4372
+ const dx = x - p.x;
4373
+ const dy = y - p.y;
4374
+ if (dx * dx + dy * dy < r2) count++;
4375
+ }
4376
+ }
4377
+ return count;
4378
+ }
4379
+ /** Find nearest item to (x, y) */ findNearest(x, y, searchRadius) {
4380
+ const minCx = Math.floor((x - searchRadius) / this.cellSize);
4381
+ const maxCx = Math.floor((x + searchRadius) / this.cellSize);
4382
+ const minCy = Math.floor((y - searchRadius) / this.cellSize);
4383
+ const maxCy = Math.floor((y + searchRadius) / this.cellSize);
4384
+ let nearest = null;
4385
+ let bestDist2 = Infinity;
4386
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4387
+ const cell = this.cells.get(this.key(cx, cy));
4388
+ if (!cell) continue;
4389
+ for (const p of cell){
4390
+ const dx = x - p.x;
4391
+ const dy = y - p.y;
4392
+ const d2 = dx * dx + dy * dy;
4393
+ if (d2 > 0 && d2 < bestDist2) {
4394
+ bestDist2 = d2;
4395
+ nearest = p;
4396
+ }
4397
+ }
4398
+ }
4399
+ return nearest;
4400
+ }
4401
+ }
4402
+ // ── Helper: density check (legacy wrapper) ──────────────────────────
4090
4403
  function $4f72c5a314eddf25$var$localDensity(x, y, positions, radius) {
4091
4404
  let count = 0;
4092
4405
  for (const p of positions)if (Math.hypot(x - p.x, y - p.y) < radius) count++;
@@ -4385,42 +4698,43 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4385
4698
  const patternOpacity = 0.02 + rng() * 0.04;
4386
4699
  const patternColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4387
4700
  if (bgPatternRoll < 0.2) {
4388
- // Dot grid
4701
+ // Dot grid — batched into a single path
4389
4702
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4390
4703
  const dotR = dotSpacing * 0.08;
4391
4704
  ctx.globalAlpha = patternOpacity;
4392
4705
  ctx.fillStyle = patternColor;
4706
+ ctx.beginPath();
4393
4707
  for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4394
- ctx.beginPath();
4708
+ ctx.moveTo(px + dotR, py);
4395
4709
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
4396
- ctx.fill();
4397
4710
  }
4711
+ ctx.fill();
4398
4712
  } else if (bgPatternRoll < 0.4) {
4399
- // Diagonal lines
4713
+ // Diagonal lines — batched into a single path
4400
4714
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4401
4715
  ctx.globalAlpha = patternOpacity;
4402
4716
  ctx.strokeStyle = patternColor;
4403
4717
  ctx.lineWidth = 0.5 * scaleFactor;
4404
4718
  const diag = Math.hypot(width, height);
4719
+ ctx.beginPath();
4405
4720
  for(let d = -diag; d < diag; d += lineSpacing){
4406
- ctx.beginPath();
4407
4721
  ctx.moveTo(d, 0);
4408
4722
  ctx.lineTo(d + height, height);
4409
- ctx.stroke();
4410
4723
  }
4724
+ ctx.stroke();
4411
4725
  } else {
4412
- // Tessellation — hexagonal grid of tiny shapes
4726
+ // Tessellation — hexagonal grid, batched into a single path
4413
4727
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4414
4728
  const tessH = tessSize * Math.sqrt(3);
4415
4729
  ctx.globalAlpha = patternOpacity * 0.7;
4416
4730
  ctx.strokeStyle = patternColor;
4417
4731
  ctx.lineWidth = 0.4 * scaleFactor;
4732
+ ctx.beginPath();
4418
4733
  for(let row = 0; row * tessH < height + tessH; row++){
4419
4734
  const offsetX = row % 2 * tessSize * 0.75;
4420
4735
  for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4421
4736
  const hx = col * tessSize * 1.5 + offsetX;
4422
4737
  const hy = row * tessH;
4423
- ctx.beginPath();
4424
4738
  for(let s = 0; s < 6; s++){
4425
4739
  const angle = Math.PI / 3 * s - Math.PI / 6;
4426
4740
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -4429,18 +4743,18 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4429
4743
  else ctx.lineTo(vx, vy);
4430
4744
  }
4431
4745
  ctx.closePath();
4432
- ctx.stroke();
4433
4746
  }
4434
4747
  }
4748
+ ctx.stroke();
4435
4749
  }
4436
4750
  ctx.restore();
4437
4751
  }
4438
4752
  ctx.globalCompositeOperation = "source-over";
4439
- // ── 2. Composition mode ────────────────────────────────────────
4440
- const compositionMode = $4f72c5a314eddf25$var$COMPOSITION_MODES[Math.floor(rng() * $4f72c5a314eddf25$var$COMPOSITION_MODES.length)];
4753
+ // ── 2. Composition mode — archetype-aware selection ──────────────
4754
+ const compositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : $4f72c5a314eddf25$var$ALL_COMPOSITION_MODES[Math.floor(rng() * $4f72c5a314eddf25$var$ALL_COMPOSITION_MODES.length)];
4441
4755
  const symRoll = rng();
4442
4756
  const symmetryMode = symRoll < 0.10 ? "bilateral-x" : symRoll < 0.20 ? "bilateral-y" : symRoll < 0.25 ? "quad" : "none";
4443
- // ── 3. Focal points + void zones ───────────────────────────────
4757
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
4444
4758
  const THIRDS_POINTS = [
4445
4759
  {
4446
4760
  x: 1 / 3,
@@ -4473,9 +4787,23 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4473
4787
  y: height * (0.2 + rng() * 0.6),
4474
4788
  strength: 0.3 + rng() * 0.4
4475
4789
  });
4476
- const numVoids = Math.floor(rng() * 2) + 1;
4790
+ // Archetype-aware void zones: dense archetypes get fewer/no voids,
4791
+ // minimal archetypes get golden-ratio positioned voids
4792
+ const PHI = (1 + Math.sqrt(5)) / 2;
4793
+ const isMinimalArchetype = archetype.gridSize <= 3;
4794
+ const isDenseArchetype = archetype.gridSize >= 8;
4795
+ const numVoids = isDenseArchetype ? 0 : Math.floor(rng() * 2) + 1;
4477
4796
  const voidZones = [];
4478
- for(let v = 0; v < numVoids; v++)voidZones.push({
4797
+ for(let v = 0; v < numVoids; v++)if (isMinimalArchetype) {
4798
+ // Place voids at golden-ratio positions for intentional negative space
4799
+ const gx = v === 0 ? 1 / PHI : 1 - 1 / PHI;
4800
+ const gy = v === 0 ? 1 - 1 / PHI : 1 / PHI;
4801
+ voidZones.push({
4802
+ x: width * (gx + (rng() - 0.5) * 0.05),
4803
+ y: height * (gy + (rng() - 0.5) * 0.05),
4804
+ radius: Math.min(width, height) * (0.08 + rng() * 0.08)
4805
+ });
4806
+ } else voidZones.push({
4479
4807
  x: width * (0.15 + rng() * 0.7),
4480
4808
  y: height * (0.15 + rng() * 0.7),
4481
4809
  radius: Math.min(width, height) * (0.06 + rng() * 0.1)
@@ -4505,19 +4833,20 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4505
4833
  ctx.beginPath();
4506
4834
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4507
4835
  ctx.stroke();
4508
- // ~50% chance: scatter tiny dots inside the void
4836
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4509
4837
  if (rng() < 0.5) {
4510
4838
  const dotCount = 3 + Math.floor(rng() * 6);
4511
4839
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4512
4840
  ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4841
+ ctx.beginPath();
4513
4842
  for(let d = 0; d < dotCount; d++){
4514
4843
  const angle = rng() * Math.PI * 2;
4515
4844
  const dist = rng() * zone.radius * 0.7;
4516
4845
  const dotR = (1 + rng() * 3) * scaleFactor;
4517
- ctx.beginPath();
4846
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4518
4847
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4519
- ctx.fill();
4520
4848
  }
4849
+ ctx.fill();
4521
4850
  }
4522
4851
  // ~30% chance: thin concentric ring inside
4523
4852
  if (rng() < 0.3) {
@@ -4552,6 +4881,9 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4552
4881
  }
4553
4882
  // Track all placed shapes for density checks and connecting curves
4554
4883
  const shapePositions = [];
4884
+ // Spatial grid for O(1) density and nearest-neighbor lookups
4885
+ const densityCheckRadius = Math.min(width, height) * 0.08;
4886
+ const spatialGrid = new $4f72c5a314eddf25$var$SpatialGrid(densityCheckRadius);
4555
4887
  // Hero avoidance radius — shapes near the hero orient toward it
4556
4888
  let heroCenter = null;
4557
4889
  // ── 4b. Hero shape — a dominant focal element ───────────────────
@@ -4597,10 +4929,35 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4597
4929
  size: heroSize,
4598
4930
  shape: heroShape
4599
4931
  });
4932
+ spatialGrid.insert({
4933
+ x: heroFocal.x,
4934
+ y: heroFocal.y,
4935
+ size: heroSize,
4936
+ shape: heroShape
4937
+ });
4600
4938
  }
4601
4939
  // ── 5. Shape layers ────────────────────────────────────────────
4602
- const densityCheckRadius = Math.min(width, height) * 0.08;
4603
4940
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4941
+ // ── Complexity budget — caps total rendering work ──────────────
4942
+ // Budget scales with pixel area so larger canvases get proportionally
4943
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4944
+ // constellations, rhythm) are gated behind the budget; when it runs
4945
+ // low they are skipped. When it's exhausted, expensive render styles
4946
+ // are downgraded to cheaper alternatives.
4947
+ //
4948
+ // RNG values are always consumed even when skipping, so the
4949
+ // deterministic sequence for shapes that *do* render is preserved.
4950
+ const pixelArea = width * height;
4951
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4952
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4953
+ const totalBudget = complexityBudget;
4954
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4955
+ let extrasSpent = 0;
4956
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4957
+ // These generate O(size²) fillRect calls per shape and dominate
4958
+ // worst-case render time. Cap scales with pixel area.
4959
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4960
+ let clipHeavyCount = 0;
4604
4961
  for(let layer = 0; layer < layers; layer++){
4605
4962
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4606
4963
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4638,7 +4995,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4638
4995
  if ($4f72c5a314eddf25$var$isInVoidZone(x, y, voidZones)) {
4639
4996
  if (rng() < 0.85) continue;
4640
4997
  }
4641
- if ($4f72c5a314eddf25$var$localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
4998
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
4642
4999
  if (rng() < 0.6) continue;
4643
5000
  }
4644
5001
  // Power distribution for size — archetype controls the curve
@@ -4689,7 +5046,26 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4689
5046
  const shapeRenderStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4690
5047
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4691
5048
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4692
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5049
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5050
+ // Budget check: downgrade expensive styles proportionally —
5051
+ // the more expensive the style, the earlier it gets downgraded.
5052
+ // noise-grain (400) downgrades when budget < 20% remaining,
5053
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5054
+ let styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5055
+ if (styleCost > 3) {
5056
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5057
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5058
+ finalRenderStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(finalRenderStyle);
5059
+ styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5060
+ }
5061
+ }
5062
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5063
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5064
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5065
+ finalRenderStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(finalRenderStyle);
5066
+ styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5067
+ }
5068
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4693
5069
  // Consistent light direction — subtle shadow offset
4694
5070
  const shadowDist = hasGlow ? 0 : size * 0.02;
4695
5071
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4698,17 +5074,11 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4698
5074
  let finalX = x;
4699
5075
  let finalY = y;
4700
5076
  if (shapePositions.length > 0 && rng() < 0.25) {
4701
- // Find nearest placed shape
4702
- let nearestDist = Infinity;
4703
- let nearestPos = null;
4704
- for (const sp of shapePositions){
4705
- const d = Math.hypot(x - sp.x, y - sp.y);
4706
- if (d < nearestDist && d > 0) {
4707
- nearestDist = d;
4708
- nearestPos = sp;
4709
- }
4710
- }
5077
+ // Use spatial grid for O(1) nearest-neighbor lookup
5078
+ const searchRadius = adjustedMaxSize * 3;
5079
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
4711
5080
  if (nearestPos) {
5081
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
4712
5082
  // Target distance: edges kissing (sum of half-sizes)
4713
5083
  const targetDist = (size + nearestPos.size) * 0.5;
4714
5084
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -4750,30 +5120,41 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4750
5120
  lightAngle: lightAngle,
4751
5121
  scaleFactor: scaleFactor
4752
5122
  };
4753
- if (shouldMirror) (0, $c3de8257a8baa3b0$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4754
- ...shapeConfig,
4755
- mirrorAxis: mirrorAxis,
4756
- mirrorGap: size * (0.1 + rng() * 0.3)
4757
- });
4758
- else (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5123
+ if (shouldMirror) {
5124
+ (0, $c3de8257a8baa3b0$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5125
+ ...shapeConfig,
5126
+ mirrorAxis: mirrorAxis,
5127
+ mirrorGap: size * (0.1 + rng() * 0.3)
5128
+ });
5129
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5130
+ } else {
5131
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5132
+ complexityBudget -= styleCost;
5133
+ }
5134
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5135
+ const extrasAllowed = extrasSpent < budgetForExtras;
4759
5136
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4760
5137
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4761
5138
  const glazePasses = 2 + Math.floor(rng() * 2);
4762
- for(let g = 0; g < glazePasses; g++){
4763
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4764
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4765
- ctx.globalAlpha = glazeAlpha;
4766
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4767
- fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4768
- strokeColor: "rgba(0,0,0,0)",
4769
- strokeWidth: 0,
4770
- size: size * glazeScale,
4771
- rotation: rotation,
4772
- proportionType: "GOLDEN_RATIO",
4773
- renderStyle: "fill-only",
4774
- rng: rng
4775
- });
5139
+ if (extrasAllowed) {
5140
+ for(let g = 0; g < glazePasses; g++){
5141
+ const glazeScale = 1 - (g + 1) * 0.12;
5142
+ const glazeAlpha = 0.08 + g * 0.04;
5143
+ ctx.globalAlpha = glazeAlpha;
5144
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5145
+ fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5146
+ strokeColor: "rgba(0,0,0,0)",
5147
+ strokeWidth: 0,
5148
+ size: size * glazeScale,
5149
+ rotation: rotation,
5150
+ proportionType: "GOLDEN_RATIO",
5151
+ renderStyle: "fill-only",
5152
+ rng: rng
5153
+ });
5154
+ }
5155
+ extrasSpent += glazePasses;
4776
5156
  }
5157
+ // RNG consumed by glazePasses calculation above regardless
4777
5158
  }
4778
5159
  shapePositions.push({
4779
5160
  x: finalX,
@@ -4781,35 +5162,51 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4781
5162
  size: size,
4782
5163
  shape: shape
4783
5164
  });
5165
+ spatialGrid.insert({
5166
+ x: finalX,
5167
+ y: finalY,
5168
+ size: size,
5169
+ shape: shape
5170
+ });
4784
5171
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
4785
5172
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4786
5173
  const echoCount = 2 + Math.floor(rng() * 2);
4787
5174
  const echoAngle = rng() * Math.PI * 2;
4788
- for(let e = 0; e < echoCount; e++){
4789
- const echoScale = 0.3 - e * 0.08;
4790
- const echoDist = size * (0.6 + e * 0.4);
4791
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4792
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4793
- const echoSize = size * Math.max(0.1, echoScale);
4794
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4795
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4796
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4797
- fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4798
- strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.4),
4799
- strokeWidth: strokeWidth * 0.6,
4800
- size: echoSize,
4801
- rotation: rotation + (e + 1) * 15,
4802
- proportionType: "GOLDEN_RATIO",
4803
- renderStyle: finalRenderStyle,
4804
- rng: rng
4805
- });
4806
- shapePositions.push({
4807
- x: echoX,
4808
- y: echoY,
4809
- size: echoSize,
4810
- shape: shape
4811
- });
5175
+ if (extrasAllowed) {
5176
+ for(let e = 0; e < echoCount; e++){
5177
+ const echoScale = 0.3 - e * 0.08;
5178
+ const echoDist = size * (0.6 + e * 0.4);
5179
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5180
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5181
+ const echoSize = size * Math.max(0.1, echoScale);
5182
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5183
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5184
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5185
+ fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5186
+ strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.4),
5187
+ strokeWidth: strokeWidth * 0.6,
5188
+ size: echoSize,
5189
+ rotation: rotation + (e + 1) * 15,
5190
+ proportionType: "GOLDEN_RATIO",
5191
+ renderStyle: finalRenderStyle,
5192
+ rng: rng
5193
+ });
5194
+ shapePositions.push({
5195
+ x: echoX,
5196
+ y: echoY,
5197
+ size: echoSize,
5198
+ shape: shape
5199
+ });
5200
+ spatialGrid.insert({
5201
+ x: echoX,
5202
+ y: echoY,
5203
+ size: echoSize,
5204
+ shape: shape
5205
+ });
5206
+ }
5207
+ extrasSpent += echoCount * styleCost;
4812
5208
  }
5209
+ // RNG for echoCount + echoAngle consumed above regardless
4813
5210
  }
4814
5211
  // ── 5d. Recursive nesting ──────────────────────────────────
4815
5212
  // Focal depth: shapes near focal points get more detail
@@ -4817,7 +5214,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4817
5214
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4818
5215
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4819
5216
  const innerCount = 1 + Math.floor(rng() * 3);
4820
- for(let n = 0; n < innerCount; n++){
5217
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4821
5218
  // Pick inner shape from palette affinities
4822
5219
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
4823
5220
  const innerShape = (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -4826,6 +5223,10 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4826
5223
  const innerOffY = (rng() - 0.5) * size * 0.4;
4827
5224
  const innerRot = rng() * 360;
4828
5225
  const innerFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5226
+ let innerStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5227
+ // Apply clip-heavy cap to nested shapes too
5228
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(innerStyle);
5229
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
4829
5230
  ctx.globalAlpha = layerOpacity * 0.7;
4830
5231
  (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
4831
5232
  fillColor: innerFill,
@@ -4834,9 +5235,21 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4834
5235
  size: innerSize,
4835
5236
  rotation: innerRot,
4836
5237
  proportionType: "GOLDEN_RATIO",
4837
- renderStyle: (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5238
+ renderStyle: innerStyle,
4838
5239
  rng: rng
4839
5240
  });
5241
+ extrasSpent += $4f72c5a314eddf25$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5242
+ }
5243
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5244
+ for(let n = 0; n < innerCount; n++){
5245
+ rng();
5246
+ rng();
5247
+ rng();
5248
+ rng();
5249
+ rng();
5250
+ rng();
5251
+ rng();
5252
+ rng();
4840
5253
  }
4841
5254
  }
4842
5255
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -4845,41 +5258,113 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4845
5258
  const constellation = $4f72c5a314eddf25$var$CONSTELLATIONS[Math.floor(rng() * $4f72c5a314eddf25$var$CONSTELLATIONS.length)];
4846
5259
  const members = constellation.build(rng, size);
4847
5260
  const groupRotation = rng() * Math.PI * 2;
4848
- const cosR = Math.cos(groupRotation);
4849
- const sinR = Math.sin(groupRotation);
4850
- for (const member of members){
4851
- // Rotate the group offset by the group rotation
4852
- const mx = finalX + member.dx * cosR - member.dy * sinR;
4853
- const my = finalY + member.dx * sinR + member.dy * cosR;
4854
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
4855
- const memberFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
4856
- const memberStroke = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
4857
- ctx.globalAlpha = layerOpacity * 0.6;
4858
- // Use the member's shape if available, otherwise fall back to palette
4859
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
4860
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
4861
- fillColor: memberFill,
4862
- strokeColor: memberStroke,
4863
- strokeWidth: strokeWidth * 0.7,
4864
- size: member.size,
4865
- rotation: member.rotation + groupRotation * 180 / Math.PI,
4866
- proportionType: "GOLDEN_RATIO",
4867
- renderStyle: (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
4868
- rng: rng
4869
- });
4870
- shapePositions.push({
4871
- x: mx,
4872
- y: my,
4873
- size: member.size,
4874
- shape: memberShape
4875
- });
5261
+ if (extrasAllowed) {
5262
+ const cosR = Math.cos(groupRotation);
5263
+ const sinR = Math.sin(groupRotation);
5264
+ for (const member of members){
5265
+ // Rotate the group offset by the group rotation
5266
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5267
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5268
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5269
+ const memberFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5270
+ const memberStroke = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5271
+ ctx.globalAlpha = layerOpacity * 0.6;
5272
+ // Use the member's shape if available, otherwise fall back to palette
5273
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5274
+ let memberStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5275
+ // Apply clip-heavy cap to constellation members too
5276
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(memberStyle);
5277
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5278
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5279
+ fillColor: memberFill,
5280
+ strokeColor: memberStroke,
5281
+ strokeWidth: strokeWidth * 0.7,
5282
+ size: member.size,
5283
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5284
+ proportionType: "GOLDEN_RATIO",
5285
+ renderStyle: memberStyle,
5286
+ rng: rng
5287
+ });
5288
+ shapePositions.push({
5289
+ x: mx,
5290
+ y: my,
5291
+ size: member.size,
5292
+ shape: memberShape
5293
+ });
5294
+ spatialGrid.insert({
5295
+ x: mx,
5296
+ y: my,
5297
+ size: member.size,
5298
+ shape: memberShape
5299
+ });
5300
+ extrasSpent += $4f72c5a314eddf25$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5301
+ }
5302
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5303
+ for(let m = 0; m < members.length; m++){
5304
+ rng();
5305
+ rng();
5306
+ rng();
5307
+ rng();
5308
+ rng();
5309
+ rng();
5310
+ }
5311
+ }
5312
+ // ── 5f. Rhythm placement — deliberate geometric progressions ──
5313
+ // ~12% of medium-large shapes spawn a rhythmic sequence
5314
+ if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
5315
+ const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
5316
+ const rhythmAngle = rng() * Math.PI * 2;
5317
+ const rhythmSpacing = size * (0.8 + rng() * 0.6);
5318
+ const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5319
+ const rhythmShape = shape; // same shape for visual rhythm
5320
+ if (extrasAllowed) {
5321
+ let rhythmSize = size * 0.6;
5322
+ for(let r = 0; r < rhythmCount; r++){
5323
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5324
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5325
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5326
+ if ($4f72c5a314eddf25$var$isInVoidZone(rx, ry, voidZones)) break;
5327
+ rhythmSize *= rhythmDecay;
5328
+ if (rhythmSize < adjustedMinSize) break;
5329
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5330
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5331
+ const rhythmFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5332
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5333
+ fillColor: rhythmFill,
5334
+ strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.5),
5335
+ strokeWidth: strokeWidth * 0.7,
5336
+ size: rhythmSize,
5337
+ rotation: rotation + (r + 1) * 12,
5338
+ proportionType: "GOLDEN_RATIO",
5339
+ renderStyle: finalRenderStyle,
5340
+ rng: rng
5341
+ });
5342
+ shapePositions.push({
5343
+ x: rx,
5344
+ y: ry,
5345
+ size: rhythmSize,
5346
+ shape: rhythmShape
5347
+ });
5348
+ spatialGrid.insert({
5349
+ x: rx,
5350
+ y: ry,
5351
+ size: rhythmSize,
5352
+ shape: rhythmShape
5353
+ });
5354
+ }
5355
+ extrasSpent += rhythmCount * styleCost;
5356
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5357
+ for(let r = 0; r < rhythmCount; r++){
5358
+ rng();
5359
+ rng();
5360
+ rng();
4876
5361
  }
4877
5362
  }
4878
5363
  }
4879
5364
  }
4880
5365
  // Reset blend mode for post-processing passes
4881
5366
  ctx.globalCompositeOperation = "source-over";
4882
- // ── 5f. Layered masking / cutout portals ───────────────────────
5367
+ // ── 5g. Layered masking / cutout portals ───────────────────────
4883
5368
  // ~18% of images get 1-3 portal windows that paint over foreground
4884
5369
  // with a tinted background wash, creating a "peek through" effect.
4885
5370
  if (rng() < 0.18 && shapePositions.length > 3) {
@@ -4938,14 +5423,26 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4938
5423
  }
4939
5424
  }
4940
5425
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5426
+ // Optimized: collect all segments into width-quantized buckets, then
5427
+ // render each bucket as a single batched path. This reduces
5428
+ // beginPath/stroke calls from O(segments) to O(buckets).
4941
5429
  const baseFlowLines = 6 + Math.floor(rng() * 10);
4942
5430
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5431
+ // Width buckets — 6 buckets cover the taper×pressure range
5432
+ const FLOW_WIDTH_BUCKETS = 6;
5433
+ const flowBuckets = [];
5434
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5435
+ // Track the representative width for each bucket
5436
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5437
+ // Pre-compute max possible width for bucket assignment
5438
+ let globalMaxFlowWidth = 0;
4943
5439
  for(let i = 0; i < numFlowLines; i++){
4944
5440
  let fx = rng() * width;
4945
5441
  let fy = rng() * height;
4946
5442
  const steps = 30 + Math.floor(rng() * 40);
4947
5443
  const stepLen = (3 + rng() * 5) * scaleFactor;
4948
5444
  const startWidth = (1 + rng() * 3) * scaleFactor;
5445
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
4949
5446
  // Variable color: interpolate between two hierarchy colors along the stroke
4950
5447
  const lineColorStart = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
4951
5448
  const lineColorEnd = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -4960,20 +5457,29 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4960
5457
  fx += Math.cos(angle) * stepLen;
4961
5458
  fy += Math.sin(angle) * stepLen;
4962
5459
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
5460
+ // Skip segments that pass through void zones
5461
+ if ($4f72c5a314eddf25$var$isInVoidZone(fx, fy, voidZones)) {
5462
+ prevX = fx;
5463
+ prevY = fy;
5464
+ continue;
5465
+ }
4963
5466
  const t = s / steps;
4964
- // Taper + pressure
4965
5467
  const taper = 1 - t * 0.8;
4966
5468
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
4967
- ctx.globalAlpha = lineAlpha * taper;
4968
- // Interpolate color along stroke
5469
+ const segWidth = startWidth * taper * pressure;
5470
+ const segAlpha = lineAlpha * taper;
4969
5471
  const lineColor = t < 0.5 ? (0, $d016ad53434219a1$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $d016ad53434219a1$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
4970
- ctx.strokeStyle = lineColor;
4971
- ctx.lineWidth = startWidth * taper * pressure;
4972
- ctx.lineCap = "round";
4973
- ctx.beginPath();
4974
- ctx.moveTo(prevX, prevY);
4975
- ctx.lineTo(fx, fy);
4976
- ctx.stroke();
5472
+ // Quantize width into bucket
5473
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5474
+ flowBuckets[bucketIdx].push({
5475
+ x1: prevX,
5476
+ y1: prevY,
5477
+ x2: fx,
5478
+ y2: fy,
5479
+ color: lineColor,
5480
+ alpha: segAlpha
5481
+ });
5482
+ flowBucketWidths[bucketIdx] = segWidth;
4977
5483
  // Branching: ~12% chance per step to spawn a thinner child stroke
4978
5484
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
4979
5485
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -4989,12 +5495,18 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4989
5495
  by += Math.sin(bAngle) * stepLen * 0.8;
4990
5496
  if (bx < 0 || bx > width || by < 0 || by > height) break;
4991
5497
  const bTaper = 1 - bs / branchSteps * 0.9;
4992
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
4993
- ctx.lineWidth = branchWidth * bTaper;
4994
- ctx.beginPath();
4995
- ctx.moveTo(bPrevX, bPrevY);
4996
- ctx.lineTo(bx, by);
4997
- ctx.stroke();
5498
+ const bSegWidth = branchWidth * bTaper;
5499
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5500
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5501
+ flowBuckets[bBucket].push({
5502
+ x1: bPrevX,
5503
+ y1: bPrevY,
5504
+ x2: bx,
5505
+ y2: by,
5506
+ color: lineColor,
5507
+ alpha: bAlpha
5508
+ });
5509
+ flowBucketWidths[bBucket] = bSegWidth;
4998
5510
  bPrevX = bx;
4999
5511
  bPrevY = by;
5000
5512
  }
@@ -5003,7 +5515,40 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5003
5515
  prevY = fy;
5004
5516
  }
5005
5517
  }
5518
+ // Render flow line buckets — one batched path per width bucket
5519
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5520
+ ctx.lineCap = "round";
5521
+ const FLOW_ALPHA_BUCKETS = 4;
5522
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5523
+ const segs = flowBuckets[wb];
5524
+ if (segs.length === 0) continue;
5525
+ ctx.lineWidth = flowBucketWidths[wb];
5526
+ // Sub-bucket by alpha
5527
+ const alphaSubs = [];
5528
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5529
+ let maxAlpha = 0;
5530
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5531
+ for(let j = 0; j < segs.length; j++){
5532
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5533
+ alphaSubs[ai].push(segs[j]);
5534
+ }
5535
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5536
+ const sub = alphaSubs[ai];
5537
+ if (sub.length === 0) continue;
5538
+ // Use the median segment's alpha and color as representative
5539
+ const rep = sub[Math.floor(sub.length / 2)];
5540
+ ctx.globalAlpha = rep.alpha;
5541
+ ctx.strokeStyle = rep.color;
5542
+ ctx.beginPath();
5543
+ for(let j = 0; j < sub.length; j++){
5544
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5545
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5546
+ }
5547
+ ctx.stroke();
5548
+ }
5549
+ }
5006
5550
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5551
+ // Optimized: collect all burst segments, then batch by quantized alpha
5007
5552
  const energyArchetypes = [
5008
5553
  "dense-chaotic",
5009
5554
  "cosmic",
@@ -5014,8 +5559,12 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5014
5559
  if (hasEnergyLines && shapePositions.length > 0) {
5015
5560
  const energyCount = 5 + Math.floor(rng() * 10);
5016
5561
  ctx.lineCap = "round";
5562
+ // Collect all energy segments with their computed state
5563
+ const ENERGY_ALPHA_BUCKETS = 3;
5564
+ const energyBuckets = [];
5565
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5566
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5017
5567
  for(let e = 0; e < energyCount; e++){
5018
- // Pick a random shape to radiate from
5019
5568
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5020
5569
  const burstCount = 2 + Math.floor(rng() * 4);
5021
5570
  const baseAngle = flowAngle(source.x, source.y);
@@ -5027,14 +5576,37 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5027
5576
  const sy = source.y + Math.sin(angle) * startDist;
5028
5577
  const ex = sx + Math.cos(angle) * lineLen;
5029
5578
  const ey = sy + Math.sin(angle) * lineLen;
5030
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5031
- ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5032
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5033
- ctx.beginPath();
5034
- ctx.moveTo(sx, sy);
5035
- ctx.lineTo(ex, ey);
5036
- ctx.stroke();
5579
+ const eAlpha = 0.04 + rng() * 0.06;
5580
+ const eColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5581
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5582
+ // Quantize alpha into bucket
5583
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5584
+ energyBuckets[bi].push({
5585
+ x1: sx,
5586
+ y1: sy,
5587
+ x2: ex,
5588
+ y2: ey,
5589
+ color: eColor,
5590
+ lw: eLw
5591
+ });
5592
+ energyAlphas[bi] = eAlpha;
5593
+ }
5594
+ }
5595
+ // Render batched energy lines
5596
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5597
+ const segs = energyBuckets[bi];
5598
+ if (segs.length === 0) continue;
5599
+ ctx.globalAlpha = energyAlphas[bi];
5600
+ // Use median segment's color and width as representative
5601
+ const rep = segs[Math.floor(segs.length / 2)];
5602
+ ctx.strokeStyle = rep.color;
5603
+ ctx.lineWidth = rep.lw;
5604
+ ctx.beginPath();
5605
+ for(let j = 0; j < segs.length; j++){
5606
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5607
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5037
5608
  }
5609
+ ctx.stroke();
5038
5610
  }
5039
5611
  }
5040
5612
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
@@ -5057,50 +5629,128 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5057
5629
  }
5058
5630
  ctx.restore();
5059
5631
  }
5060
- // ── 7. Noise texture overlay ───────────────────────────────────
5632
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
5633
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
5634
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
5061
5635
  const noiseRng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash, 777));
5062
- const noiseDensity = Math.floor(width * height / 800);
5063
- for(let i = 0; i < noiseDensity; i++){
5064
- const nx = noiseRng() * width;
5065
- const ny = noiseRng() * height;
5066
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5067
- const alpha = 0.01 + noiseRng() * 0.03;
5068
- ctx.globalAlpha = alpha;
5069
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5070
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5636
+ const rawNoiseDensity = Math.floor(width * height / 800);
5637
+ // Cap at 2500 dots beyond this the visual effect is indistinguishable
5638
+ // but getImageData/putImageData cost scales with canvas size
5639
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5640
+ try {
5641
+ const imageData = ctx.getImageData(0, 0, width, height);
5642
+ const data = imageData.data;
5643
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5644
+ if (pixelScale === 1) // Fast path — no inner loop, direct pixel write
5645
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
5646
+ for(let i = 0; i < noiseDensity; i++){
5647
+ const nx = Math.floor(noiseRng() * width);
5648
+ const ny = Math.floor(noiseRng() * height);
5649
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5650
+ // srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
5651
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5652
+ const invA256 = 256 - srcA256;
5653
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
5654
+ const idx = ny * width + nx << 2;
5655
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5656
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5657
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5658
+ }
5659
+ else for(let i = 0; i < noiseDensity; i++){
5660
+ const nx = Math.floor(noiseRng() * width);
5661
+ const ny = Math.floor(noiseRng() * height);
5662
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5663
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5664
+ const invA256 = 256 - srcA256;
5665
+ const bSrc = brightness * srcA256;
5666
+ for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5667
+ const idx = (ny + dy) * width + (nx + dx) << 2;
5668
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5669
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5670
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5671
+ }
5672
+ }
5673
+ ctx.putImageData(imageData, 0, 0);
5674
+ } catch {
5675
+ // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5676
+ for(let i = 0; i < noiseDensity; i++){
5677
+ const nx = noiseRng() * width;
5678
+ const ny = noiseRng() * height;
5679
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5680
+ const alpha = 0.01 + noiseRng() * 0.03;
5681
+ ctx.globalAlpha = alpha;
5682
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5683
+ ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5684
+ }
5071
5685
  }
5072
5686
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5073
5687
  ctx.globalAlpha = 1;
5074
5688
  const vignetteStrength = 0.25 + rng() * 0.2;
5075
5689
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
5690
+ // Tint vignette based on background: warm sepia for light, cool blue for dark
5691
+ const isLightBg = bgLum > 0.5;
5692
+ const vignetteColor = isLightBg ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
5693
+ : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
5076
5694
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
5077
5695
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
5078
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
5696
+ vigGrad.addColorStop(1, vignetteColor);
5079
5697
  ctx.fillStyle = vigGrad;
5080
5698
  ctx.fillRect(0, 0, width, height);
5081
- // ── 9. Organic connecting curves ───────────────────────────────
5699
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
5700
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5701
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5082
5702
  if (shapePositions.length > 1) {
5083
5703
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5704
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5084
5705
  ctx.lineWidth = 0.8 * scaleFactor;
5706
+ // Collect curves into 3 alpha buckets
5707
+ const CURVE_ALPHA_BUCKETS = 3;
5708
+ const curveBuckets = [];
5709
+ const curveColors = [];
5710
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5711
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5085
5712
  for(let i = 0; i < numCurves; i++){
5086
5713
  const idxA = Math.floor(rng() * shapePositions.length);
5087
5714
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
5088
5715
  const idxB = (idxA + offset) % shapePositions.length;
5089
5716
  const a = shapePositions[idxA];
5090
5717
  const b = shapePositions[idxB];
5091
- const mx = (a.x + b.x) / 2;
5092
- const my = (a.y + b.y) / 2;
5093
5718
  const dx = b.x - a.x;
5094
5719
  const dy = b.y - a.y;
5095
5720
  const dist = Math.hypot(dx, dy);
5721
+ // Skip connections between distant shapes
5722
+ if (dist > maxCurveDist) continue;
5723
+ const mx = (a.x + b.x) / 2;
5724
+ const my = (a.y + b.y) / 2;
5096
5725
  const bulge = (rng() - 0.5) * dist * 0.4;
5097
5726
  const cpx = mx + -dy / (dist || 1) * bulge;
5098
5727
  const cpy = my + dx / (dist || 1) * bulge;
5099
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5100
- ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5728
+ const curveAlpha = 0.06 + rng() * 0.1;
5729
+ const curveColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5730
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5731
+ curveBuckets[bi].push({
5732
+ ax: a.x,
5733
+ ay: a.y,
5734
+ cpx: cpx,
5735
+ cpy: cpy,
5736
+ bx: b.x,
5737
+ by: b.y
5738
+ });
5739
+ curveAlphas[bi] = curveAlpha;
5740
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5741
+ }
5742
+ // Render batched curves
5743
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5744
+ const curves = curveBuckets[bi];
5745
+ if (curves.length === 0) continue;
5746
+ ctx.globalAlpha = curveAlphas[bi];
5747
+ ctx.strokeStyle = curveColors[bi];
5101
5748
  ctx.beginPath();
5102
- ctx.moveTo(a.x, a.y);
5103
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5749
+ for(let j = 0; j < curves.length; j++){
5750
+ const c = curves[j];
5751
+ ctx.moveTo(c.ax, c.ay);
5752
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5753
+ }
5104
5754
  ctx.stroke();
5105
5755
  }
5106
5756
  }
@@ -5218,11 +5868,14 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5218
5868
  }
5219
5869
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5220
5870
  // Vine tendrils — organic curving lines along edges
5871
+ // Optimized: batch all tendrils into a single path
5221
5872
  ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5222
5873
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5223
5874
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5224
5875
  ctx.lineCap = "round";
5225
5876
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5877
+ ctx.beginPath();
5878
+ const leafPositions = [];
5226
5879
  for(let t = 0; t < tendrilCount; t++){
5227
5880
  // Start from a random edge point
5228
5881
  const edge = Math.floor(borderRng() * 4);
@@ -5240,7 +5893,6 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5240
5893
  tx = width - borderPad;
5241
5894
  ty = borderRng() * height;
5242
5895
  }
5243
- ctx.beginPath();
5244
5896
  ctx.moveTo(tx, ty);
5245
5897
  const segs = 3 + Math.floor(borderRng() * 4);
5246
5898
  for(let s = 0; s < segs; s++){
@@ -5254,14 +5906,23 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5254
5906
  ty = cpy3;
5255
5907
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5256
5908
  }
5257
- ctx.stroke();
5258
- // Small leaf/dot at tendril end
5259
- if (borderRng() < 0.6) {
5260
- ctx.beginPath();
5261
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5262
- ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5263
- ctx.fill();
5909
+ // Collect leaf positions for batch fill
5910
+ if (borderRng() < 0.6) leafPositions.push({
5911
+ x: tx,
5912
+ y: ty,
5913
+ r: borderPad * (0.15 + borderRng() * 0.2)
5914
+ });
5915
+ }
5916
+ ctx.stroke();
5917
+ // Batch all leaf dots into a single fill
5918
+ if (leafPositions.length > 0) {
5919
+ ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5920
+ ctx.beginPath();
5921
+ for (const leaf of leafPositions){
5922
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5923
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5264
5924
  }
5925
+ ctx.fill();
5265
5926
  }
5266
5927
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5267
5928
  // Star-studded arcs along edges
@@ -5276,8 +5937,9 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5276
5937
  ctx.beginPath();
5277
5938
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5278
5939
  ctx.stroke();
5279
- // Scatter small stars along the border region
5940
+ // Scatter small stars along the border region — batched into single path
5280
5941
  const starCount = 15 + Math.floor(borderRng() * 15);
5942
+ ctx.beginPath();
5281
5943
  for(let s = 0; s < starCount; s++){
5282
5944
  const edge = Math.floor(borderRng() * 4);
5283
5945
  let sx, sy;
@@ -5296,7 +5958,6 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5296
5958
  }
5297
5959
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5298
5960
  // 4-point star
5299
- ctx.beginPath();
5300
5961
  for(let p = 0; p < 8; p++){
5301
5962
  const a = p / 8 * Math.PI * 2;
5302
5963
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5306,8 +5967,8 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5306
5967
  else ctx.lineTo(px2, py2);
5307
5968
  }
5308
5969
  ctx.closePath();
5309
- ctx.fill();
5310
5970
  }
5971
+ ctx.fill();
5311
5972
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5312
5973
  // Thin single rule — understated elegance
5313
5974
  ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5318,13 +5979,41 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5318
5979
  // Other archetypes: no border (intentional — not every image needs one)
5319
5980
  ctx.restore();
5320
5981
  }
5321
- // ── 11. Signature mark — unique geometric chop from hash prefix ──
5982
+ // ── 11. Signature mark — placed in the least-dense corner ──────
5322
5983
  {
5323
5984
  const sigRng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash, 42));
5324
5985
  const sigSize = Math.min(width, height) * 0.025;
5325
- // Bottom-right corner with padding
5326
- const sigX = width - sigSize * 2.5;
5327
- const sigY = height - sigSize * 2.5;
5986
+ const sigMargin = sigSize * 2.5;
5987
+ // Find the corner with the lowest local density
5988
+ const cornerCandidates = [
5989
+ {
5990
+ x: sigMargin,
5991
+ y: sigMargin
5992
+ },
5993
+ {
5994
+ x: width - sigMargin,
5995
+ y: sigMargin
5996
+ },
5997
+ {
5998
+ x: sigMargin,
5999
+ y: height - sigMargin
6000
+ },
6001
+ {
6002
+ x: width - sigMargin,
6003
+ y: height - sigMargin
6004
+ }
6005
+ ];
6006
+ let bestCorner = cornerCandidates[3]; // default: bottom-right
6007
+ let minDensity = Infinity;
6008
+ for (const corner of cornerCandidates){
6009
+ const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
6010
+ if (density < minDensity) {
6011
+ minDensity = density;
6012
+ bestCorner = corner;
6013
+ }
6014
+ }
6015
+ const sigX = bestCorner.x;
6016
+ const sigY = bestCorner.y;
5328
6017
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
5329
6018
  const sigColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.accent, 0.15);
5330
6019
  ctx.save();