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/module.js CHANGED
@@ -517,13 +517,21 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
517
517
  }
518
518
  }
519
519
  // ── Standalone color utilities ──────────────────────────────────────
520
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
521
- const c = hex.replace("#", "");
522
- return [
520
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
521
+ const $9d614e7d77fc2947$var$_rgbCache = new Map();
522
+ const $9d614e7d77fc2947$var$_RGB_CACHE_MAX = 512;
523
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
524
+ let cached = $9d614e7d77fc2947$var$_rgbCache.get(hex);
525
+ if (cached) return cached;
526
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
527
+ cached = [
523
528
  parseInt(c.substring(0, 2), 16),
524
529
  parseInt(c.substring(2, 4), 16),
525
530
  parseInt(c.substring(4, 6), 16)
526
531
  ];
532
+ if ($9d614e7d77fc2947$var$_rgbCache.size >= $9d614e7d77fc2947$var$_RGB_CACHE_MAX) $9d614e7d77fc2947$var$_rgbCache.clear();
533
+ $9d614e7d77fc2947$var$_rgbCache.set(hex, cached);
534
+ return cached;
527
535
  }
528
536
  /** Format [r, g, b] back to #RRGGBB. */ function $9d614e7d77fc2947$var$rgbToHex(r, g, b) {
529
537
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -580,7 +588,9 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
580
588
  }
581
589
  function $9d614e7d77fc2947$export$f2121afcad3d553f(hex, alpha) {
582
590
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex);
583
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
591
+ // Quantize alpha to 3 decimal places without toFixed overhead
592
+ const a = Math.round(alpha * 1000) / 1000;
593
+ return `rgba(${r},${g},${b},${a})`;
584
594
  }
585
595
  function $9d614e7d77fc2947$export$fabac4600b87056(colors, rng) {
586
596
  if (colors.length < 3) return {
@@ -589,15 +599,17 @@ function $9d614e7d77fc2947$export$fabac4600b87056(colors, rng) {
589
599
  accent: colors[colors.length - 1] || "#888888",
590
600
  all: colors
591
601
  };
592
- // Pick dominant as the color closest to the palette's average hue
602
+ // Pick dominant as the color with the highest chroma (saturation × distance from gray)
603
+ // This selects the most visually prominent color rather than the average
593
604
  const hsls = colors.map((c)=>$9d614e7d77fc2947$var$hexToHsl(c));
594
- const avgHue = hsls.reduce((s, h)=>s + h[0], 0) / hsls.length;
595
605
  let dominantIdx = 0;
596
- let minDist = 360;
606
+ let maxChroma = -1;
597
607
  for(let i = 0; i < hsls.length; i++){
598
- const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
599
- if (d < minDist) {
600
- minDist = d;
608
+ // Chroma approximation: saturation × how far lightness is from 50% (gray)
609
+ const lightnessVibrancy = 1 - Math.abs(hsls[i][2] - 0.5) * 2; // peaks at L=0.5
610
+ const chroma = hsls[i][1] * lightnessVibrancy;
611
+ if (chroma > maxChroma) {
612
+ maxChroma = chroma;
601
613
  dominantIdx = i;
602
614
  }
603
615
  }
@@ -658,12 +670,21 @@ function $9d614e7d77fc2947$export$51ea55f869b7e0d3(hex, target, amount) {
658
670
  const [h, s, l] = $9d614e7d77fc2947$var$hexToHsl(hex);
659
671
  return $9d614e7d77fc2947$var$hslToHex($9d614e7d77fc2947$var$shiftHueToward(h, target, amount), s, l);
660
672
  }
673
+ /**
674
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
675
+ * Uses the sRGB luminance formula from WCAG. Cached.
676
+ */ const $9d614e7d77fc2947$var$_lumCache = new Map();
661
677
  function $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(hex) {
678
+ let cached = $9d614e7d77fc2947$var$_lumCache.get(hex);
679
+ if (cached !== undefined) return cached;
662
680
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex).map((c)=>{
663
681
  const s = c / 255;
664
682
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
665
683
  });
666
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
684
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
685
+ if ($9d614e7d77fc2947$var$_lumCache.size >= 512) $9d614e7d77fc2947$var$_lumCache.clear();
686
+ $9d614e7d77fc2947$var$_lumCache.set(hex, cached);
687
+ return cached;
667
688
  }
668
689
  function $9d614e7d77fc2947$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
669
690
  const fgLum = $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1099,21 +1120,31 @@ const $8bde0a7ee87832b5$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1099
1120
  (0, $79312e33271883e9$export$e46c5570db033611)(ctx, size, finalConfig);
1100
1121
  const gridSize = 8;
1101
1122
  const unit = size / gridSize;
1123
+ const radius = unit / 2;
1124
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1125
+ const starPoints = [];
1126
+ for(let k = 0; k < 8; k++){
1127
+ const angle = Math.PI / 4 * k;
1128
+ const angle2 = angle + Math.PI / 4;
1129
+ starPoints.push({
1130
+ c1: Math.cos(angle) * radius,
1131
+ s1: Math.sin(angle) * radius,
1132
+ c2: Math.cos(angle2) * radius,
1133
+ s2: Math.sin(angle2) * radius
1134
+ });
1135
+ }
1102
1136
  ctx.beginPath();
1103
1137
  // Create base grid
1104
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1138
+ for(let i = 0; i <= gridSize; i++){
1105
1139
  const x = (i - gridSize / 2) * unit;
1106
- const y = (j - gridSize / 2) * unit;
1107
- // Draw star pattern at each intersection
1108
- const radius = unit / 2;
1109
- for(let k = 0; k < 8; k++){
1110
- const angle = Math.PI / 4 * k;
1111
- const x1 = x + radius * Math.cos(angle);
1112
- const y1 = y + radius * Math.sin(angle);
1113
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1114
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1115
- ctx.moveTo(x1, y1);
1116
- ctx.lineTo(x2, y2);
1140
+ for(let j = 0; j <= gridSize; j++){
1141
+ const y = (j - gridSize / 2) * unit;
1142
+ // Draw star pattern at each intersection using pre-computed offsets
1143
+ for(let k = 0; k < 8; k++){
1144
+ const sp = starPoints[k];
1145
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1146
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1147
+ }
1117
1148
  }
1118
1149
  }
1119
1150
  ctx.stroke();
@@ -1433,20 +1464,23 @@ const $d63629e16208c310$export$eeae7765f05012e2 = (ctx, size)=>{
1433
1464
  const $d63629e16208c310$export$3355220a8108efc3 = (ctx, size)=>{
1434
1465
  const outerRadius = size / 2;
1435
1466
  const innerRadius = size / 4;
1436
- const steps = 36;
1467
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1468
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1469
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1470
+ const TWO_PI = Math.PI * 2;
1471
+ const angleStep = TWO_PI / steps;
1437
1472
  ctx.beginPath();
1438
1473
  for(let i = 0; i < steps; i++){
1439
- const angle1 = i / steps * Math.PI * 2;
1440
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1474
+ const angle1 = i * angleStep;
1475
+ const cosA = Math.cos(angle1);
1476
+ const sinA = Math.sin(angle1);
1441
1477
  for(let j = 0; j < steps; j++){
1442
- const phi1 = j / steps * Math.PI * 2;
1443
- const phi2 = (j + 1) / steps * Math.PI * 2;
1444
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1445
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1446
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1447
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1448
- ctx.moveTo(x1, y1);
1449
- ctx.lineTo(x2, y2);
1478
+ const phi1 = j * angleStep;
1479
+ const phi2 = phi1 + angleStep;
1480
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1481
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1482
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1483
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1450
1484
  }
1451
1485
  }
1452
1486
  };
@@ -2009,6 +2043,43 @@ const $9beb8f41637c29fd$var$RENDER_STYLES = [
2009
2043
  function $9beb8f41637c29fd$export$9fd4e64b2acd410e(rng) {
2010
2044
  return $9beb8f41637c29fd$var$RENDER_STYLES[Math.floor(rng() * $9beb8f41637c29fd$var$RENDER_STYLES.length)];
2011
2045
  }
2046
+ const $9beb8f41637c29fd$export$2f738f61a8c15e07 = {
2047
+ "fill-and-stroke": 1,
2048
+ "fill-only": 0.5,
2049
+ "stroke-only": 1,
2050
+ "double-stroke": 1.5,
2051
+ "dashed": 1,
2052
+ "watercolor": 7,
2053
+ "hatched": 3,
2054
+ "incomplete": 1,
2055
+ "stipple": 90,
2056
+ "stencil": 2,
2057
+ "noise-grain": 400,
2058
+ "wood-grain": 10,
2059
+ "marble-vein": 4,
2060
+ "fabric-weave": 6,
2061
+ "hand-drawn": 5
2062
+ };
2063
+ function $9beb8f41637c29fd$export$909ab0580e273f19(style) {
2064
+ switch(style){
2065
+ case "noise-grain":
2066
+ return "hatched";
2067
+ case "stipple":
2068
+ return "dashed";
2069
+ case "wood-grain":
2070
+ return "hatched";
2071
+ case "watercolor":
2072
+ return "fill-and-stroke";
2073
+ case "fabric-weave":
2074
+ return "hatched";
2075
+ case "hand-drawn":
2076
+ return "fill-and-stroke";
2077
+ case "marble-vein":
2078
+ return "stroke-only";
2079
+ default:
2080
+ return style;
2081
+ }
2082
+ }
2012
2083
  function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2013
2084
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2014
2085
  ctx.save();
@@ -2128,6 +2199,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2128
2199
  case "hatched":
2129
2200
  {
2130
2201
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2202
+ // Optimized: batch all parallel lines into a single path per pass
2131
2203
  const savedAlphaH = ctx.globalAlpha;
2132
2204
  ctx.globalAlpha = savedAlphaH * 0.3;
2133
2205
  ctx.fill();
@@ -2139,28 +2211,28 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2139
2211
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2140
2212
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2141
2213
  ctx.globalAlpha = savedAlphaH * 0.6;
2142
- // Draw parallel lines across the bounding box
2214
+ // Draw parallel lines across the bounding box — batched into single path
2143
2215
  const extent = size * 0.8;
2144
2216
  const cos = Math.cos(hatchAngle);
2145
2217
  const sin = Math.sin(hatchAngle);
2218
+ ctx.beginPath();
2146
2219
  for(let d = -extent; d <= extent; d += hatchSpacing){
2147
- ctx.beginPath();
2148
2220
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2149
2221
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2150
- ctx.stroke();
2151
2222
  }
2223
+ ctx.stroke();
2152
2224
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2153
2225
  if (!rng || rng() < 0.5) {
2154
2226
  const crossAngle = hatchAngle + Math.PI / 2;
2155
2227
  const cos2 = Math.cos(crossAngle);
2156
2228
  const sin2 = Math.sin(crossAngle);
2157
2229
  ctx.globalAlpha = savedAlphaH * 0.35;
2230
+ ctx.beginPath();
2158
2231
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2159
- ctx.beginPath();
2160
2232
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2161
2233
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2162
- ctx.stroke();
2163
2234
  }
2235
+ ctx.stroke();
2164
2236
  }
2165
2237
  ctx.restore();
2166
2238
  ctx.globalAlpha = savedAlphaH;
@@ -2198,6 +2270,8 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2198
2270
  case "stipple":
2199
2271
  {
2200
2272
  // Dot-fill texture — clip to shape, then scatter dots
2273
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2274
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2201
2275
  const savedAlphaS = ctx.globalAlpha;
2202
2276
  ctx.globalAlpha = savedAlphaS * 0.15;
2203
2277
  ctx.fill(); // ghost fill
@@ -2205,16 +2279,20 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2205
2279
  ctx.save();
2206
2280
  ctx.clip();
2207
2281
  const dotSpacing = Math.max(2, size * 0.03);
2208
- const extent = size * 0.55;
2282
+ const extentS = size * 0.55;
2283
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2284
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2285
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2209
2286
  ctx.globalAlpha = savedAlphaS * 0.7;
2210
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2211
- // Jitter each dot position for organic feel
2212
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2213
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2214
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2215
- ctx.beginPath();
2216
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2217
- ctx.fill();
2287
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2288
+ const dx = -extentS + xi * actualSpacing;
2289
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2290
+ const dy = -extentS + yi * actualSpacing;
2291
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2292
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2293
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2294
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2295
+ }
2218
2296
  }
2219
2297
  ctx.restore();
2220
2298
  ctx.globalAlpha = savedAlphaS;
@@ -2247,6 +2325,9 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2247
2325
  case "noise-grain":
2248
2326
  {
2249
2327
  // Procedural noise grain texture clipped to shape boundary
2328
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2329
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2330
+ // and batch dots by brightness (black/white) × alpha bucket
2250
2331
  const savedAlphaN = ctx.globalAlpha;
2251
2332
  ctx.globalAlpha = savedAlphaN * 0.25;
2252
2333
  ctx.fill(); // base tint
@@ -2255,17 +2336,47 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2255
2336
  ctx.clip();
2256
2337
  const grainSpacing = Math.max(1.5, size * 0.015);
2257
2338
  const extentN = size * 0.55;
2258
- ctx.globalAlpha = savedAlphaN * 0.6;
2259
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2260
- if (!rng) break;
2261
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2262
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2263
- const brightness = rng() > 0.5 ? 255 : 0;
2264
- const dotAlpha = 0.15 + rng() * 0.35;
2265
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2266
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2267
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2268
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2339
+ if (rng) {
2340
+ // Cap grid to max 40 dots per axis beyond this the grain is
2341
+ // visually indistinguishable but cost scales quadratically.
2342
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2343
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2344
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2345
+ const BUCKETS = 4;
2346
+ const bucketMin = 0.15;
2347
+ const bucketRange = 0.35;
2348
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2349
+ const buckets = [];
2350
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2351
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2352
+ const gx = -extentN + xi * actualGrainSpacing;
2353
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2354
+ const gy = -extentN + yi * actualGrainSpacing;
2355
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2356
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2357
+ const isWhite = rng() > 0.5;
2358
+ const dotAlpha = bucketMin + rng() * bucketRange;
2359
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2360
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2361
+ const offset = isWhite ? BUCKETS : 0;
2362
+ buckets[offset + bucketIdx].push({
2363
+ x: gx + jx,
2364
+ y: gy + jy,
2365
+ s: dotSize
2366
+ });
2367
+ }
2368
+ }
2369
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2370
+ for(let color = 0; color < 2; color++){
2371
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2372
+ for(let b = 0; b < BUCKETS; b++){
2373
+ const dots = buckets[color * BUCKETS + b];
2374
+ if (dots.length === 0) continue;
2375
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2376
+ ctx.globalAlpha = savedAlphaN * alpha;
2377
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2378
+ }
2379
+ }
2269
2380
  }
2270
2381
  ctx.restore();
2271
2382
  ctx.fillStyle = fillColor;
@@ -2278,6 +2389,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2278
2389
  case "wood-grain":
2279
2390
  {
2280
2391
  // Parallel wavy lines simulating wood grain, clipped to shape
2392
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2281
2393
  const savedAlphaW = ctx.globalAlpha;
2282
2394
  ctx.globalAlpha = savedAlphaW * 0.2;
2283
2395
  ctx.fill(); // base tint
@@ -2293,17 +2405,19 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2293
2405
  ctx.globalAlpha = savedAlphaW * 0.5;
2294
2406
  const cosG = Math.cos(grainAngle);
2295
2407
  const sinG = Math.sin(grainAngle);
2408
+ const waveCoeff = waveFreq * Math.PI;
2409
+ const invExtentW = 1 / extentW;
2410
+ // Batch all grain lines into a single path
2411
+ ctx.beginPath();
2296
2412
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2297
- ctx.beginPath();
2298
- for(let t = -extentW; t <= extentW; t += 2){
2299
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2300
- const px = t * cosG - (d + wave) * sinG;
2301
- const py = t * sinG + (d + wave) * cosG;
2302
- if (t === -extentW) ctx.moveTo(px, py);
2303
- else ctx.lineTo(px, py);
2413
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2414
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2415
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2416
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2417
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2304
2418
  }
2305
- ctx.stroke();
2306
2419
  }
2420
+ ctx.stroke();
2307
2421
  ctx.restore();
2308
2422
  ctx.globalAlpha = savedAlphaW;
2309
2423
  ctx.globalAlpha *= 0.35;
@@ -2365,6 +2479,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2365
2479
  case "fabric-weave":
2366
2480
  {
2367
2481
  // Interlocking horizontal/vertical threads clipped to shape
2482
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2368
2483
  const savedAlphaF = ctx.globalAlpha;
2369
2484
  ctx.globalAlpha = savedAlphaF * 0.15;
2370
2485
  ctx.fill(); // ghost base
@@ -2374,26 +2489,24 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2374
2489
  const threadSpacing = Math.max(2, size * 0.04);
2375
2490
  const extentF = size * 0.55;
2376
2491
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2492
+ // Horizontal threads — batched
2377
2493
  ctx.globalAlpha = savedAlphaF * 0.55;
2378
- // Horizontal threads
2494
+ ctx.beginPath();
2379
2495
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2380
- ctx.beginPath();
2381
2496
  ctx.moveTo(-extentF, y);
2382
2497
  ctx.lineTo(extentF, y);
2383
- ctx.stroke();
2384
2498
  }
2385
- // Vertical threads (offset by half spacing for weave effect)
2499
+ ctx.stroke();
2500
+ // Vertical threads (offset by half spacing for weave effect) — batched
2386
2501
  ctx.globalAlpha = savedAlphaF * 0.45;
2387
2502
  ctx.strokeStyle = fillColor;
2388
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2389
- ctx.beginPath();
2390
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2391
- // Over-under: draw segment, skip segment
2392
- ctx.moveTo(x, y);
2393
- ctx.lineTo(x, y + threadSpacing);
2394
- }
2395
- ctx.stroke();
2503
+ ctx.beginPath();
2504
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2505
+ // Over-under: draw segment, skip segment
2506
+ ctx.moveTo(x, y);
2507
+ ctx.lineTo(x, y + threadSpacing);
2396
2508
  }
2509
+ ctx.stroke();
2397
2510
  ctx.strokeStyle = strokeColor;
2398
2511
  ctx.restore();
2399
2512
  ctx.globalAlpha = savedAlphaF;
@@ -2459,14 +2572,17 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2459
2572
  ctx.translate(x, y);
2460
2573
  ctx.rotate(rotation * Math.PI / 180);
2461
2574
  // ── Drop shadow — soft colored shadow offset along light direction ──
2462
- if (lightAngle !== undefined && size > 10) {
2575
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2576
+ // and visually imperceptible at that scale.
2577
+ const useShadow = size >= 20;
2578
+ if (useShadow && lightAngle !== undefined) {
2463
2579
  const shadowDist = size * 0.035;
2464
2580
  const shadowBlurR = size * 0.06;
2465
2581
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2466
2582
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2467
2583
  ctx.shadowBlur = shadowBlurR;
2468
2584
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2469
- } else if (glowRadius > 0) {
2585
+ } else if (useShadow && glowRadius > 0) {
2470
2586
  // Glow / shadow effect (legacy path)
2471
2587
  ctx.shadowBlur = glowRadius;
2472
2588
  ctx.shadowColor = glowColor || fillColor;
@@ -2490,17 +2606,24 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2490
2606
  $9beb8f41637c29fd$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2491
2607
  }
2492
2608
  // Reset shadow so patterns and highlight aren't double-shadowed
2493
- ctx.shadowBlur = 0;
2494
- ctx.shadowOffsetX = 0;
2495
- ctx.shadowOffsetY = 0;
2496
- ctx.shadowColor = "transparent";
2497
- // ── Specular highlight — bright arc on the light-facing side ──
2498
- if (lightAngle !== undefined && size > 15 && rng) {
2609
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2610
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2611
+ ctx.shadowBlur = 0;
2612
+ ctx.shadowOffsetX = 0;
2613
+ ctx.shadowOffsetY = 0;
2614
+ ctx.shadowColor = "transparent";
2615
+ }
2616
+ // ── Specular highlight — tinted arc on the light-facing side ──
2617
+ // Skip for small shapes (< 30px) — gradient creation + composite op
2618
+ // switch is expensive and the highlight is invisible at small sizes.
2619
+ if (lightAngle !== undefined && size > 30 && rng) {
2499
2620
  const hlRadius = size * 0.35;
2500
2621
  const hlDist = size * 0.15;
2501
2622
  const hlX = Math.cos(lightAngle) * hlDist;
2502
2623
  const hlY = Math.sin(lightAngle) * hlDist;
2503
2624
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2625
+ // Use a simple white highlight — the per-shape hex parse was expensive
2626
+ // and the visual difference from tinted highlights is negligible.
2504
2627
  hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2505
2628
  hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2506
2629
  hlGrad.addColorStop(1, "rgba(255,255,255,0)");
@@ -3565,6 +3688,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3565
3688
  "watercolor",
3566
3689
  "fill-only"
3567
3690
  ],
3691
+ preferredCompositions: [
3692
+ "clustered",
3693
+ "flow-field",
3694
+ "radial"
3695
+ ],
3568
3696
  flowLineMultiplier: 2.5,
3569
3697
  heroShape: false,
3570
3698
  glowMultiplier: 0.5,
@@ -3586,6 +3714,10 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3586
3714
  "stroke-only",
3587
3715
  "incomplete"
3588
3716
  ],
3717
+ preferredCompositions: [
3718
+ "golden-spiral",
3719
+ "grid-subdivision"
3720
+ ],
3589
3721
  flowLineMultiplier: 0.3,
3590
3722
  heroShape: true,
3591
3723
  glowMultiplier: 0,
@@ -3607,6 +3739,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3607
3739
  "fill-only",
3608
3740
  "incomplete"
3609
3741
  ],
3742
+ preferredCompositions: [
3743
+ "flow-field",
3744
+ "golden-spiral",
3745
+ "spiral"
3746
+ ],
3610
3747
  flowLineMultiplier: 4,
3611
3748
  heroShape: false,
3612
3749
  glowMultiplier: 0.3,
@@ -3629,6 +3766,10 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3629
3766
  "double-stroke",
3630
3767
  "hatched"
3631
3768
  ],
3769
+ preferredCompositions: [
3770
+ "grid-subdivision",
3771
+ "radial"
3772
+ ],
3632
3773
  flowLineMultiplier: 0,
3633
3774
  heroShape: false,
3634
3775
  glowMultiplier: 0,
@@ -3650,6 +3791,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3650
3791
  "incomplete",
3651
3792
  "fill-only"
3652
3793
  ],
3794
+ preferredCompositions: [
3795
+ "golden-spiral",
3796
+ "radial",
3797
+ "spiral"
3798
+ ],
3653
3799
  flowLineMultiplier: 1.5,
3654
3800
  heroShape: true,
3655
3801
  glowMultiplier: 2,
@@ -3670,6 +3816,10 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3670
3816
  "fill-and-stroke",
3671
3817
  "double-stroke"
3672
3818
  ],
3819
+ preferredCompositions: [
3820
+ "grid-subdivision",
3821
+ "golden-spiral"
3822
+ ],
3673
3823
  flowLineMultiplier: 0,
3674
3824
  heroShape: true,
3675
3825
  glowMultiplier: 0,
@@ -3691,6 +3841,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3691
3841
  "double-stroke",
3692
3842
  "dashed"
3693
3843
  ],
3844
+ preferredCompositions: [
3845
+ "radial",
3846
+ "spiral",
3847
+ "clustered"
3848
+ ],
3694
3849
  flowLineMultiplier: 2,
3695
3850
  heroShape: true,
3696
3851
  glowMultiplier: 3,
@@ -3713,6 +3868,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3713
3868
  "stroke-only",
3714
3869
  "dashed"
3715
3870
  ],
3871
+ preferredCompositions: [
3872
+ "flow-field",
3873
+ "grid-subdivision",
3874
+ "clustered"
3875
+ ],
3716
3876
  flowLineMultiplier: 1.5,
3717
3877
  heroShape: false,
3718
3878
  glowMultiplier: 0,
@@ -3734,6 +3894,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3734
3894
  "watercolor",
3735
3895
  "fill-and-stroke"
3736
3896
  ],
3897
+ preferredCompositions: [
3898
+ "radial",
3899
+ "spiral",
3900
+ "golden-spiral"
3901
+ ],
3737
3902
  flowLineMultiplier: 3,
3738
3903
  heroShape: true,
3739
3904
  glowMultiplier: 2.5,
@@ -3755,6 +3920,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3755
3920
  "fill-only",
3756
3921
  "incomplete"
3757
3922
  ],
3923
+ preferredCompositions: [
3924
+ "golden-spiral",
3925
+ "flow-field",
3926
+ "radial"
3927
+ ],
3758
3928
  flowLineMultiplier: 0.5,
3759
3929
  heroShape: false,
3760
3930
  glowMultiplier: 0.3,
@@ -3776,6 +3946,10 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3776
3946
  "stroke-only",
3777
3947
  "dashed"
3778
3948
  ],
3949
+ preferredCompositions: [
3950
+ "grid-subdivision",
3951
+ "radial"
3952
+ ],
3779
3953
  flowLineMultiplier: 0,
3780
3954
  heroShape: false,
3781
3955
  glowMultiplier: 0,
@@ -3797,6 +3971,10 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3797
3971
  "fill-only",
3798
3972
  "double-stroke"
3799
3973
  ],
3974
+ preferredCompositions: [
3975
+ "grid-subdivision",
3976
+ "clustered"
3977
+ ],
3800
3978
  flowLineMultiplier: 0,
3801
3979
  heroShape: true,
3802
3980
  glowMultiplier: 0,
@@ -3818,6 +3996,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3818
3996
  "watercolor",
3819
3997
  "fill-only"
3820
3998
  ],
3999
+ preferredCompositions: [
4000
+ "radial",
4001
+ "golden-spiral",
4002
+ "flow-field"
4003
+ ],
3821
4004
  flowLineMultiplier: 1,
3822
4005
  heroShape: true,
3823
4006
  glowMultiplier: 1,
@@ -3839,6 +4022,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3839
4022
  "stroke-only",
3840
4023
  "fill-only"
3841
4024
  ],
4025
+ preferredCompositions: [
4026
+ "clustered",
4027
+ "grid-subdivision",
4028
+ "radial"
4029
+ ],
3842
4030
  flowLineMultiplier: 0,
3843
4031
  heroShape: false,
3844
4032
  glowMultiplier: 0.3,
@@ -3860,6 +4048,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3860
4048
  "fill-only",
3861
4049
  "incomplete"
3862
4050
  ],
4051
+ preferredCompositions: [
4052
+ "flow-field",
4053
+ "golden-spiral",
4054
+ "spiral"
4055
+ ],
3863
4056
  flowLineMultiplier: 3,
3864
4057
  heroShape: true,
3865
4058
  glowMultiplier: 0.2,
@@ -3881,6 +4074,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3881
4074
  "fill-only",
3882
4075
  "hatched"
3883
4076
  ],
4077
+ preferredCompositions: [
4078
+ "radial",
4079
+ "clustered",
4080
+ "flow-field"
4081
+ ],
3884
4082
  flowLineMultiplier: 0,
3885
4083
  heroShape: false,
3886
4084
  glowMultiplier: 0,
@@ -3903,6 +4101,11 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3903
4101
  "stroke-only",
3904
4102
  "incomplete"
3905
4103
  ],
4104
+ preferredCompositions: [
4105
+ "spiral",
4106
+ "radial",
4107
+ "golden-spiral"
4108
+ ],
3906
4109
  flowLineMultiplier: 2,
3907
4110
  heroShape: true,
3908
4111
  glowMultiplier: 2.5,
@@ -3926,6 +4129,12 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3926
4129
  ...b.preferredStyles
3927
4130
  ])
3928
4131
  ];
4132
+ const mergedCompositions = [
4133
+ ...new Set([
4134
+ ...a.preferredCompositions,
4135
+ ...b.preferredCompositions
4136
+ ])
4137
+ ];
3929
4138
  return {
3930
4139
  name: `${a.name}+${b.name}`,
3931
4140
  gridSize: Math.round($3faa2521b78398cf$var$lerpNum(a.gridSize, b.gridSize, t)),
@@ -3937,6 +4146,7 @@ const $3faa2521b78398cf$var$ARCHETYPES = [
3937
4146
  backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
3938
4147
  paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
3939
4148
  preferredStyles: mergedStyles,
4149
+ preferredCompositions: mergedCompositions,
3940
4150
  flowLineMultiplier: $3faa2521b78398cf$var$lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
3941
4151
  heroShape: t < 0.5 ? a.heroShape : b.heroShape,
3942
4152
  glowMultiplier: $3faa2521b78398cf$var$lerpNum(a.glowMultiplier, b.glowMultiplier, t),
@@ -3958,6 +4168,46 @@ function $3faa2521b78398cf$export$f1142fd7da4d6590(rng) {
3958
4168
  }
3959
4169
 
3960
4170
 
4171
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4172
+ // Based on benchmark measurements. Used by the complexity budget to
4173
+ // cap total rendering work and downgrade expensive styles when needed.
4174
+ const $b623126c6e9cbb71$var$RENDER_STYLE_COST = {
4175
+ "fill-and-stroke": 1,
4176
+ "fill-only": 0.5,
4177
+ "stroke-only": 1,
4178
+ "double-stroke": 1.5,
4179
+ "dashed": 1,
4180
+ "watercolor": 7,
4181
+ "hatched": 3,
4182
+ "incomplete": 1,
4183
+ "stipple": 90,
4184
+ "stencil": 2,
4185
+ "noise-grain": 400,
4186
+ "wood-grain": 10,
4187
+ "marble-vein": 4,
4188
+ "fabric-weave": 6,
4189
+ "hand-drawn": 5
4190
+ };
4191
+ function $b623126c6e9cbb71$var$downgradeRenderStyle(style) {
4192
+ switch(style){
4193
+ case "noise-grain":
4194
+ return "hatched";
4195
+ case "stipple":
4196
+ return "dashed";
4197
+ case "wood-grain":
4198
+ return "hatched";
4199
+ case "watercolor":
4200
+ return "fill-and-stroke";
4201
+ case "fabric-weave":
4202
+ return "hatched";
4203
+ case "hand-drawn":
4204
+ return "fill-and-stroke";
4205
+ case "marble-vein":
4206
+ return "stroke-only";
4207
+ default:
4208
+ return style;
4209
+ }
4210
+ }
3961
4211
  // ── Shape categories for weighted selection (legacy fallback) ───────
3962
4212
  const $b623126c6e9cbb71$var$SACRED_SHAPES = [
3963
4213
  "mandala",
@@ -3970,7 +4220,8 @@ const $b623126c6e9cbb71$var$SACRED_SHAPES = [
3970
4220
  "torus",
3971
4221
  "eggOfLife"
3972
4222
  ];
3973
- const $b623126c6e9cbb71$var$COMPOSITION_MODES = [
4223
+ // ── Composition modes ───────────────────────────────────────────────
4224
+ const $b623126c6e9cbb71$var$ALL_COMPOSITION_MODES = [
3974
4225
  "radial",
3975
4226
  "flow-field",
3976
4227
  "spiral",
@@ -4072,7 +4323,69 @@ function $b623126c6e9cbb71$var$isInVoidZone(x, y, voidZones) {
4072
4323
  }
4073
4324
  return false;
4074
4325
  }
4075
- // ── Helper: density check ───────────────────────────────────────────
4326
+ // ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
4327
+ class $b623126c6e9cbb71$var$SpatialGrid {
4328
+ cells;
4329
+ cellSize;
4330
+ constructor(cellSize){
4331
+ this.cells = new Map();
4332
+ this.cellSize = cellSize;
4333
+ }
4334
+ key(cx, cy) {
4335
+ return `${cx},${cy}`;
4336
+ }
4337
+ insert(item) {
4338
+ const cx = Math.floor(item.x / this.cellSize);
4339
+ const cy = Math.floor(item.y / this.cellSize);
4340
+ const k = this.key(cx, cy);
4341
+ const cell = this.cells.get(k);
4342
+ if (cell) cell.push(item);
4343
+ else this.cells.set(k, [
4344
+ item
4345
+ ]);
4346
+ }
4347
+ /** Count items within radius of (x, y) */ countNear(x, y, radius) {
4348
+ const r2 = radius * radius;
4349
+ const minCx = Math.floor((x - radius) / this.cellSize);
4350
+ const maxCx = Math.floor((x + radius) / this.cellSize);
4351
+ const minCy = Math.floor((y - radius) / this.cellSize);
4352
+ const maxCy = Math.floor((y + radius) / this.cellSize);
4353
+ let count = 0;
4354
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4355
+ const cell = this.cells.get(this.key(cx, cy));
4356
+ if (!cell) continue;
4357
+ for (const p of cell){
4358
+ const dx = x - p.x;
4359
+ const dy = y - p.y;
4360
+ if (dx * dx + dy * dy < r2) count++;
4361
+ }
4362
+ }
4363
+ return count;
4364
+ }
4365
+ /** Find nearest item to (x, y) */ findNearest(x, y, searchRadius) {
4366
+ const minCx = Math.floor((x - searchRadius) / this.cellSize);
4367
+ const maxCx = Math.floor((x + searchRadius) / this.cellSize);
4368
+ const minCy = Math.floor((y - searchRadius) / this.cellSize);
4369
+ const maxCy = Math.floor((y + searchRadius) / this.cellSize);
4370
+ let nearest = null;
4371
+ let bestDist2 = Infinity;
4372
+ for(let cx = minCx; cx <= maxCx; cx++)for(let cy = minCy; cy <= maxCy; cy++){
4373
+ const cell = this.cells.get(this.key(cx, cy));
4374
+ if (!cell) continue;
4375
+ for (const p of cell){
4376
+ const dx = x - p.x;
4377
+ const dy = y - p.y;
4378
+ const d2 = dx * dx + dy * dy;
4379
+ if (d2 > 0 && d2 < bestDist2) {
4380
+ bestDist2 = d2;
4381
+ nearest = p;
4382
+ }
4383
+ }
4384
+ }
4385
+ return nearest;
4386
+ }
4387
+ }
4388
+ // ── Helper: density check (legacy wrapper) ──────────────────────────
4076
4389
  function $b623126c6e9cbb71$var$localDensity(x, y, positions, radius) {
4077
4390
  let count = 0;
4078
4391
  for (const p of positions)if (Math.hypot(x - p.x, y - p.y) < radius) count++;
@@ -4371,42 +4684,43 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4371
4684
  const patternOpacity = 0.02 + rng() * 0.04;
4372
4685
  const patternColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4373
4686
  if (bgPatternRoll < 0.2) {
4374
- // Dot grid
4687
+ // Dot grid — batched into a single path
4375
4688
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4376
4689
  const dotR = dotSpacing * 0.08;
4377
4690
  ctx.globalAlpha = patternOpacity;
4378
4691
  ctx.fillStyle = patternColor;
4692
+ ctx.beginPath();
4379
4693
  for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4380
- ctx.beginPath();
4694
+ ctx.moveTo(px + dotR, py);
4381
4695
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
4382
- ctx.fill();
4383
4696
  }
4697
+ ctx.fill();
4384
4698
  } else if (bgPatternRoll < 0.4) {
4385
- // Diagonal lines
4699
+ // Diagonal lines — batched into a single path
4386
4700
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4387
4701
  ctx.globalAlpha = patternOpacity;
4388
4702
  ctx.strokeStyle = patternColor;
4389
4703
  ctx.lineWidth = 0.5 * scaleFactor;
4390
4704
  const diag = Math.hypot(width, height);
4705
+ ctx.beginPath();
4391
4706
  for(let d = -diag; d < diag; d += lineSpacing){
4392
- ctx.beginPath();
4393
4707
  ctx.moveTo(d, 0);
4394
4708
  ctx.lineTo(d + height, height);
4395
- ctx.stroke();
4396
4709
  }
4710
+ ctx.stroke();
4397
4711
  } else {
4398
- // Tessellation — hexagonal grid of tiny shapes
4712
+ // Tessellation — hexagonal grid, batched into a single path
4399
4713
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4400
4714
  const tessH = tessSize * Math.sqrt(3);
4401
4715
  ctx.globalAlpha = patternOpacity * 0.7;
4402
4716
  ctx.strokeStyle = patternColor;
4403
4717
  ctx.lineWidth = 0.4 * scaleFactor;
4718
+ ctx.beginPath();
4404
4719
  for(let row = 0; row * tessH < height + tessH; row++){
4405
4720
  const offsetX = row % 2 * tessSize * 0.75;
4406
4721
  for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4407
4722
  const hx = col * tessSize * 1.5 + offsetX;
4408
4723
  const hy = row * tessH;
4409
- ctx.beginPath();
4410
4724
  for(let s = 0; s < 6; s++){
4411
4725
  const angle = Math.PI / 3 * s - Math.PI / 6;
4412
4726
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -4415,18 +4729,18 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4415
4729
  else ctx.lineTo(vx, vy);
4416
4730
  }
4417
4731
  ctx.closePath();
4418
- ctx.stroke();
4419
4732
  }
4420
4733
  }
4734
+ ctx.stroke();
4421
4735
  }
4422
4736
  ctx.restore();
4423
4737
  }
4424
4738
  ctx.globalCompositeOperation = "source-over";
4425
- // ── 2. Composition mode ────────────────────────────────────────
4426
- const compositionMode = $b623126c6e9cbb71$var$COMPOSITION_MODES[Math.floor(rng() * $b623126c6e9cbb71$var$COMPOSITION_MODES.length)];
4739
+ // ── 2. Composition mode — archetype-aware selection ──────────────
4740
+ const compositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : $b623126c6e9cbb71$var$ALL_COMPOSITION_MODES[Math.floor(rng() * $b623126c6e9cbb71$var$ALL_COMPOSITION_MODES.length)];
4427
4741
  const symRoll = rng();
4428
4742
  const symmetryMode = symRoll < 0.10 ? "bilateral-x" : symRoll < 0.20 ? "bilateral-y" : symRoll < 0.25 ? "quad" : "none";
4429
- // ── 3. Focal points + void zones ───────────────────────────────
4743
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
4430
4744
  const THIRDS_POINTS = [
4431
4745
  {
4432
4746
  x: 1 / 3,
@@ -4459,9 +4773,23 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4459
4773
  y: height * (0.2 + rng() * 0.6),
4460
4774
  strength: 0.3 + rng() * 0.4
4461
4775
  });
4462
- const numVoids = Math.floor(rng() * 2) + 1;
4776
+ // Archetype-aware void zones: dense archetypes get fewer/no voids,
4777
+ // minimal archetypes get golden-ratio positioned voids
4778
+ const PHI = (1 + Math.sqrt(5)) / 2;
4779
+ const isMinimalArchetype = archetype.gridSize <= 3;
4780
+ const isDenseArchetype = archetype.gridSize >= 8;
4781
+ const numVoids = isDenseArchetype ? 0 : Math.floor(rng() * 2) + 1;
4463
4782
  const voidZones = [];
4464
- for(let v = 0; v < numVoids; v++)voidZones.push({
4783
+ for(let v = 0; v < numVoids; v++)if (isMinimalArchetype) {
4784
+ // Place voids at golden-ratio positions for intentional negative space
4785
+ const gx = v === 0 ? 1 / PHI : 1 - 1 / PHI;
4786
+ const gy = v === 0 ? 1 - 1 / PHI : 1 / PHI;
4787
+ voidZones.push({
4788
+ x: width * (gx + (rng() - 0.5) * 0.05),
4789
+ y: height * (gy + (rng() - 0.5) * 0.05),
4790
+ radius: Math.min(width, height) * (0.08 + rng() * 0.08)
4791
+ });
4792
+ } else voidZones.push({
4465
4793
  x: width * (0.15 + rng() * 0.7),
4466
4794
  y: height * (0.15 + rng() * 0.7),
4467
4795
  radius: Math.min(width, height) * (0.06 + rng() * 0.1)
@@ -4491,19 +4819,20 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4491
4819
  ctx.beginPath();
4492
4820
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4493
4821
  ctx.stroke();
4494
- // ~50% chance: scatter tiny dots inside the void
4822
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4495
4823
  if (rng() < 0.5) {
4496
4824
  const dotCount = 3 + Math.floor(rng() * 6);
4497
4825
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4498
4826
  ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4827
+ ctx.beginPath();
4499
4828
  for(let d = 0; d < dotCount; d++){
4500
4829
  const angle = rng() * Math.PI * 2;
4501
4830
  const dist = rng() * zone.radius * 0.7;
4502
4831
  const dotR = (1 + rng() * 3) * scaleFactor;
4503
- ctx.beginPath();
4832
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4504
4833
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4505
- ctx.fill();
4506
4834
  }
4835
+ ctx.fill();
4507
4836
  }
4508
4837
  // ~30% chance: thin concentric ring inside
4509
4838
  if (rng() < 0.3) {
@@ -4538,6 +4867,9 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4538
4867
  }
4539
4868
  // Track all placed shapes for density checks and connecting curves
4540
4869
  const shapePositions = [];
4870
+ // Spatial grid for O(1) density and nearest-neighbor lookups
4871
+ const densityCheckRadius = Math.min(width, height) * 0.08;
4872
+ const spatialGrid = new $b623126c6e9cbb71$var$SpatialGrid(densityCheckRadius);
4541
4873
  // Hero avoidance radius — shapes near the hero orient toward it
4542
4874
  let heroCenter = null;
4543
4875
  // ── 4b. Hero shape — a dominant focal element ───────────────────
@@ -4583,10 +4915,35 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4583
4915
  size: heroSize,
4584
4916
  shape: heroShape
4585
4917
  });
4918
+ spatialGrid.insert({
4919
+ x: heroFocal.x,
4920
+ y: heroFocal.y,
4921
+ size: heroSize,
4922
+ shape: heroShape
4923
+ });
4586
4924
  }
4587
4925
  // ── 5. Shape layers ────────────────────────────────────────────
4588
- const densityCheckRadius = Math.min(width, height) * 0.08;
4589
4926
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4927
+ // ── Complexity budget — caps total rendering work ──────────────
4928
+ // Budget scales with pixel area so larger canvases get proportionally
4929
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4930
+ // constellations, rhythm) are gated behind the budget; when it runs
4931
+ // low they are skipped. When it's exhausted, expensive render styles
4932
+ // are downgraded to cheaper alternatives.
4933
+ //
4934
+ // RNG values are always consumed even when skipping, so the
4935
+ // deterministic sequence for shapes that *do* render is preserved.
4936
+ const pixelArea = width * height;
4937
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4938
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4939
+ const totalBudget = complexityBudget;
4940
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4941
+ let extrasSpent = 0;
4942
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4943
+ // These generate O(size²) fillRect calls per shape and dominate
4944
+ // worst-case render time. Cap scales with pixel area.
4945
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4946
+ let clipHeavyCount = 0;
4590
4947
  for(let layer = 0; layer < layers; layer++){
4591
4948
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4592
4949
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4624,7 +4981,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4624
4981
  if ($b623126c6e9cbb71$var$isInVoidZone(x, y, voidZones)) {
4625
4982
  if (rng() < 0.85) continue;
4626
4983
  }
4627
- if ($b623126c6e9cbb71$var$localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
4984
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
4628
4985
  if (rng() < 0.6) continue;
4629
4986
  }
4630
4987
  // Power distribution for size — archetype controls the curve
@@ -4675,7 +5032,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4675
5032
  const shapeRenderStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4676
5033
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4677
5034
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4678
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5035
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5036
+ // Budget check: downgrade expensive styles proportionally —
5037
+ // the more expensive the style, the earlier it gets downgraded.
5038
+ // noise-grain (400) downgrades when budget < 20% remaining,
5039
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5040
+ let styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5041
+ if (styleCost > 3) {
5042
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5043
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5044
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5045
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5046
+ }
5047
+ }
5048
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5049
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5050
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5051
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5052
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5053
+ }
5054
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4679
5055
  // Consistent light direction — subtle shadow offset
4680
5056
  const shadowDist = hasGlow ? 0 : size * 0.02;
4681
5057
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4684,17 +5060,11 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4684
5060
  let finalX = x;
4685
5061
  let finalY = y;
4686
5062
  if (shapePositions.length > 0 && rng() < 0.25) {
4687
- // Find nearest placed shape
4688
- let nearestDist = Infinity;
4689
- let nearestPos = null;
4690
- for (const sp of shapePositions){
4691
- const d = Math.hypot(x - sp.x, y - sp.y);
4692
- if (d < nearestDist && d > 0) {
4693
- nearestDist = d;
4694
- nearestPos = sp;
4695
- }
4696
- }
5063
+ // Use spatial grid for O(1) nearest-neighbor lookup
5064
+ const searchRadius = adjustedMaxSize * 3;
5065
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
4697
5066
  if (nearestPos) {
5067
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
4698
5068
  // Target distance: edges kissing (sum of half-sizes)
4699
5069
  const targetDist = (size + nearestPos.size) * 0.5;
4700
5070
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -4736,30 +5106,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4736
5106
  lightAngle: lightAngle,
4737
5107
  scaleFactor: scaleFactor
4738
5108
  };
4739
- if (shouldMirror) (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4740
- ...shapeConfig,
4741
- mirrorAxis: mirrorAxis,
4742
- mirrorGap: size * (0.1 + rng() * 0.3)
4743
- });
4744
- else (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5109
+ if (shouldMirror) {
5110
+ (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5111
+ ...shapeConfig,
5112
+ mirrorAxis: mirrorAxis,
5113
+ mirrorGap: size * (0.1 + rng() * 0.3)
5114
+ });
5115
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5116
+ } else {
5117
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5118
+ complexityBudget -= styleCost;
5119
+ }
5120
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5121
+ const extrasAllowed = extrasSpent < budgetForExtras;
4745
5122
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4746
5123
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4747
5124
  const glazePasses = 2 + Math.floor(rng() * 2);
4748
- for(let g = 0; g < glazePasses; g++){
4749
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4750
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4751
- ctx.globalAlpha = glazeAlpha;
4752
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4753
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4754
- strokeColor: "rgba(0,0,0,0)",
4755
- strokeWidth: 0,
4756
- size: size * glazeScale,
4757
- rotation: rotation,
4758
- proportionType: "GOLDEN_RATIO",
4759
- renderStyle: "fill-only",
4760
- rng: rng
4761
- });
5125
+ if (extrasAllowed) {
5126
+ for(let g = 0; g < glazePasses; g++){
5127
+ const glazeScale = 1 - (g + 1) * 0.12;
5128
+ const glazeAlpha = 0.08 + g * 0.04;
5129
+ ctx.globalAlpha = glazeAlpha;
5130
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5131
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5132
+ strokeColor: "rgba(0,0,0,0)",
5133
+ strokeWidth: 0,
5134
+ size: size * glazeScale,
5135
+ rotation: rotation,
5136
+ proportionType: "GOLDEN_RATIO",
5137
+ renderStyle: "fill-only",
5138
+ rng: rng
5139
+ });
5140
+ }
5141
+ extrasSpent += glazePasses;
4762
5142
  }
5143
+ // RNG consumed by glazePasses calculation above regardless
4763
5144
  }
4764
5145
  shapePositions.push({
4765
5146
  x: finalX,
@@ -4767,35 +5148,51 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4767
5148
  size: size,
4768
5149
  shape: shape
4769
5150
  });
5151
+ spatialGrid.insert({
5152
+ x: finalX,
5153
+ y: finalY,
5154
+ size: size,
5155
+ shape: shape
5156
+ });
4770
5157
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
4771
5158
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4772
5159
  const echoCount = 2 + Math.floor(rng() * 2);
4773
5160
  const echoAngle = rng() * Math.PI * 2;
4774
- for(let e = 0; e < echoCount; e++){
4775
- const echoScale = 0.3 - e * 0.08;
4776
- const echoDist = size * (0.6 + e * 0.4);
4777
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4778
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4779
- const echoSize = size * Math.max(0.1, echoScale);
4780
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4781
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4782
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4783
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4784
- strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
4785
- strokeWidth: strokeWidth * 0.6,
4786
- size: echoSize,
4787
- rotation: rotation + (e + 1) * 15,
4788
- proportionType: "GOLDEN_RATIO",
4789
- renderStyle: finalRenderStyle,
4790
- rng: rng
4791
- });
4792
- shapePositions.push({
4793
- x: echoX,
4794
- y: echoY,
4795
- size: echoSize,
4796
- shape: shape
4797
- });
5161
+ if (extrasAllowed) {
5162
+ for(let e = 0; e < echoCount; e++){
5163
+ const echoScale = 0.3 - e * 0.08;
5164
+ const echoDist = size * (0.6 + e * 0.4);
5165
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5166
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5167
+ const echoSize = size * Math.max(0.1, echoScale);
5168
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5169
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5170
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5171
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5172
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
5173
+ strokeWidth: strokeWidth * 0.6,
5174
+ size: echoSize,
5175
+ rotation: rotation + (e + 1) * 15,
5176
+ proportionType: "GOLDEN_RATIO",
5177
+ renderStyle: finalRenderStyle,
5178
+ rng: rng
5179
+ });
5180
+ shapePositions.push({
5181
+ x: echoX,
5182
+ y: echoY,
5183
+ size: echoSize,
5184
+ shape: shape
5185
+ });
5186
+ spatialGrid.insert({
5187
+ x: echoX,
5188
+ y: echoY,
5189
+ size: echoSize,
5190
+ shape: shape
5191
+ });
5192
+ }
5193
+ extrasSpent += echoCount * styleCost;
4798
5194
  }
5195
+ // RNG for echoCount + echoAngle consumed above regardless
4799
5196
  }
4800
5197
  // ── 5d. Recursive nesting ──────────────────────────────────
4801
5198
  // Focal depth: shapes near focal points get more detail
@@ -4803,7 +5200,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4803
5200
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4804
5201
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4805
5202
  const innerCount = 1 + Math.floor(rng() * 3);
4806
- for(let n = 0; n < innerCount; n++){
5203
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4807
5204
  // Pick inner shape from palette affinities
4808
5205
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
4809
5206
  const innerShape = (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -4812,6 +5209,10 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4812
5209
  const innerOffY = (rng() - 0.5) * size * 0.4;
4813
5210
  const innerRot = rng() * 360;
4814
5211
  const innerFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5212
+ let innerStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5213
+ // Apply clip-heavy cap to nested shapes too
5214
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(innerStyle);
5215
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
4815
5216
  ctx.globalAlpha = layerOpacity * 0.7;
4816
5217
  (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
4817
5218
  fillColor: innerFill,
@@ -4820,9 +5221,21 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4820
5221
  size: innerSize,
4821
5222
  rotation: innerRot,
4822
5223
  proportionType: "GOLDEN_RATIO",
4823
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5224
+ renderStyle: innerStyle,
4824
5225
  rng: rng
4825
5226
  });
5227
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5228
+ }
5229
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5230
+ for(let n = 0; n < innerCount; n++){
5231
+ rng();
5232
+ rng();
5233
+ rng();
5234
+ rng();
5235
+ rng();
5236
+ rng();
5237
+ rng();
5238
+ rng();
4826
5239
  }
4827
5240
  }
4828
5241
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -4831,41 +5244,113 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4831
5244
  const constellation = $b623126c6e9cbb71$var$CONSTELLATIONS[Math.floor(rng() * $b623126c6e9cbb71$var$CONSTELLATIONS.length)];
4832
5245
  const members = constellation.build(rng, size);
4833
5246
  const groupRotation = rng() * Math.PI * 2;
4834
- const cosR = Math.cos(groupRotation);
4835
- const sinR = Math.sin(groupRotation);
4836
- for (const member of members){
4837
- // Rotate the group offset by the group rotation
4838
- const mx = finalX + member.dx * cosR - member.dy * sinR;
4839
- const my = finalY + member.dx * sinR + member.dy * cosR;
4840
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
4841
- const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
4842
- const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
4843
- ctx.globalAlpha = layerOpacity * 0.6;
4844
- // Use the member's shape if available, otherwise fall back to palette
4845
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
4846
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
4847
- fillColor: memberFill,
4848
- strokeColor: memberStroke,
4849
- strokeWidth: strokeWidth * 0.7,
4850
- size: member.size,
4851
- rotation: member.rotation + groupRotation * 180 / Math.PI,
4852
- proportionType: "GOLDEN_RATIO",
4853
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
4854
- rng: rng
4855
- });
4856
- shapePositions.push({
4857
- x: mx,
4858
- y: my,
4859
- size: member.size,
4860
- shape: memberShape
4861
- });
5247
+ if (extrasAllowed) {
5248
+ const cosR = Math.cos(groupRotation);
5249
+ const sinR = Math.sin(groupRotation);
5250
+ for (const member of members){
5251
+ // Rotate the group offset by the group rotation
5252
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5253
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5254
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5255
+ const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5256
+ const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5257
+ ctx.globalAlpha = layerOpacity * 0.6;
5258
+ // Use the member's shape if available, otherwise fall back to palette
5259
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5260
+ let memberStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5261
+ // Apply clip-heavy cap to constellation members too
5262
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(memberStyle);
5263
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5264
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5265
+ fillColor: memberFill,
5266
+ strokeColor: memberStroke,
5267
+ strokeWidth: strokeWidth * 0.7,
5268
+ size: member.size,
5269
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5270
+ proportionType: "GOLDEN_RATIO",
5271
+ renderStyle: memberStyle,
5272
+ rng: rng
5273
+ });
5274
+ shapePositions.push({
5275
+ x: mx,
5276
+ y: my,
5277
+ size: member.size,
5278
+ shape: memberShape
5279
+ });
5280
+ spatialGrid.insert({
5281
+ x: mx,
5282
+ y: my,
5283
+ size: member.size,
5284
+ shape: memberShape
5285
+ });
5286
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5287
+ }
5288
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5289
+ for(let m = 0; m < members.length; m++){
5290
+ rng();
5291
+ rng();
5292
+ rng();
5293
+ rng();
5294
+ rng();
5295
+ rng();
5296
+ }
5297
+ }
5298
+ // ── 5f. Rhythm placement — deliberate geometric progressions ──
5299
+ // ~12% of medium-large shapes spawn a rhythmic sequence
5300
+ if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
5301
+ const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
5302
+ const rhythmAngle = rng() * Math.PI * 2;
5303
+ const rhythmSpacing = size * (0.8 + rng() * 0.6);
5304
+ const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5305
+ const rhythmShape = shape; // same shape for visual rhythm
5306
+ if (extrasAllowed) {
5307
+ let rhythmSize = size * 0.6;
5308
+ for(let r = 0; r < rhythmCount; r++){
5309
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5310
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5311
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5312
+ if ($b623126c6e9cbb71$var$isInVoidZone(rx, ry, voidZones)) break;
5313
+ rhythmSize *= rhythmDecay;
5314
+ if (rhythmSize < adjustedMinSize) break;
5315
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5316
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5317
+ const rhythmFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5318
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5319
+ fillColor: rhythmFill,
5320
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.5),
5321
+ strokeWidth: strokeWidth * 0.7,
5322
+ size: rhythmSize,
5323
+ rotation: rotation + (r + 1) * 12,
5324
+ proportionType: "GOLDEN_RATIO",
5325
+ renderStyle: finalRenderStyle,
5326
+ rng: rng
5327
+ });
5328
+ shapePositions.push({
5329
+ x: rx,
5330
+ y: ry,
5331
+ size: rhythmSize,
5332
+ shape: rhythmShape
5333
+ });
5334
+ spatialGrid.insert({
5335
+ x: rx,
5336
+ y: ry,
5337
+ size: rhythmSize,
5338
+ shape: rhythmShape
5339
+ });
5340
+ }
5341
+ extrasSpent += rhythmCount * styleCost;
5342
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5343
+ for(let r = 0; r < rhythmCount; r++){
5344
+ rng();
5345
+ rng();
5346
+ rng();
4862
5347
  }
4863
5348
  }
4864
5349
  }
4865
5350
  }
4866
5351
  // Reset blend mode for post-processing passes
4867
5352
  ctx.globalCompositeOperation = "source-over";
4868
- // ── 5f. Layered masking / cutout portals ───────────────────────
5353
+ // ── 5g. Layered masking / cutout portals ───────────────────────
4869
5354
  // ~18% of images get 1-3 portal windows that paint over foreground
4870
5355
  // with a tinted background wash, creating a "peek through" effect.
4871
5356
  if (rng() < 0.18 && shapePositions.length > 3) {
@@ -4924,14 +5409,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4924
5409
  }
4925
5410
  }
4926
5411
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5412
+ // Optimized: collect all segments into width-quantized buckets, then
5413
+ // render each bucket as a single batched path. This reduces
5414
+ // beginPath/stroke calls from O(segments) to O(buckets).
4927
5415
  const baseFlowLines = 6 + Math.floor(rng() * 10);
4928
5416
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5417
+ // Width buckets — 6 buckets cover the taper×pressure range
5418
+ const FLOW_WIDTH_BUCKETS = 6;
5419
+ const flowBuckets = [];
5420
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5421
+ // Track the representative width for each bucket
5422
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5423
+ // Pre-compute max possible width for bucket assignment
5424
+ let globalMaxFlowWidth = 0;
4929
5425
  for(let i = 0; i < numFlowLines; i++){
4930
5426
  let fx = rng() * width;
4931
5427
  let fy = rng() * height;
4932
5428
  const steps = 30 + Math.floor(rng() * 40);
4933
5429
  const stepLen = (3 + rng() * 5) * scaleFactor;
4934
5430
  const startWidth = (1 + rng() * 3) * scaleFactor;
5431
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
4935
5432
  // Variable color: interpolate between two hierarchy colors along the stroke
4936
5433
  const lineColorStart = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
4937
5434
  const lineColorEnd = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -4946,20 +5443,29 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4946
5443
  fx += Math.cos(angle) * stepLen;
4947
5444
  fy += Math.sin(angle) * stepLen;
4948
5445
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
5446
+ // Skip segments that pass through void zones
5447
+ if ($b623126c6e9cbb71$var$isInVoidZone(fx, fy, voidZones)) {
5448
+ prevX = fx;
5449
+ prevY = fy;
5450
+ continue;
5451
+ }
4949
5452
  const t = s / steps;
4950
- // Taper + pressure
4951
5453
  const taper = 1 - t * 0.8;
4952
5454
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
4953
- ctx.globalAlpha = lineAlpha * taper;
4954
- // Interpolate color along stroke
5455
+ const segWidth = startWidth * taper * pressure;
5456
+ const segAlpha = lineAlpha * taper;
4955
5457
  const lineColor = t < 0.5 ? (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
4956
- ctx.strokeStyle = lineColor;
4957
- ctx.lineWidth = startWidth * taper * pressure;
4958
- ctx.lineCap = "round";
4959
- ctx.beginPath();
4960
- ctx.moveTo(prevX, prevY);
4961
- ctx.lineTo(fx, fy);
4962
- ctx.stroke();
5458
+ // Quantize width into bucket
5459
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5460
+ flowBuckets[bucketIdx].push({
5461
+ x1: prevX,
5462
+ y1: prevY,
5463
+ x2: fx,
5464
+ y2: fy,
5465
+ color: lineColor,
5466
+ alpha: segAlpha
5467
+ });
5468
+ flowBucketWidths[bucketIdx] = segWidth;
4963
5469
  // Branching: ~12% chance per step to spawn a thinner child stroke
4964
5470
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
4965
5471
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -4975,12 +5481,18 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4975
5481
  by += Math.sin(bAngle) * stepLen * 0.8;
4976
5482
  if (bx < 0 || bx > width || by < 0 || by > height) break;
4977
5483
  const bTaper = 1 - bs / branchSteps * 0.9;
4978
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
4979
- ctx.lineWidth = branchWidth * bTaper;
4980
- ctx.beginPath();
4981
- ctx.moveTo(bPrevX, bPrevY);
4982
- ctx.lineTo(bx, by);
4983
- ctx.stroke();
5484
+ const bSegWidth = branchWidth * bTaper;
5485
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5486
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5487
+ flowBuckets[bBucket].push({
5488
+ x1: bPrevX,
5489
+ y1: bPrevY,
5490
+ x2: bx,
5491
+ y2: by,
5492
+ color: lineColor,
5493
+ alpha: bAlpha
5494
+ });
5495
+ flowBucketWidths[bBucket] = bSegWidth;
4984
5496
  bPrevX = bx;
4985
5497
  bPrevY = by;
4986
5498
  }
@@ -4989,7 +5501,40 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4989
5501
  prevY = fy;
4990
5502
  }
4991
5503
  }
5504
+ // Render flow line buckets — one batched path per width bucket
5505
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5506
+ ctx.lineCap = "round";
5507
+ const FLOW_ALPHA_BUCKETS = 4;
5508
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5509
+ const segs = flowBuckets[wb];
5510
+ if (segs.length === 0) continue;
5511
+ ctx.lineWidth = flowBucketWidths[wb];
5512
+ // Sub-bucket by alpha
5513
+ const alphaSubs = [];
5514
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5515
+ let maxAlpha = 0;
5516
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5517
+ for(let j = 0; j < segs.length; j++){
5518
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5519
+ alphaSubs[ai].push(segs[j]);
5520
+ }
5521
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5522
+ const sub = alphaSubs[ai];
5523
+ if (sub.length === 0) continue;
5524
+ // Use the median segment's alpha and color as representative
5525
+ const rep = sub[Math.floor(sub.length / 2)];
5526
+ ctx.globalAlpha = rep.alpha;
5527
+ ctx.strokeStyle = rep.color;
5528
+ ctx.beginPath();
5529
+ for(let j = 0; j < sub.length; j++){
5530
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5531
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5532
+ }
5533
+ ctx.stroke();
5534
+ }
5535
+ }
4992
5536
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5537
+ // Optimized: collect all burst segments, then batch by quantized alpha
4993
5538
  const energyArchetypes = [
4994
5539
  "dense-chaotic",
4995
5540
  "cosmic",
@@ -5000,8 +5545,12 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5000
5545
  if (hasEnergyLines && shapePositions.length > 0) {
5001
5546
  const energyCount = 5 + Math.floor(rng() * 10);
5002
5547
  ctx.lineCap = "round";
5548
+ // Collect all energy segments with their computed state
5549
+ const ENERGY_ALPHA_BUCKETS = 3;
5550
+ const energyBuckets = [];
5551
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5552
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5003
5553
  for(let e = 0; e < energyCount; e++){
5004
- // Pick a random shape to radiate from
5005
5554
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5006
5555
  const burstCount = 2 + Math.floor(rng() * 4);
5007
5556
  const baseAngle = flowAngle(source.x, source.y);
@@ -5013,14 +5562,37 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5013
5562
  const sy = source.y + Math.sin(angle) * startDist;
5014
5563
  const ex = sx + Math.cos(angle) * lineLen;
5015
5564
  const ey = sy + Math.sin(angle) * lineLen;
5016
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5017
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5018
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5019
- ctx.beginPath();
5020
- ctx.moveTo(sx, sy);
5021
- ctx.lineTo(ex, ey);
5022
- ctx.stroke();
5565
+ const eAlpha = 0.04 + rng() * 0.06;
5566
+ const eColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5567
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5568
+ // Quantize alpha into bucket
5569
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5570
+ energyBuckets[bi].push({
5571
+ x1: sx,
5572
+ y1: sy,
5573
+ x2: ex,
5574
+ y2: ey,
5575
+ color: eColor,
5576
+ lw: eLw
5577
+ });
5578
+ energyAlphas[bi] = eAlpha;
5579
+ }
5580
+ }
5581
+ // Render batched energy lines
5582
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5583
+ const segs = energyBuckets[bi];
5584
+ if (segs.length === 0) continue;
5585
+ ctx.globalAlpha = energyAlphas[bi];
5586
+ // Use median segment's color and width as representative
5587
+ const rep = segs[Math.floor(segs.length / 2)];
5588
+ ctx.strokeStyle = rep.color;
5589
+ ctx.lineWidth = rep.lw;
5590
+ ctx.beginPath();
5591
+ for(let j = 0; j < segs.length; j++){
5592
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5593
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5023
5594
  }
5595
+ ctx.stroke();
5024
5596
  }
5025
5597
  }
5026
5598
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
@@ -5043,50 +5615,128 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5043
5615
  }
5044
5616
  ctx.restore();
5045
5617
  }
5046
- // ── 7. Noise texture overlay ───────────────────────────────────
5618
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
5619
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
5620
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
5047
5621
  const noiseRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 777));
5048
- const noiseDensity = Math.floor(width * height / 800);
5049
- for(let i = 0; i < noiseDensity; i++){
5050
- const nx = noiseRng() * width;
5051
- const ny = noiseRng() * height;
5052
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5053
- const alpha = 0.01 + noiseRng() * 0.03;
5054
- ctx.globalAlpha = alpha;
5055
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5056
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5622
+ const rawNoiseDensity = Math.floor(width * height / 800);
5623
+ // Cap at 2500 dots beyond this the visual effect is indistinguishable
5624
+ // but getImageData/putImageData cost scales with canvas size
5625
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5626
+ try {
5627
+ const imageData = ctx.getImageData(0, 0, width, height);
5628
+ const data = imageData.data;
5629
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5630
+ if (pixelScale === 1) // Fast path — no inner loop, direct pixel write
5631
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
5632
+ for(let i = 0; i < noiseDensity; i++){
5633
+ const nx = Math.floor(noiseRng() * width);
5634
+ const ny = Math.floor(noiseRng() * height);
5635
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5636
+ // srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
5637
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5638
+ const invA256 = 256 - srcA256;
5639
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
5640
+ const idx = ny * width + nx << 2;
5641
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5642
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5643
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5644
+ }
5645
+ else for(let i = 0; i < noiseDensity; i++){
5646
+ const nx = Math.floor(noiseRng() * width);
5647
+ const ny = Math.floor(noiseRng() * height);
5648
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5649
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
5650
+ const invA256 = 256 - srcA256;
5651
+ const bSrc = brightness * srcA256;
5652
+ for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5653
+ const idx = (ny + dy) * width + (nx + dx) << 2;
5654
+ data[idx] = data[idx] * invA256 + bSrc >> 8;
5655
+ data[idx + 1] = data[idx + 1] * invA256 + bSrc >> 8;
5656
+ data[idx + 2] = data[idx + 2] * invA256 + bSrc >> 8;
5657
+ }
5658
+ }
5659
+ ctx.putImageData(imageData, 0, 0);
5660
+ } catch {
5661
+ // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5662
+ for(let i = 0; i < noiseDensity; i++){
5663
+ const nx = noiseRng() * width;
5664
+ const ny = noiseRng() * height;
5665
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5666
+ const alpha = 0.01 + noiseRng() * 0.03;
5667
+ ctx.globalAlpha = alpha;
5668
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5669
+ ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5670
+ }
5057
5671
  }
5058
5672
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5059
5673
  ctx.globalAlpha = 1;
5060
5674
  const vignetteStrength = 0.25 + rng() * 0.2;
5061
5675
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
5676
+ // Tint vignette based on background: warm sepia for light, cool blue for dark
5677
+ const isLightBg = bgLum > 0.5;
5678
+ const vignetteColor = isLightBg ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
5679
+ : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
5062
5680
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
5063
5681
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
5064
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
5682
+ vigGrad.addColorStop(1, vignetteColor);
5065
5683
  ctx.fillStyle = vigGrad;
5066
5684
  ctx.fillRect(0, 0, width, height);
5067
- // ── 9. Organic connecting curves ───────────────────────────────
5685
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
5686
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5687
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5068
5688
  if (shapePositions.length > 1) {
5069
5689
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5690
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5070
5691
  ctx.lineWidth = 0.8 * scaleFactor;
5692
+ // Collect curves into 3 alpha buckets
5693
+ const CURVE_ALPHA_BUCKETS = 3;
5694
+ const curveBuckets = [];
5695
+ const curveColors = [];
5696
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5697
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5071
5698
  for(let i = 0; i < numCurves; i++){
5072
5699
  const idxA = Math.floor(rng() * shapePositions.length);
5073
5700
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
5074
5701
  const idxB = (idxA + offset) % shapePositions.length;
5075
5702
  const a = shapePositions[idxA];
5076
5703
  const b = shapePositions[idxB];
5077
- const mx = (a.x + b.x) / 2;
5078
- const my = (a.y + b.y) / 2;
5079
5704
  const dx = b.x - a.x;
5080
5705
  const dy = b.y - a.y;
5081
5706
  const dist = Math.hypot(dx, dy);
5707
+ // Skip connections between distant shapes
5708
+ if (dist > maxCurveDist) continue;
5709
+ const mx = (a.x + b.x) / 2;
5710
+ const my = (a.y + b.y) / 2;
5082
5711
  const bulge = (rng() - 0.5) * dist * 0.4;
5083
5712
  const cpx = mx + -dy / (dist || 1) * bulge;
5084
5713
  const cpy = my + dx / (dist || 1) * bulge;
5085
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5086
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5714
+ const curveAlpha = 0.06 + rng() * 0.1;
5715
+ const curveColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5716
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5717
+ curveBuckets[bi].push({
5718
+ ax: a.x,
5719
+ ay: a.y,
5720
+ cpx: cpx,
5721
+ cpy: cpy,
5722
+ bx: b.x,
5723
+ by: b.y
5724
+ });
5725
+ curveAlphas[bi] = curveAlpha;
5726
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5727
+ }
5728
+ // Render batched curves
5729
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5730
+ const curves = curveBuckets[bi];
5731
+ if (curves.length === 0) continue;
5732
+ ctx.globalAlpha = curveAlphas[bi];
5733
+ ctx.strokeStyle = curveColors[bi];
5087
5734
  ctx.beginPath();
5088
- ctx.moveTo(a.x, a.y);
5089
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5735
+ for(let j = 0; j < curves.length; j++){
5736
+ const c = curves[j];
5737
+ ctx.moveTo(c.ax, c.ay);
5738
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5739
+ }
5090
5740
  ctx.stroke();
5091
5741
  }
5092
5742
  }
@@ -5204,11 +5854,14 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5204
5854
  }
5205
5855
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5206
5856
  // Vine tendrils — organic curving lines along edges
5857
+ // Optimized: batch all tendrils into a single path
5207
5858
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5208
5859
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5209
5860
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5210
5861
  ctx.lineCap = "round";
5211
5862
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5863
+ ctx.beginPath();
5864
+ const leafPositions = [];
5212
5865
  for(let t = 0; t < tendrilCount; t++){
5213
5866
  // Start from a random edge point
5214
5867
  const edge = Math.floor(borderRng() * 4);
@@ -5226,7 +5879,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5226
5879
  tx = width - borderPad;
5227
5880
  ty = borderRng() * height;
5228
5881
  }
5229
- ctx.beginPath();
5230
5882
  ctx.moveTo(tx, ty);
5231
5883
  const segs = 3 + Math.floor(borderRng() * 4);
5232
5884
  for(let s = 0; s < segs; s++){
@@ -5240,14 +5892,23 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5240
5892
  ty = cpy3;
5241
5893
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5242
5894
  }
5243
- ctx.stroke();
5244
- // Small leaf/dot at tendril end
5245
- if (borderRng() < 0.6) {
5246
- ctx.beginPath();
5247
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5248
- ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5249
- ctx.fill();
5895
+ // Collect leaf positions for batch fill
5896
+ if (borderRng() < 0.6) leafPositions.push({
5897
+ x: tx,
5898
+ y: ty,
5899
+ r: borderPad * (0.15 + borderRng() * 0.2)
5900
+ });
5901
+ }
5902
+ ctx.stroke();
5903
+ // Batch all leaf dots into a single fill
5904
+ if (leafPositions.length > 0) {
5905
+ ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5906
+ ctx.beginPath();
5907
+ for (const leaf of leafPositions){
5908
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5909
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5250
5910
  }
5911
+ ctx.fill();
5251
5912
  }
5252
5913
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5253
5914
  // Star-studded arcs along edges
@@ -5262,8 +5923,9 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5262
5923
  ctx.beginPath();
5263
5924
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5264
5925
  ctx.stroke();
5265
- // Scatter small stars along the border region
5926
+ // Scatter small stars along the border region — batched into single path
5266
5927
  const starCount = 15 + Math.floor(borderRng() * 15);
5928
+ ctx.beginPath();
5267
5929
  for(let s = 0; s < starCount; s++){
5268
5930
  const edge = Math.floor(borderRng() * 4);
5269
5931
  let sx, sy;
@@ -5282,7 +5944,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5282
5944
  }
5283
5945
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5284
5946
  // 4-point star
5285
- ctx.beginPath();
5286
5947
  for(let p = 0; p < 8; p++){
5287
5948
  const a = p / 8 * Math.PI * 2;
5288
5949
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5292,8 +5953,8 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5292
5953
  else ctx.lineTo(px2, py2);
5293
5954
  }
5294
5955
  ctx.closePath();
5295
- ctx.fill();
5296
5956
  }
5957
+ ctx.fill();
5297
5958
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5298
5959
  // Thin single rule — understated elegance
5299
5960
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5304,13 +5965,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5304
5965
  // Other archetypes: no border (intentional — not every image needs one)
5305
5966
  ctx.restore();
5306
5967
  }
5307
- // ── 11. Signature mark — unique geometric chop from hash prefix ──
5968
+ // ── 11. Signature mark — placed in the least-dense corner ──────
5308
5969
  {
5309
5970
  const sigRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 42));
5310
5971
  const sigSize = Math.min(width, height) * 0.025;
5311
- // Bottom-right corner with padding
5312
- const sigX = width - sigSize * 2.5;
5313
- const sigY = height - sigSize * 2.5;
5972
+ const sigMargin = sigSize * 2.5;
5973
+ // Find the corner with the lowest local density
5974
+ const cornerCandidates = [
5975
+ {
5976
+ x: sigMargin,
5977
+ y: sigMargin
5978
+ },
5979
+ {
5980
+ x: width - sigMargin,
5981
+ y: sigMargin
5982
+ },
5983
+ {
5984
+ x: sigMargin,
5985
+ y: height - sigMargin
5986
+ },
5987
+ {
5988
+ x: width - sigMargin,
5989
+ y: height - sigMargin
5990
+ }
5991
+ ];
5992
+ let bestCorner = cornerCandidates[3]; // default: bottom-right
5993
+ let minDensity = Infinity;
5994
+ for (const corner of cornerCandidates){
5995
+ const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
5996
+ if (density < minDensity) {
5997
+ minDensity = density;
5998
+ bestCorner = corner;
5999
+ }
6000
+ }
6001
+ const sigX = bestCorner.x;
6002
+ const sigY = bestCorner.y;
5314
6003
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
5315
6004
  const sigColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.accent, 0.15);
5316
6005
  ctx.save();