git-hash-art 0.11.0 → 0.13.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 {
@@ -674,12 +684,21 @@ function $d016ad53434219a1$export$51ea55f869b7e0d3(hex, target, amount) {
674
684
  const [h, s, l] = $d016ad53434219a1$var$hexToHsl(hex);
675
685
  return $d016ad53434219a1$var$hslToHex($d016ad53434219a1$var$shiftHueToward(h, target, amount), s, l);
676
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();
677
691
  function $d016ad53434219a1$export$5c6e3c2b59b7fbbe(hex) {
692
+ let cached = $d016ad53434219a1$var$_lumCache.get(hex);
693
+ if (cached !== undefined) return cached;
678
694
  const [r, g, b] = $d016ad53434219a1$var$hexToRgb(hex).map((c)=>{
679
695
  const s = c / 255;
680
696
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
681
697
  });
682
- 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;
683
702
  }
684
703
  function $d016ad53434219a1$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
685
704
  const fgLum = $d016ad53434219a1$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1115,21 +1134,31 @@ const $4bf3d69be49ad55c$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1115
1134
  (0, $efc5b85ac9840d51$export$e46c5570db033611)(ctx, size, finalConfig);
1116
1135
  const gridSize = 8;
1117
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
+ }
1118
1150
  ctx.beginPath();
1119
1151
  // Create base grid
1120
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1152
+ for(let i = 0; i <= gridSize; i++){
1121
1153
  const x = (i - gridSize / 2) * unit;
1122
- const y = (j - gridSize / 2) * unit;
1123
- // Draw star pattern at each intersection
1124
- const radius = unit / 2;
1125
- for(let k = 0; k < 8; k++){
1126
- const angle = Math.PI / 4 * k;
1127
- const x1 = x + radius * Math.cos(angle);
1128
- const y1 = y + radius * Math.sin(angle);
1129
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1130
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1131
- ctx.moveTo(x1, y1);
1132
- 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
+ }
1133
1162
  }
1134
1163
  }
1135
1164
  ctx.stroke();
@@ -1449,20 +1478,23 @@ const $dd5df256f00f6199$export$eeae7765f05012e2 = (ctx, size)=>{
1449
1478
  const $dd5df256f00f6199$export$3355220a8108efc3 = (ctx, size)=>{
1450
1479
  const outerRadius = size / 2;
1451
1480
  const innerRadius = size / 4;
1452
- 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;
1453
1486
  ctx.beginPath();
1454
1487
  for(let i = 0; i < steps; i++){
1455
- const angle1 = i / steps * Math.PI * 2;
1456
- // 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);
1457
1491
  for(let j = 0; j < steps; j++){
1458
- const phi1 = j / steps * Math.PI * 2;
1459
- const phi2 = (j + 1) / steps * Math.PI * 2;
1460
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1461
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1462
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1463
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1464
- ctx.moveTo(x1, y1);
1465
- 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);
1466
1498
  }
1467
1499
  }
1468
1500
  };
@@ -2025,6 +2057,43 @@ const $c3de8257a8baa3b0$var$RENDER_STYLES = [
2025
2057
  function $c3de8257a8baa3b0$export$9fd4e64b2acd410e(rng) {
2026
2058
  return $c3de8257a8baa3b0$var$RENDER_STYLES[Math.floor(rng() * $c3de8257a8baa3b0$var$RENDER_STYLES.length)];
2027
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
+ }
2028
2097
  function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2029
2098
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2030
2099
  ctx.save();
@@ -2144,6 +2213,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2144
2213
  case "hatched":
2145
2214
  {
2146
2215
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2216
+ // Optimized: batch all parallel lines into a single path per pass
2147
2217
  const savedAlphaH = ctx.globalAlpha;
2148
2218
  ctx.globalAlpha = savedAlphaH * 0.3;
2149
2219
  ctx.fill();
@@ -2155,28 +2225,28 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2155
2225
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2156
2226
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2157
2227
  ctx.globalAlpha = savedAlphaH * 0.6;
2158
- // Draw parallel lines across the bounding box
2228
+ // Draw parallel lines across the bounding box — batched into single path
2159
2229
  const extent = size * 0.8;
2160
2230
  const cos = Math.cos(hatchAngle);
2161
2231
  const sin = Math.sin(hatchAngle);
2232
+ ctx.beginPath();
2162
2233
  for(let d = -extent; d <= extent; d += hatchSpacing){
2163
- ctx.beginPath();
2164
2234
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2165
2235
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2166
- ctx.stroke();
2167
2236
  }
2237
+ ctx.stroke();
2168
2238
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2169
2239
  if (!rng || rng() < 0.5) {
2170
2240
  const crossAngle = hatchAngle + Math.PI / 2;
2171
2241
  const cos2 = Math.cos(crossAngle);
2172
2242
  const sin2 = Math.sin(crossAngle);
2173
2243
  ctx.globalAlpha = savedAlphaH * 0.35;
2244
+ ctx.beginPath();
2174
2245
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2175
- ctx.beginPath();
2176
2246
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2177
2247
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2178
- ctx.stroke();
2179
2248
  }
2249
+ ctx.stroke();
2180
2250
  }
2181
2251
  ctx.restore();
2182
2252
  ctx.globalAlpha = savedAlphaH;
@@ -2214,6 +2284,8 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2214
2284
  case "stipple":
2215
2285
  {
2216
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.
2217
2289
  const savedAlphaS = ctx.globalAlpha;
2218
2290
  ctx.globalAlpha = savedAlphaS * 0.15;
2219
2291
  ctx.fill(); // ghost fill
@@ -2221,16 +2293,20 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2221
2293
  ctx.save();
2222
2294
  ctx.clip();
2223
2295
  const dotSpacing = Math.max(2, size * 0.03);
2224
- 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;
2225
2300
  ctx.globalAlpha = savedAlphaS * 0.7;
2226
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2227
- // Jitter each dot position for organic feel
2228
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2229
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2230
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2231
- ctx.beginPath();
2232
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2233
- 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
+ }
2234
2310
  }
2235
2311
  ctx.restore();
2236
2312
  ctx.globalAlpha = savedAlphaS;
@@ -2263,6 +2339,9 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2263
2339
  case "noise-grain":
2264
2340
  {
2265
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
2266
2345
  const savedAlphaN = ctx.globalAlpha;
2267
2346
  ctx.globalAlpha = savedAlphaN * 0.25;
2268
2347
  ctx.fill(); // base tint
@@ -2271,17 +2350,47 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2271
2350
  ctx.clip();
2272
2351
  const grainSpacing = Math.max(1.5, size * 0.015);
2273
2352
  const extentN = size * 0.55;
2274
- ctx.globalAlpha = savedAlphaN * 0.6;
2275
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2276
- if (!rng) break;
2277
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2278
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2279
- const brightness = rng() > 0.5 ? 255 : 0;
2280
- const dotAlpha = 0.15 + rng() * 0.35;
2281
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2282
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2283
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2284
- 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
+ }
2285
2394
  }
2286
2395
  ctx.restore();
2287
2396
  ctx.fillStyle = fillColor;
@@ -2294,6 +2403,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2294
2403
  case "wood-grain":
2295
2404
  {
2296
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
2297
2407
  const savedAlphaW = ctx.globalAlpha;
2298
2408
  ctx.globalAlpha = savedAlphaW * 0.2;
2299
2409
  ctx.fill(); // base tint
@@ -2309,17 +2419,19 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2309
2419
  ctx.globalAlpha = savedAlphaW * 0.5;
2310
2420
  const cosG = Math.cos(grainAngle);
2311
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();
2312
2426
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2313
- ctx.beginPath();
2314
- for(let t = -extentW; t <= extentW; t += 2){
2315
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2316
- const px = t * cosG - (d + wave) * sinG;
2317
- const py = t * sinG + (d + wave) * cosG;
2318
- if (t === -extentW) ctx.moveTo(px, py);
2319
- 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);
2320
2432
  }
2321
- ctx.stroke();
2322
2433
  }
2434
+ ctx.stroke();
2323
2435
  ctx.restore();
2324
2436
  ctx.globalAlpha = savedAlphaW;
2325
2437
  ctx.globalAlpha *= 0.35;
@@ -2381,6 +2493,7 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2381
2493
  case "fabric-weave":
2382
2494
  {
2383
2495
  // Interlocking horizontal/vertical threads clipped to shape
2496
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2384
2497
  const savedAlphaF = ctx.globalAlpha;
2385
2498
  ctx.globalAlpha = savedAlphaF * 0.15;
2386
2499
  ctx.fill(); // ghost base
@@ -2390,26 +2503,24 @@ function $c3de8257a8baa3b0$export$71b514a25c47df50(ctx, shape, x, y, config) {
2390
2503
  const threadSpacing = Math.max(2, size * 0.04);
2391
2504
  const extentF = size * 0.55;
2392
2505
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2506
+ // Horizontal threads — batched
2393
2507
  ctx.globalAlpha = savedAlphaF * 0.55;
2394
- // Horizontal threads
2508
+ ctx.beginPath();
2395
2509
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2396
- ctx.beginPath();
2397
2510
  ctx.moveTo(-extentF, y);
2398
2511
  ctx.lineTo(extentF, y);
2399
- ctx.stroke();
2400
2512
  }
2401
- // Vertical threads (offset by half spacing for weave effect)
2513
+ ctx.stroke();
2514
+ // Vertical threads (offset by half spacing for weave effect) — batched
2402
2515
  ctx.globalAlpha = savedAlphaF * 0.45;
2403
2516
  ctx.strokeStyle = fillColor;
2404
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2405
- ctx.beginPath();
2406
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2407
- // Over-under: draw segment, skip segment
2408
- ctx.moveTo(x, y);
2409
- ctx.lineTo(x, y + threadSpacing);
2410
- }
2411
- 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);
2412
2522
  }
2523
+ ctx.stroke();
2413
2524
  ctx.strokeStyle = strokeColor;
2414
2525
  ctx.restore();
2415
2526
  ctx.globalAlpha = savedAlphaF;
@@ -2475,14 +2586,17 @@ function $c3de8257a8baa3b0$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2475
2586
  ctx.translate(x, y);
2476
2587
  ctx.rotate(rotation * Math.PI / 180);
2477
2588
  // ── Drop shadow — soft colored shadow offset along light direction ──
2478
- 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) {
2479
2593
  const shadowDist = size * 0.035;
2480
2594
  const shadowBlurR = size * 0.06;
2481
2595
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2482
2596
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2483
2597
  ctx.shadowBlur = shadowBlurR;
2484
2598
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2485
- } else if (glowRadius > 0) {
2599
+ } else if (useShadow && glowRadius > 0) {
2486
2600
  // Glow / shadow effect (legacy path)
2487
2601
  ctx.shadowBlur = glowRadius;
2488
2602
  ctx.shadowColor = glowColor || fillColor;
@@ -2506,30 +2620,27 @@ function $c3de8257a8baa3b0$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2506
2620
  $c3de8257a8baa3b0$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2507
2621
  }
2508
2622
  // Reset shadow so patterns and highlight aren't double-shadowed
2509
- ctx.shadowBlur = 0;
2510
- ctx.shadowOffsetX = 0;
2511
- ctx.shadowOffsetY = 0;
2512
- ctx.shadowColor = "transparent";
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
+ }
2513
2630
  // ── Specular highlight — tinted arc on the light-facing side ──
2514
- if (lightAngle !== undefined && size > 15 && rng) {
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) {
2515
2634
  const hlRadius = size * 0.35;
2516
2635
  const hlDist = size * 0.15;
2517
2636
  const hlX = Math.cos(lightAngle) * hlDist;
2518
2637
  const hlY = Math.sin(lightAngle) * hlDist;
2519
2638
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2520
- // Tint highlight warm/cool based on fill color for cohesion
2521
- // Parse fill to detect warmth fallback to white for non-parseable
2522
- let hlBase = "255,255,255";
2523
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
2524
- const r = parseInt(fillColor.slice(1, 3), 16);
2525
- const g = parseInt(fillColor.slice(3, 5), 16);
2526
- const b = parseInt(fillColor.slice(5, 7), 16);
2527
- // Blend toward white but keep a hint of the fill's warmth
2528
- hlBase = `${Math.round(r * 0.15 + 216.75)},${Math.round(g * 0.15 + 216.75)},${Math.round(b * 0.15 + 216.75)}`;
2529
- }
2530
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
2531
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
2532
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
2639
+ // Use a simple white highlight the per-shape hex parse was expensive
2640
+ // and the visual difference from tinted highlights is negligible.
2641
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2642
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2643
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
2533
2644
  const savedOp = ctx.globalCompositeOperation;
2534
2645
  ctx.globalCompositeOperation = "soft-light";
2535
2646
  ctx.fillStyle = hlGrad;
@@ -4071,6 +4182,46 @@ function $f89bc858f7202849$export$f1142fd7da4d6590(rng) {
4071
4182
  }
4072
4183
 
4073
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
+ }
4074
4225
  // ── Shape categories for weighted selection (legacy fallback) ───────
4075
4226
  const $4f72c5a314eddf25$var$SACRED_SHAPES = [
4076
4227
  "mandala",
@@ -4456,6 +4607,15 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4456
4607
  ...(0, $93cf69256c93baa9$export$c2f8e0cc249a8d8f),
4457
4608
  ...config
4458
4609
  };
4610
+ const _dt = finalConfig._debugTiming;
4611
+ const _t = _dt ? ()=>performance.now() : undefined;
4612
+ let _p = _t ? _t() : 0;
4613
+ function _mark(name) {
4614
+ if (!_dt || !_t) return;
4615
+ const now = _t();
4616
+ _dt.phases[name] = now - _p;
4617
+ _p = now;
4618
+ }
4459
4619
  const rng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash));
4460
4620
  // ── 0. Select archetype — fundamentally different visual personality ──
4461
4621
  const archetype = (0, $f89bc858f7202849$export$f1142fd7da4d6590)(rng);
@@ -4489,12 +4649,14 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4489
4649
  const adjustedMaxSize = maxShapeSize * scaleFactor;
4490
4650
  const cx = width / 2;
4491
4651
  const cy = height / 2;
4652
+ _mark("0_setup");
4492
4653
  // ── 1. Background ──────────────────────────────────────────────
4493
4654
  const bgRadius = Math.hypot(cx, cy);
4494
4655
  $4f72c5a314eddf25$var$drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
4495
4656
  // Gradient mesh overlay — 3-4 color control points for richer backgrounds
4657
+ // Use source-over instead of soft-light for cheaper compositing
4496
4658
  const meshPoints = 3 + Math.floor(rng() * 2);
4497
- ctx.globalCompositeOperation = "soft-light";
4659
+ ctx.globalAlpha = 1;
4498
4660
  for(let i = 0; i < meshPoints; i++){
4499
4661
  const mx = rng() * width;
4500
4662
  const my = rng() * height;
@@ -4503,95 +4665,103 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4503
4665
  const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
4504
4666
  grad.addColorStop(0, (0, $d016ad53434219a1$export$f2121afcad3d553f)(mColor, 0.08 + rng() * 0.06));
4505
4667
  grad.addColorStop(1, "rgba(0,0,0,0)");
4506
- ctx.globalAlpha = 1;
4507
4668
  ctx.fillStyle = grad;
4508
- ctx.fillRect(0, 0, width, height);
4669
+ // Clip to gradient bounding box — avoids blending transparent pixels
4670
+ const gx = Math.max(0, mx - mRadius);
4671
+ const gy = Math.max(0, my - mRadius);
4672
+ const gw = Math.min(width, mx + mRadius) - gx;
4673
+ const gh = Math.min(height, my + mRadius) - gy;
4674
+ ctx.fillRect(gx, gy, gw, gh);
4509
4675
  }
4510
- ctx.globalCompositeOperation = "source-over";
4511
4676
  // Compute average background luminance for contrast enforcement
4512
4677
  const bgLum = ((0, $d016ad53434219a1$export$5c6e3c2b59b7fbbe)(bgStart) + (0, $d016ad53434219a1$export$5c6e3c2b59b7fbbe)(bgEnd)) / 2;
4513
4678
  // ── 1b. Layered background — archetype-coherent shapes ─────────
4679
+ // Use source-over with pre-multiplied alpha instead of soft-light
4680
+ // for much cheaper compositing (soft-light requires per-pixel blend)
4514
4681
  const bgShapeCount = 3 + Math.floor(rng() * 4);
4515
- ctx.globalCompositeOperation = "soft-light";
4516
4682
  for(let i = 0; i < bgShapeCount; i++){
4517
4683
  const bx = rng() * width;
4518
4684
  const by = rng() * height;
4519
4685
  const bSize = width * 0.3 + rng() * width * 0.5;
4520
4686
  const bColor = (0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng);
4521
- ctx.globalAlpha = 0.03 + rng() * 0.05;
4687
+ ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
4522
4688
  ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(bColor, 0.15);
4523
4689
  ctx.beginPath();
4524
4690
  // Use archetype-appropriate background shapes
4525
- if (archetype.name === "geometric-precision" || archetype.name === "op-art") // Rectangular shapes for geometric archetypes
4526
- ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4691
+ if (archetype.name === "geometric-precision" || archetype.name === "op-art") ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4527
4692
  else ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
4528
4693
  ctx.fill();
4529
4694
  }
4530
- // Subtle concentric rings from center
4695
+ // Subtle concentric rings from center — batched into single stroke
4531
4696
  const ringCount = 2 + Math.floor(rng() * 3);
4532
4697
  ctx.globalAlpha = 0.02 + rng() * 0.03;
4533
4698
  ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
4534
4699
  ctx.lineWidth = 1 * scaleFactor;
4700
+ ctx.beginPath();
4535
4701
  for(let i = 1; i <= ringCount; i++){
4536
4702
  const r = Math.min(width, height) * 0.15 * i;
4537
- ctx.beginPath();
4703
+ ctx.moveTo(cx + r, cy);
4538
4704
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
4539
- ctx.stroke();
4540
4705
  }
4541
- ctx.globalCompositeOperation = "source-over";
4706
+ ctx.stroke();
4542
4707
  // ── 1c. Background pattern layer — subtle textured paper ───────
4543
4708
  const bgPatternRoll = rng();
4544
4709
  if (bgPatternRoll < 0.6) {
4545
4710
  ctx.save();
4546
- ctx.globalCompositeOperation = "soft-light";
4547
4711
  const patternOpacity = 0.02 + rng() * 0.04;
4548
4712
  const patternColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4549
4713
  if (bgPatternRoll < 0.2) {
4550
- // Dot grid — batched into a single path
4551
- const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4552
- const dotR = dotSpacing * 0.08;
4714
+ // Dot grid — use fillRect instead of arcs (much cheaper, no path building)
4715
+ const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
4716
+ const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
4553
4717
  ctx.globalAlpha = patternOpacity;
4554
4718
  ctx.fillStyle = patternColor;
4555
- ctx.beginPath();
4556
- for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4557
- ctx.moveTo(px + dotR, py);
4558
- ctx.arc(px, py, dotR, 0, Math.PI * 2);
4719
+ let dotCount = 0;
4720
+ for(let px = 0; px < width && dotCount < 2000; px += dotSpacing)for(let py = 0; py < height && dotCount < 2000; py += dotSpacing){
4721
+ ctx.fillRect(px, py, dotDiam, dotDiam);
4722
+ dotCount++;
4559
4723
  }
4560
- ctx.fill();
4561
4724
  } else if (bgPatternRoll < 0.4) {
4562
- // Diagonal lines — batched into a single path
4563
- const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4725
+ // Diagonal lines — batched into a single path, capped at 300 lines
4726
+ const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
4564
4727
  ctx.globalAlpha = patternOpacity;
4565
4728
  ctx.strokeStyle = patternColor;
4566
4729
  ctx.lineWidth = 0.5 * scaleFactor;
4567
4730
  const diag = Math.hypot(width, height);
4568
4731
  ctx.beginPath();
4569
- for(let d = -diag; d < diag; d += lineSpacing){
4732
+ let lineCount = 0;
4733
+ for(let d = -diag; d < diag && lineCount < 300; d += lineSpacing){
4570
4734
  ctx.moveTo(d, 0);
4571
4735
  ctx.lineTo(d + height, height);
4736
+ lineCount++;
4572
4737
  }
4573
4738
  ctx.stroke();
4574
4739
  } else {
4575
- // Tessellation — hexagonal grid, batched into a single path
4576
- const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4740
+ // Tessellation — hexagonal grid, capped at 500 hexagons
4741
+ const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
4577
4742
  const tessH = tessSize * Math.sqrt(3);
4578
4743
  ctx.globalAlpha = patternOpacity * 0.7;
4579
4744
  ctx.strokeStyle = patternColor;
4580
4745
  ctx.lineWidth = 0.4 * scaleFactor;
4746
+ // Pre-compute hex vertex offsets (avoid trig per vertex)
4747
+ const hexVx = [];
4748
+ const hexVy = [];
4749
+ for(let s = 0; s < 6; s++){
4750
+ const angle = Math.PI / 3 * s - Math.PI / 6;
4751
+ hexVx.push(Math.cos(angle) * tessSize * 0.5);
4752
+ hexVy.push(Math.sin(angle) * tessSize * 0.5);
4753
+ }
4581
4754
  ctx.beginPath();
4582
- for(let row = 0; row * tessH < height + tessH; row++){
4755
+ let hexCount = 0;
4756
+ for(let row = 0; row * tessH < height + tessH && hexCount < 500; row++){
4583
4757
  const offsetX = row % 2 * tessSize * 0.75;
4584
- for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4758
+ for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++){
4585
4759
  const hx = col * tessSize * 1.5 + offsetX;
4586
4760
  const hy = row * tessH;
4587
- for(let s = 0; s < 6; s++){
4588
- const angle = Math.PI / 3 * s - Math.PI / 6;
4589
- const vx = hx + Math.cos(angle) * tessSize * 0.5;
4590
- const vy = hy + Math.sin(angle) * tessSize * 0.5;
4591
- if (s === 0) ctx.moveTo(vx, vy);
4592
- else ctx.lineTo(vx, vy);
4593
- }
4761
+ ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
4762
+ for(let s = 1; s < 6; s++)ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
4594
4763
  ctx.closePath();
4764
+ hexCount++;
4595
4765
  }
4596
4766
  }
4597
4767
  ctx.stroke();
@@ -4599,6 +4769,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4599
4769
  ctx.restore();
4600
4770
  }
4601
4771
  ctx.globalCompositeOperation = "source-over";
4772
+ _mark("1_background");
4602
4773
  // ── 2. Composition mode — archetype-aware selection ──────────────
4603
4774
  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)];
4604
4775
  const symRoll = rng();
@@ -4682,19 +4853,20 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4682
4853
  ctx.beginPath();
4683
4854
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4684
4855
  ctx.stroke();
4685
- // ~50% chance: scatter tiny dots inside the void
4856
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4686
4857
  if (rng() < 0.5) {
4687
4858
  const dotCount = 3 + Math.floor(rng() * 6);
4688
4859
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4689
4860
  ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4861
+ ctx.beginPath();
4690
4862
  for(let d = 0; d < dotCount; d++){
4691
4863
  const angle = rng() * Math.PI * 2;
4692
4864
  const dist = rng() * zone.radius * 0.7;
4693
4865
  const dotR = (1 + rng() * 3) * scaleFactor;
4694
- ctx.beginPath();
4866
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4695
4867
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4696
- ctx.fill();
4697
4868
  }
4869
+ ctx.fill();
4698
4870
  }
4699
4871
  // ~30% chance: thin concentric ring inside
4700
4872
  if (rng() < 0.3) {
@@ -4708,6 +4880,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4708
4880
  }
4709
4881
  }
4710
4882
  ctx.globalAlpha = 1;
4883
+ _mark("2_3_composition_focal");
4711
4884
  // ── 4. Flow field — simplex noise for organic variation ─────────
4712
4885
  // Create a seeded simplex noise field (unique per hash)
4713
4886
  const noiseFieldRng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash, 333));
@@ -4784,8 +4957,29 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4784
4957
  shape: heroShape
4785
4958
  });
4786
4959
  }
4960
+ _mark("4_flowfield_hero");
4787
4961
  // ── 5. Shape layers ────────────────────────────────────────────
4788
4962
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4963
+ // ── Complexity budget — caps total rendering work ──────────────
4964
+ // Budget scales with pixel area so larger canvases get proportionally
4965
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4966
+ // constellations, rhythm) are gated behind the budget; when it runs
4967
+ // low they are skipped. When it's exhausted, expensive render styles
4968
+ // are downgraded to cheaper alternatives.
4969
+ //
4970
+ // RNG values are always consumed even when skipping, so the
4971
+ // deterministic sequence for shapes that *do* render is preserved.
4972
+ const pixelArea = width * height;
4973
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4974
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4975
+ const totalBudget = complexityBudget;
4976
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4977
+ let extrasSpent = 0;
4978
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4979
+ // These generate O(size²) fillRect calls per shape and dominate
4980
+ // worst-case render time. Cap scales with pixel area.
4981
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4982
+ let clipHeavyCount = 0;
4789
4983
  for(let layer = 0; layer < layers; layer++){
4790
4984
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4791
4985
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4874,7 +5068,26 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4874
5068
  const shapeRenderStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4875
5069
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4876
5070
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4877
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5071
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5072
+ // Budget check: downgrade expensive styles proportionally —
5073
+ // the more expensive the style, the earlier it gets downgraded.
5074
+ // noise-grain (400) downgrades when budget < 20% remaining,
5075
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5076
+ let styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5077
+ if (styleCost > 3) {
5078
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5079
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5080
+ finalRenderStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(finalRenderStyle);
5081
+ styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5082
+ }
5083
+ }
5084
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5085
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5086
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5087
+ finalRenderStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(finalRenderStyle);
5088
+ styleCost = $4f72c5a314eddf25$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5089
+ }
5090
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4878
5091
  // Consistent light direction — subtle shadow offset
4879
5092
  const shadowDist = hasGlow ? 0 : size * 0.02;
4880
5093
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4929,30 +5142,41 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4929
5142
  lightAngle: lightAngle,
4930
5143
  scaleFactor: scaleFactor
4931
5144
  };
4932
- if (shouldMirror) (0, $c3de8257a8baa3b0$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4933
- ...shapeConfig,
4934
- mirrorAxis: mirrorAxis,
4935
- mirrorGap: size * (0.1 + rng() * 0.3)
4936
- });
4937
- else (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5145
+ if (shouldMirror) {
5146
+ (0, $c3de8257a8baa3b0$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5147
+ ...shapeConfig,
5148
+ mirrorAxis: mirrorAxis,
5149
+ mirrorGap: size * (0.1 + rng() * 0.3)
5150
+ });
5151
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5152
+ } else {
5153
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5154
+ complexityBudget -= styleCost;
5155
+ }
5156
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5157
+ const extrasAllowed = extrasSpent < budgetForExtras;
4938
5158
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4939
5159
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4940
5160
  const glazePasses = 2 + Math.floor(rng() * 2);
4941
- for(let g = 0; g < glazePasses; g++){
4942
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4943
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4944
- ctx.globalAlpha = glazeAlpha;
4945
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4946
- fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4947
- strokeColor: "rgba(0,0,0,0)",
4948
- strokeWidth: 0,
4949
- size: size * glazeScale,
4950
- rotation: rotation,
4951
- proportionType: "GOLDEN_RATIO",
4952
- renderStyle: "fill-only",
4953
- rng: rng
4954
- });
5161
+ if (extrasAllowed) {
5162
+ for(let g = 0; g < glazePasses; g++){
5163
+ const glazeScale = 1 - (g + 1) * 0.12;
5164
+ const glazeAlpha = 0.08 + g * 0.04;
5165
+ ctx.globalAlpha = glazeAlpha;
5166
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5167
+ fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5168
+ strokeColor: "rgba(0,0,0,0)",
5169
+ strokeWidth: 0,
5170
+ size: size * glazeScale,
5171
+ rotation: rotation,
5172
+ proportionType: "GOLDEN_RATIO",
5173
+ renderStyle: "fill-only",
5174
+ rng: rng
5175
+ });
5176
+ }
5177
+ extrasSpent += glazePasses;
4955
5178
  }
5179
+ // RNG consumed by glazePasses calculation above regardless
4956
5180
  }
4957
5181
  shapePositions.push({
4958
5182
  x: finalX,
@@ -4970,37 +5194,41 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
4970
5194
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4971
5195
  const echoCount = 2 + Math.floor(rng() * 2);
4972
5196
  const echoAngle = rng() * Math.PI * 2;
4973
- for(let e = 0; e < echoCount; e++){
4974
- const echoScale = 0.3 - e * 0.08;
4975
- const echoDist = size * (0.6 + e * 0.4);
4976
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4977
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4978
- const echoSize = size * Math.max(0.1, echoScale);
4979
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4980
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4981
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4982
- fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4983
- strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.4),
4984
- strokeWidth: strokeWidth * 0.6,
4985
- size: echoSize,
4986
- rotation: rotation + (e + 1) * 15,
4987
- proportionType: "GOLDEN_RATIO",
4988
- renderStyle: finalRenderStyle,
4989
- rng: rng
4990
- });
4991
- shapePositions.push({
4992
- x: echoX,
4993
- y: echoY,
4994
- size: echoSize,
4995
- shape: shape
4996
- });
4997
- spatialGrid.insert({
4998
- x: echoX,
4999
- y: echoY,
5000
- size: echoSize,
5001
- shape: shape
5002
- });
5197
+ if (extrasAllowed) {
5198
+ for(let e = 0; e < echoCount; e++){
5199
+ const echoScale = 0.3 - e * 0.08;
5200
+ const echoDist = size * (0.6 + e * 0.4);
5201
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5202
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5203
+ const echoSize = size * Math.max(0.1, echoScale);
5204
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5205
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5206
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5207
+ fillColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5208
+ strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.4),
5209
+ strokeWidth: strokeWidth * 0.6,
5210
+ size: echoSize,
5211
+ rotation: rotation + (e + 1) * 15,
5212
+ proportionType: "GOLDEN_RATIO",
5213
+ renderStyle: finalRenderStyle,
5214
+ rng: rng
5215
+ });
5216
+ shapePositions.push({
5217
+ x: echoX,
5218
+ y: echoY,
5219
+ size: echoSize,
5220
+ shape: shape
5221
+ });
5222
+ spatialGrid.insert({
5223
+ x: echoX,
5224
+ y: echoY,
5225
+ size: echoSize,
5226
+ shape: shape
5227
+ });
5228
+ }
5229
+ extrasSpent += echoCount * styleCost;
5003
5230
  }
5231
+ // RNG for echoCount + echoAngle consumed above regardless
5004
5232
  }
5005
5233
  // ── 5d. Recursive nesting ──────────────────────────────────
5006
5234
  // Focal depth: shapes near focal points get more detail
@@ -5008,7 +5236,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5008
5236
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
5009
5237
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
5010
5238
  const innerCount = 1 + Math.floor(rng() * 3);
5011
- for(let n = 0; n < innerCount; n++){
5239
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
5012
5240
  // Pick inner shape from palette affinities
5013
5241
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
5014
5242
  const innerShape = (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -5017,6 +5245,10 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5017
5245
  const innerOffY = (rng() - 0.5) * size * 0.4;
5018
5246
  const innerRot = rng() * 360;
5019
5247
  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);
5248
+ let innerStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5249
+ // Apply clip-heavy cap to nested shapes too
5250
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(innerStyle);
5251
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
5020
5252
  ctx.globalAlpha = layerOpacity * 0.7;
5021
5253
  (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
5022
5254
  fillColor: innerFill,
@@ -5025,9 +5257,21 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5025
5257
  size: innerSize,
5026
5258
  rotation: innerRot,
5027
5259
  proportionType: "GOLDEN_RATIO",
5028
- renderStyle: (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5260
+ renderStyle: innerStyle,
5029
5261
  rng: rng
5030
5262
  });
5263
+ extrasSpent += $4f72c5a314eddf25$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5264
+ }
5265
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5266
+ for(let n = 0; n < innerCount; n++){
5267
+ rng();
5268
+ rng();
5269
+ rng();
5270
+ rng();
5271
+ rng();
5272
+ rng();
5273
+ rng();
5274
+ rng();
5031
5275
  }
5032
5276
  }
5033
5277
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -5036,40 +5280,55 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5036
5280
  const constellation = $4f72c5a314eddf25$var$CONSTELLATIONS[Math.floor(rng() * $4f72c5a314eddf25$var$CONSTELLATIONS.length)];
5037
5281
  const members = constellation.build(rng, size);
5038
5282
  const groupRotation = rng() * Math.PI * 2;
5039
- const cosR = Math.cos(groupRotation);
5040
- const sinR = Math.sin(groupRotation);
5041
- for (const member of members){
5042
- // Rotate the group offset by the group rotation
5043
- const mx = finalX + member.dx * cosR - member.dy * sinR;
5044
- const my = finalY + member.dx * sinR + member.dy * cosR;
5045
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
5046
- const memberFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5047
- const memberStroke = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5048
- ctx.globalAlpha = layerOpacity * 0.6;
5049
- // Use the member's shape if available, otherwise fall back to palette
5050
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5051
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5052
- fillColor: memberFill,
5053
- strokeColor: memberStroke,
5054
- strokeWidth: strokeWidth * 0.7,
5055
- size: member.size,
5056
- rotation: member.rotation + groupRotation * 180 / Math.PI,
5057
- proportionType: "GOLDEN_RATIO",
5058
- renderStyle: (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
5059
- rng: rng
5060
- });
5061
- shapePositions.push({
5062
- x: mx,
5063
- y: my,
5064
- size: member.size,
5065
- shape: memberShape
5066
- });
5067
- spatialGrid.insert({
5068
- x: mx,
5069
- y: my,
5070
- size: member.size,
5071
- shape: memberShape
5072
- });
5283
+ if (extrasAllowed) {
5284
+ const cosR = Math.cos(groupRotation);
5285
+ const sinR = Math.sin(groupRotation);
5286
+ for (const member of members){
5287
+ // Rotate the group offset by the group rotation
5288
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5289
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5290
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5291
+ const memberFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5292
+ const memberStroke = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5293
+ ctx.globalAlpha = layerOpacity * 0.6;
5294
+ // Use the member's shape if available, otherwise fall back to palette
5295
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $e73976f898150d4d$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5296
+ let memberStyle = (0, $e73976f898150d4d$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5297
+ // Apply clip-heavy cap to constellation members too
5298
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $4f72c5a314eddf25$var$downgradeRenderStyle(memberStyle);
5299
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5300
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5301
+ fillColor: memberFill,
5302
+ strokeColor: memberStroke,
5303
+ strokeWidth: strokeWidth * 0.7,
5304
+ size: member.size,
5305
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5306
+ proportionType: "GOLDEN_RATIO",
5307
+ renderStyle: memberStyle,
5308
+ rng: rng
5309
+ });
5310
+ shapePositions.push({
5311
+ x: mx,
5312
+ y: my,
5313
+ size: member.size,
5314
+ shape: memberShape
5315
+ });
5316
+ spatialGrid.insert({
5317
+ x: mx,
5318
+ y: my,
5319
+ size: member.size,
5320
+ shape: memberShape
5321
+ });
5322
+ extrasSpent += $4f72c5a314eddf25$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5323
+ }
5324
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5325
+ for(let m = 0; m < members.length; m++){
5326
+ rng();
5327
+ rng();
5328
+ rng();
5329
+ rng();
5330
+ rng();
5331
+ rng();
5073
5332
  }
5074
5333
  }
5075
5334
  // ── 5f. Rhythm placement — deliberate geometric progressions ──
@@ -5080,45 +5339,58 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5080
5339
  const rhythmSpacing = size * (0.8 + rng() * 0.6);
5081
5340
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5082
5341
  const rhythmShape = shape; // same shape for visual rhythm
5083
- let rhythmSize = size * 0.6;
5342
+ if (extrasAllowed) {
5343
+ let rhythmSize = size * 0.6;
5344
+ for(let r = 0; r < rhythmCount; r++){
5345
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5346
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5347
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5348
+ if ($4f72c5a314eddf25$var$isInVoidZone(rx, ry, voidZones)) break;
5349
+ rhythmSize *= rhythmDecay;
5350
+ if (rhythmSize < adjustedMinSize) break;
5351
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5352
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5353
+ const rhythmFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5354
+ (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5355
+ fillColor: rhythmFill,
5356
+ strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.5),
5357
+ strokeWidth: strokeWidth * 0.7,
5358
+ size: rhythmSize,
5359
+ rotation: rotation + (r + 1) * 12,
5360
+ proportionType: "GOLDEN_RATIO",
5361
+ renderStyle: finalRenderStyle,
5362
+ rng: rng
5363
+ });
5364
+ shapePositions.push({
5365
+ x: rx,
5366
+ y: ry,
5367
+ size: rhythmSize,
5368
+ shape: rhythmShape
5369
+ });
5370
+ spatialGrid.insert({
5371
+ x: rx,
5372
+ y: ry,
5373
+ size: rhythmSize,
5374
+ shape: rhythmShape
5375
+ });
5376
+ }
5377
+ extrasSpent += rhythmCount * styleCost;
5378
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5084
5379
  for(let r = 0; r < rhythmCount; r++){
5085
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5086
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5087
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5088
- if ($4f72c5a314eddf25$var$isInVoidZone(rx, ry, voidZones)) break;
5089
- rhythmSize *= rhythmDecay;
5090
- if (rhythmSize < adjustedMinSize) break;
5091
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5092
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5093
- const rhythmFill = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$18a34c25ea7e724b)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5094
- (0, $c3de8257a8baa3b0$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5095
- fillColor: rhythmFill,
5096
- strokeColor: (0, $d016ad53434219a1$export$f2121afcad3d553f)(strokeColor, 0.5),
5097
- strokeWidth: strokeWidth * 0.7,
5098
- size: rhythmSize,
5099
- rotation: rotation + (r + 1) * 12,
5100
- proportionType: "GOLDEN_RATIO",
5101
- renderStyle: finalRenderStyle,
5102
- rng: rng
5103
- });
5104
- shapePositions.push({
5105
- x: rx,
5106
- y: ry,
5107
- size: rhythmSize,
5108
- shape: rhythmShape
5109
- });
5110
- spatialGrid.insert({
5111
- x: rx,
5112
- y: ry,
5113
- size: rhythmSize,
5114
- shape: rhythmShape
5115
- });
5380
+ rng();
5381
+ rng();
5382
+ rng();
5116
5383
  }
5117
5384
  }
5118
5385
  }
5119
5386
  }
5120
5387
  // Reset blend mode for post-processing passes
5121
5388
  ctx.globalCompositeOperation = "source-over";
5389
+ if (_dt) {
5390
+ _dt.shapeCount = shapePositions.length;
5391
+ _dt.extraCount = extrasSpent;
5392
+ }
5393
+ _mark("5_shape_layers");
5122
5394
  // ── 5g. Layered masking / cutout portals ───────────────────────
5123
5395
  // ~18% of images get 1-3 portal windows that paint over foreground
5124
5396
  // with a tinted background wash, creating a "peek through" effect.
@@ -5177,15 +5449,28 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5177
5449
  ctx.restore();
5178
5450
  }
5179
5451
  }
5452
+ _mark("5g_portals");
5180
5453
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5454
+ // Optimized: collect all segments into width-quantized buckets, then
5455
+ // render each bucket as a single batched path. This reduces
5456
+ // beginPath/stroke calls from O(segments) to O(buckets).
5181
5457
  const baseFlowLines = 6 + Math.floor(rng() * 10);
5182
5458
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5459
+ // Width buckets — 6 buckets cover the taper×pressure range
5460
+ const FLOW_WIDTH_BUCKETS = 6;
5461
+ const flowBuckets = [];
5462
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5463
+ // Track the representative width for each bucket
5464
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5465
+ // Pre-compute max possible width for bucket assignment
5466
+ let globalMaxFlowWidth = 0;
5183
5467
  for(let i = 0; i < numFlowLines; i++){
5184
5468
  let fx = rng() * width;
5185
5469
  let fy = rng() * height;
5186
5470
  const steps = 30 + Math.floor(rng() * 40);
5187
5471
  const stepLen = (3 + rng() * 5) * scaleFactor;
5188
5472
  const startWidth = (1 + rng() * 3) * scaleFactor;
5473
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
5189
5474
  // Variable color: interpolate between two hierarchy colors along the stroke
5190
5475
  const lineColorStart = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
5191
5476
  const lineColorEnd = (0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -5207,19 +5492,22 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5207
5492
  continue;
5208
5493
  }
5209
5494
  const t = s / steps;
5210
- // Taper + pressure
5211
5495
  const taper = 1 - t * 0.8;
5212
5496
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
5213
- ctx.globalAlpha = lineAlpha * taper;
5214
- // Interpolate color along stroke
5497
+ const segWidth = startWidth * taper * pressure;
5498
+ const segAlpha = lineAlpha * taper;
5215
5499
  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);
5216
- ctx.strokeStyle = lineColor;
5217
- ctx.lineWidth = startWidth * taper * pressure;
5218
- ctx.lineCap = "round";
5219
- ctx.beginPath();
5220
- ctx.moveTo(prevX, prevY);
5221
- ctx.lineTo(fx, fy);
5222
- ctx.stroke();
5500
+ // Quantize width into bucket
5501
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5502
+ flowBuckets[bucketIdx].push({
5503
+ x1: prevX,
5504
+ y1: prevY,
5505
+ x2: fx,
5506
+ y2: fy,
5507
+ color: lineColor,
5508
+ alpha: segAlpha
5509
+ });
5510
+ flowBucketWidths[bucketIdx] = segWidth;
5223
5511
  // Branching: ~12% chance per step to spawn a thinner child stroke
5224
5512
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
5225
5513
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -5235,12 +5523,18 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5235
5523
  by += Math.sin(bAngle) * stepLen * 0.8;
5236
5524
  if (bx < 0 || bx > width || by < 0 || by > height) break;
5237
5525
  const bTaper = 1 - bs / branchSteps * 0.9;
5238
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
5239
- ctx.lineWidth = branchWidth * bTaper;
5240
- ctx.beginPath();
5241
- ctx.moveTo(bPrevX, bPrevY);
5242
- ctx.lineTo(bx, by);
5243
- ctx.stroke();
5526
+ const bSegWidth = branchWidth * bTaper;
5527
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5528
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5529
+ flowBuckets[bBucket].push({
5530
+ x1: bPrevX,
5531
+ y1: bPrevY,
5532
+ x2: bx,
5533
+ y2: by,
5534
+ color: lineColor,
5535
+ alpha: bAlpha
5536
+ });
5537
+ flowBucketWidths[bBucket] = bSegWidth;
5244
5538
  bPrevX = bx;
5245
5539
  bPrevY = by;
5246
5540
  }
@@ -5249,7 +5543,41 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5249
5543
  prevY = fy;
5250
5544
  }
5251
5545
  }
5546
+ // Render flow line buckets — one batched path per width bucket
5547
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5548
+ ctx.lineCap = "round";
5549
+ const FLOW_ALPHA_BUCKETS = 4;
5550
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5551
+ const segs = flowBuckets[wb];
5552
+ if (segs.length === 0) continue;
5553
+ ctx.lineWidth = flowBucketWidths[wb];
5554
+ // Sub-bucket by alpha
5555
+ const alphaSubs = [];
5556
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5557
+ let maxAlpha = 0;
5558
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5559
+ for(let j = 0; j < segs.length; j++){
5560
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5561
+ alphaSubs[ai].push(segs[j]);
5562
+ }
5563
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5564
+ const sub = alphaSubs[ai];
5565
+ if (sub.length === 0) continue;
5566
+ // Use the median segment's alpha and color as representative
5567
+ const rep = sub[Math.floor(sub.length / 2)];
5568
+ ctx.globalAlpha = rep.alpha;
5569
+ ctx.strokeStyle = rep.color;
5570
+ ctx.beginPath();
5571
+ for(let j = 0; j < sub.length; j++){
5572
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5573
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5574
+ }
5575
+ ctx.stroke();
5576
+ }
5577
+ }
5578
+ _mark("6_flow_lines");
5252
5579
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5580
+ // Optimized: collect all burst segments, then batch by quantized alpha
5253
5581
  const energyArchetypes = [
5254
5582
  "dense-chaotic",
5255
5583
  "cosmic",
@@ -5260,8 +5588,12 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5260
5588
  if (hasEnergyLines && shapePositions.length > 0) {
5261
5589
  const energyCount = 5 + Math.floor(rng() * 10);
5262
5590
  ctx.lineCap = "round";
5591
+ // Collect all energy segments with their computed state
5592
+ const ENERGY_ALPHA_BUCKETS = 3;
5593
+ const energyBuckets = [];
5594
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5595
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5263
5596
  for(let e = 0; e < energyCount; e++){
5264
- // Pick a random shape to radiate from
5265
5597
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5266
5598
  const burstCount = 2 + Math.floor(rng() * 4);
5267
5599
  const baseAngle = flowAngle(source.x, source.y);
@@ -5273,16 +5605,40 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5273
5605
  const sy = source.y + Math.sin(angle) * startDist;
5274
5606
  const ex = sx + Math.cos(angle) * lineLen;
5275
5607
  const ey = sy + Math.sin(angle) * lineLen;
5276
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5277
- ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5278
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5279
- ctx.beginPath();
5280
- ctx.moveTo(sx, sy);
5281
- ctx.lineTo(ex, ey);
5282
- ctx.stroke();
5608
+ const eAlpha = 0.04 + rng() * 0.06;
5609
+ const eColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5610
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5611
+ // Quantize alpha into bucket
5612
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5613
+ energyBuckets[bi].push({
5614
+ x1: sx,
5615
+ y1: sy,
5616
+ x2: ex,
5617
+ y2: ey,
5618
+ color: eColor,
5619
+ lw: eLw
5620
+ });
5621
+ energyAlphas[bi] = eAlpha;
5283
5622
  }
5284
5623
  }
5624
+ // Render batched energy lines
5625
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5626
+ const segs = energyBuckets[bi];
5627
+ if (segs.length === 0) continue;
5628
+ ctx.globalAlpha = energyAlphas[bi];
5629
+ // Use median segment's color and width as representative
5630
+ const rep = segs[Math.floor(segs.length / 2)];
5631
+ ctx.strokeStyle = rep.color;
5632
+ ctx.lineWidth = rep.lw;
5633
+ ctx.beginPath();
5634
+ for(let j = 0; j < segs.length; j++){
5635
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5636
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5637
+ }
5638
+ ctx.stroke();
5639
+ }
5285
5640
  }
5641
+ _mark("6b_energy_lines");
5286
5642
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
5287
5643
  if (symmetryMode !== "none") {
5288
5644
  const canvas = ctx.canvas;
@@ -5303,43 +5659,25 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5303
5659
  }
5304
5660
  ctx.restore();
5305
5661
  }
5306
- // ── 7. Noise texture overlay — batched via ImageData ─────────────
5662
+ _mark("6c_symmetry");
5663
+ // ── 7. Noise texture overlay ─────────────────────────────────────
5664
+ // With density capped at 2500 dots, direct fillRect calls are far cheaper
5665
+ // than the getImageData/putImageData round-trip which copies the entire
5666
+ // pixel buffer (4 × width × height bytes) twice.
5307
5667
  const noiseRng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash, 777));
5308
- const noiseDensity = Math.floor(width * height / 800);
5309
- try {
5310
- const imageData = ctx.getImageData(0, 0, width, height);
5311
- const data = imageData.data;
5312
- const pixelScale = Math.max(1, Math.round(scaleFactor));
5313
- for(let i = 0; i < noiseDensity; i++){
5314
- const nx = Math.floor(noiseRng() * width);
5315
- const ny = Math.floor(noiseRng() * height);
5316
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5317
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
5318
- // Write a small block of pixels for scale
5319
- for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5320
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
5321
- // Alpha-blend the noise dot onto existing pixel data
5322
- const srcA = alpha / 255;
5323
- const invA = 1 - srcA;
5324
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
5325
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
5326
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
5327
- // Keep existing alpha
5328
- }
5329
- }
5330
- ctx.putImageData(imageData, 0, 0);
5331
- } catch {
5332
- // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5333
- for(let i = 0; i < noiseDensity; i++){
5334
- const nx = noiseRng() * width;
5335
- const ny = noiseRng() * height;
5336
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5337
- const alpha = 0.01 + noiseRng() * 0.03;
5338
- ctx.globalAlpha = alpha;
5339
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5340
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5341
- }
5668
+ const rawNoiseDensity = Math.floor(width * height / 800);
5669
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5670
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5671
+ for(let i = 0; i < noiseDensity; i++){
5672
+ const nx = noiseRng() * width;
5673
+ const ny = noiseRng() * height;
5674
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5675
+ const alpha = 0.01 + noiseRng() * 0.03;
5676
+ ctx.globalAlpha = alpha;
5677
+ ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
5678
+ ctx.fillRect(nx, ny, pixelScale, pixelScale);
5342
5679
  }
5680
+ _mark("7_noise_texture");
5343
5681
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5344
5682
  ctx.globalAlpha = 1;
5345
5683
  const vignetteStrength = 0.25 + rng() * 0.2;
@@ -5353,11 +5691,20 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5353
5691
  vigGrad.addColorStop(1, vignetteColor);
5354
5692
  ctx.fillStyle = vigGrad;
5355
5693
  ctx.fillRect(0, 0, width, height);
5694
+ _mark("8_vignette");
5356
5695
  // ── 9. Organic connecting curves — proximity-aware ───────────────
5696
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5697
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5357
5698
  if (shapePositions.length > 1) {
5358
5699
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5359
5700
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5360
5701
  ctx.lineWidth = 0.8 * scaleFactor;
5702
+ // Collect curves into 3 alpha buckets
5703
+ const CURVE_ALPHA_BUCKETS = 3;
5704
+ const curveBuckets = [];
5705
+ const curveColors = [];
5706
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5707
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5361
5708
  for(let i = 0; i < numCurves; i++){
5362
5709
  const idxA = Math.floor(rng() * shapePositions.length);
5363
5710
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
@@ -5374,14 +5721,36 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5374
5721
  const bulge = (rng() - 0.5) * dist * 0.4;
5375
5722
  const cpx = mx + -dy / (dist || 1) * bulge;
5376
5723
  const cpy = my + dx / (dist || 1) * bulge;
5377
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5378
- ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5724
+ const curveAlpha = 0.06 + rng() * 0.1;
5725
+ const curveColor = (0, $d016ad53434219a1$export$f2121afcad3d553f)((0, $d016ad53434219a1$export$90ad0e6170cf6af5)((0, $d016ad53434219a1$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5726
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5727
+ curveBuckets[bi].push({
5728
+ ax: a.x,
5729
+ ay: a.y,
5730
+ cpx: cpx,
5731
+ cpy: cpy,
5732
+ bx: b.x,
5733
+ by: b.y
5734
+ });
5735
+ curveAlphas[bi] = curveAlpha;
5736
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5737
+ }
5738
+ // Render batched curves
5739
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5740
+ const curves = curveBuckets[bi];
5741
+ if (curves.length === 0) continue;
5742
+ ctx.globalAlpha = curveAlphas[bi];
5743
+ ctx.strokeStyle = curveColors[bi];
5379
5744
  ctx.beginPath();
5380
- ctx.moveTo(a.x, a.y);
5381
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5745
+ for(let j = 0; j < curves.length; j++){
5746
+ const c = curves[j];
5747
+ ctx.moveTo(c.ax, c.ay);
5748
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5749
+ }
5382
5750
  ctx.stroke();
5383
5751
  }
5384
5752
  }
5753
+ _mark("9_connecting_curves");
5385
5754
  // ── 10. Post-processing ────────────────────────────────────────
5386
5755
  // 10a. Color grading — unified tone across the whole image
5387
5756
  // Apply as a semi-transparent overlay in the grade hue
@@ -5441,6 +5810,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5441
5810
  ctx.fillRect(0, 0, width, height);
5442
5811
  ctx.globalCompositeOperation = "source-over";
5443
5812
  }
5813
+ _mark("10_post_processing");
5444
5814
  // ── 10e. Generative borders — archetype-driven decorative frames ──
5445
5815
  {
5446
5816
  ctx.save();
@@ -5496,11 +5866,14 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5496
5866
  }
5497
5867
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5498
5868
  // Vine tendrils — organic curving lines along edges
5869
+ // Optimized: batch all tendrils into a single path
5499
5870
  ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5500
5871
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5501
5872
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5502
5873
  ctx.lineCap = "round";
5503
5874
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5875
+ ctx.beginPath();
5876
+ const leafPositions = [];
5504
5877
  for(let t = 0; t < tendrilCount; t++){
5505
5878
  // Start from a random edge point
5506
5879
  const edge = Math.floor(borderRng() * 4);
@@ -5518,7 +5891,6 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5518
5891
  tx = width - borderPad;
5519
5892
  ty = borderRng() * height;
5520
5893
  }
5521
- ctx.beginPath();
5522
5894
  ctx.moveTo(tx, ty);
5523
5895
  const segs = 3 + Math.floor(borderRng() * 4);
5524
5896
  for(let s = 0; s < segs; s++){
@@ -5532,14 +5904,23 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5532
5904
  ty = cpy3;
5533
5905
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5534
5906
  }
5535
- ctx.stroke();
5536
- // Small leaf/dot at tendril end
5537
- if (borderRng() < 0.6) {
5538
- ctx.beginPath();
5539
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5540
- ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5541
- ctx.fill();
5907
+ // Collect leaf positions for batch fill
5908
+ if (borderRng() < 0.6) leafPositions.push({
5909
+ x: tx,
5910
+ y: ty,
5911
+ r: borderPad * (0.15 + borderRng() * 0.2)
5912
+ });
5913
+ }
5914
+ ctx.stroke();
5915
+ // Batch all leaf dots into a single fill
5916
+ if (leafPositions.length > 0) {
5917
+ ctx.fillStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5918
+ ctx.beginPath();
5919
+ for (const leaf of leafPositions){
5920
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5921
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5542
5922
  }
5923
+ ctx.fill();
5543
5924
  }
5544
5925
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5545
5926
  // Star-studded arcs along edges
@@ -5554,8 +5935,9 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5554
5935
  ctx.beginPath();
5555
5936
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5556
5937
  ctx.stroke();
5557
- // Scatter small stars along the border region
5938
+ // Scatter small stars along the border region — batched into single path
5558
5939
  const starCount = 15 + Math.floor(borderRng() * 15);
5940
+ ctx.beginPath();
5559
5941
  for(let s = 0; s < starCount; s++){
5560
5942
  const edge = Math.floor(borderRng() * 4);
5561
5943
  let sx, sy;
@@ -5574,7 +5956,6 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5574
5956
  }
5575
5957
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5576
5958
  // 4-point star
5577
- ctx.beginPath();
5578
5959
  for(let p = 0; p < 8; p++){
5579
5960
  const a = p / 8 * Math.PI * 2;
5580
5961
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5584,8 +5965,8 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5584
5965
  else ctx.lineTo(px2, py2);
5585
5966
  }
5586
5967
  ctx.closePath();
5587
- ctx.fill();
5588
5968
  }
5969
+ ctx.fill();
5589
5970
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5590
5971
  // Thin single rule — understated elegance
5591
5972
  ctx.strokeStyle = (0, $d016ad53434219a1$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5596,6 +5977,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5596
5977
  // Other archetypes: no border (intentional — not every image needs one)
5597
5978
  ctx.restore();
5598
5979
  }
5980
+ _mark("10e_borders");
5599
5981
  // ── 11. Signature mark — placed in the least-dense corner ──────
5600
5982
  {
5601
5983
  const sigRng = (0, $e4b03e131ed2a289$export$eaf9227667332084)((0, $e4b03e131ed2a289$export$e9cc707de01b7042)(gitHash, 42));
@@ -5663,6 +6045,7 @@ function $4f72c5a314eddf25$export$29a844702096332e(ctx, gitHash, config = {}) {
5663
6045
  ctx.restore();
5664
6046
  }
5665
6047
  ctx.globalAlpha = 1;
6048
+ _mark("11_signature");
5666
6049
  }
5667
6050
 
5668
6051