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/browser.js CHANGED
@@ -508,13 +508,21 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
508
508
  }
509
509
  }
510
510
  // ── Standalone color utilities ──────────────────────────────────────
511
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
512
- const c = hex.replace("#", "");
513
- return [
511
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
512
+ const $b5a262d09b87e373$var$_rgbCache = new Map();
513
+ const $b5a262d09b87e373$var$_RGB_CACHE_MAX = 512;
514
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $b5a262d09b87e373$var$hexToRgb(hex) {
515
+ let cached = $b5a262d09b87e373$var$_rgbCache.get(hex);
516
+ if (cached) return cached;
517
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
518
+ cached = [
514
519
  parseInt(c.substring(0, 2), 16),
515
520
  parseInt(c.substring(2, 4), 16),
516
521
  parseInt(c.substring(4, 6), 16)
517
522
  ];
523
+ if ($b5a262d09b87e373$var$_rgbCache.size >= $b5a262d09b87e373$var$_RGB_CACHE_MAX) $b5a262d09b87e373$var$_rgbCache.clear();
524
+ $b5a262d09b87e373$var$_rgbCache.set(hex, cached);
525
+ return cached;
518
526
  }
519
527
  /** Format [r, g, b] back to #RRGGBB. */ function $b5a262d09b87e373$var$rgbToHex(r, g, b) {
520
528
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -571,7 +579,9 @@ class $b5a262d09b87e373$export$ab958c550f521376 {
571
579
  }
572
580
  function $b5a262d09b87e373$export$f2121afcad3d553f(hex, alpha) {
573
581
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex);
574
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
582
+ // Quantize alpha to 3 decimal places without toFixed overhead
583
+ const a = Math.round(alpha * 1000) / 1000;
584
+ return `rgba(${r},${g},${b},${a})`;
575
585
  }
576
586
  function $b5a262d09b87e373$export$fabac4600b87056(colors, rng) {
577
587
  if (colors.length < 3) return {
@@ -651,12 +661,21 @@ function $b5a262d09b87e373$export$51ea55f869b7e0d3(hex, target, amount) {
651
661
  const [h, s, l] = $b5a262d09b87e373$var$hexToHsl(hex);
652
662
  return $b5a262d09b87e373$var$hslToHex($b5a262d09b87e373$var$shiftHueToward(h, target, amount), s, l);
653
663
  }
664
+ /**
665
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
666
+ * Uses the sRGB luminance formula from WCAG. Cached.
667
+ */ const $b5a262d09b87e373$var$_lumCache = new Map();
654
668
  function $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(hex) {
669
+ let cached = $b5a262d09b87e373$var$_lumCache.get(hex);
670
+ if (cached !== undefined) return cached;
655
671
  const [r, g, b] = $b5a262d09b87e373$var$hexToRgb(hex).map((c)=>{
656
672
  const s = c / 255;
657
673
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
658
674
  });
659
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
675
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
676
+ if ($b5a262d09b87e373$var$_lumCache.size >= 512) $b5a262d09b87e373$var$_lumCache.clear();
677
+ $b5a262d09b87e373$var$_lumCache.set(hex, cached);
678
+ return cached;
660
679
  }
661
680
  function $b5a262d09b87e373$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
662
681
  const fgLum = $b5a262d09b87e373$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1092,21 +1111,31 @@ const $f0f1a7293548e501$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1092
1111
  (0, $ce2c52df8af02e62$export$e46c5570db033611)(ctx, size, finalConfig);
1093
1112
  const gridSize = 8;
1094
1113
  const unit = size / gridSize;
1114
+ const radius = unit / 2;
1115
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1116
+ const starPoints = [];
1117
+ for(let k = 0; k < 8; k++){
1118
+ const angle = Math.PI / 4 * k;
1119
+ const angle2 = angle + Math.PI / 4;
1120
+ starPoints.push({
1121
+ c1: Math.cos(angle) * radius,
1122
+ s1: Math.sin(angle) * radius,
1123
+ c2: Math.cos(angle2) * radius,
1124
+ s2: Math.sin(angle2) * radius
1125
+ });
1126
+ }
1095
1127
  ctx.beginPath();
1096
1128
  // Create base grid
1097
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1129
+ for(let i = 0; i <= gridSize; i++){
1098
1130
  const x = (i - gridSize / 2) * unit;
1099
- const y = (j - gridSize / 2) * unit;
1100
- // Draw star pattern at each intersection
1101
- const radius = unit / 2;
1102
- for(let k = 0; k < 8; k++){
1103
- const angle = Math.PI / 4 * k;
1104
- const x1 = x + radius * Math.cos(angle);
1105
- const y1 = y + radius * Math.sin(angle);
1106
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1107
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1108
- ctx.moveTo(x1, y1);
1109
- ctx.lineTo(x2, y2);
1131
+ for(let j = 0; j <= gridSize; j++){
1132
+ const y = (j - gridSize / 2) * unit;
1133
+ // Draw star pattern at each intersection using pre-computed offsets
1134
+ for(let k = 0; k < 8; k++){
1135
+ const sp = starPoints[k];
1136
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1137
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1138
+ }
1110
1139
  }
1111
1140
  }
1112
1141
  ctx.stroke();
@@ -1426,20 +1455,23 @@ const $77711f013715e6da$export$eeae7765f05012e2 = (ctx, size)=>{
1426
1455
  const $77711f013715e6da$export$3355220a8108efc3 = (ctx, size)=>{
1427
1456
  const outerRadius = size / 2;
1428
1457
  const innerRadius = size / 4;
1429
- const steps = 36;
1458
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1459
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1460
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1461
+ const TWO_PI = Math.PI * 2;
1462
+ const angleStep = TWO_PI / steps;
1430
1463
  ctx.beginPath();
1431
1464
  for(let i = 0; i < steps; i++){
1432
- const angle1 = i / steps * Math.PI * 2;
1433
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1465
+ const angle1 = i * angleStep;
1466
+ const cosA = Math.cos(angle1);
1467
+ const sinA = Math.sin(angle1);
1434
1468
  for(let j = 0; j < steps; j++){
1435
- const phi1 = j / steps * Math.PI * 2;
1436
- const phi2 = (j + 1) / steps * Math.PI * 2;
1437
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1438
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1439
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1440
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1441
- ctx.moveTo(x1, y1);
1442
- ctx.lineTo(x2, y2);
1469
+ const phi1 = j * angleStep;
1470
+ const phi2 = phi1 + angleStep;
1471
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1472
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1473
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1474
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1443
1475
  }
1444
1476
  }
1445
1477
  };
@@ -2002,6 +2034,43 @@ const $e0f99502ff383dd8$var$RENDER_STYLES = [
2002
2034
  function $e0f99502ff383dd8$export$9fd4e64b2acd410e(rng) {
2003
2035
  return $e0f99502ff383dd8$var$RENDER_STYLES[Math.floor(rng() * $e0f99502ff383dd8$var$RENDER_STYLES.length)];
2004
2036
  }
2037
+ const $e0f99502ff383dd8$export$2f738f61a8c15e07 = {
2038
+ "fill-and-stroke": 1,
2039
+ "fill-only": 0.5,
2040
+ "stroke-only": 1,
2041
+ "double-stroke": 1.5,
2042
+ "dashed": 1,
2043
+ "watercolor": 7,
2044
+ "hatched": 3,
2045
+ "incomplete": 1,
2046
+ "stipple": 90,
2047
+ "stencil": 2,
2048
+ "noise-grain": 400,
2049
+ "wood-grain": 10,
2050
+ "marble-vein": 4,
2051
+ "fabric-weave": 6,
2052
+ "hand-drawn": 5
2053
+ };
2054
+ function $e0f99502ff383dd8$export$909ab0580e273f19(style) {
2055
+ switch(style){
2056
+ case "noise-grain":
2057
+ return "hatched";
2058
+ case "stipple":
2059
+ return "dashed";
2060
+ case "wood-grain":
2061
+ return "hatched";
2062
+ case "watercolor":
2063
+ return "fill-and-stroke";
2064
+ case "fabric-weave":
2065
+ return "hatched";
2066
+ case "hand-drawn":
2067
+ return "fill-and-stroke";
2068
+ case "marble-vein":
2069
+ return "stroke-only";
2070
+ default:
2071
+ return style;
2072
+ }
2073
+ }
2005
2074
  function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2006
2075
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2007
2076
  ctx.save();
@@ -2121,6 +2190,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2121
2190
  case "hatched":
2122
2191
  {
2123
2192
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2193
+ // Optimized: batch all parallel lines into a single path per pass
2124
2194
  const savedAlphaH = ctx.globalAlpha;
2125
2195
  ctx.globalAlpha = savedAlphaH * 0.3;
2126
2196
  ctx.fill();
@@ -2132,28 +2202,28 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2132
2202
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2133
2203
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2134
2204
  ctx.globalAlpha = savedAlphaH * 0.6;
2135
- // Draw parallel lines across the bounding box
2205
+ // Draw parallel lines across the bounding box — batched into single path
2136
2206
  const extent = size * 0.8;
2137
2207
  const cos = Math.cos(hatchAngle);
2138
2208
  const sin = Math.sin(hatchAngle);
2209
+ ctx.beginPath();
2139
2210
  for(let d = -extent; d <= extent; d += hatchSpacing){
2140
- ctx.beginPath();
2141
2211
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2142
2212
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2143
- ctx.stroke();
2144
2213
  }
2214
+ ctx.stroke();
2145
2215
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2146
2216
  if (!rng || rng() < 0.5) {
2147
2217
  const crossAngle = hatchAngle + Math.PI / 2;
2148
2218
  const cos2 = Math.cos(crossAngle);
2149
2219
  const sin2 = Math.sin(crossAngle);
2150
2220
  ctx.globalAlpha = savedAlphaH * 0.35;
2221
+ ctx.beginPath();
2151
2222
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2152
- ctx.beginPath();
2153
2223
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2154
2224
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2155
- ctx.stroke();
2156
2225
  }
2226
+ ctx.stroke();
2157
2227
  }
2158
2228
  ctx.restore();
2159
2229
  ctx.globalAlpha = savedAlphaH;
@@ -2191,6 +2261,8 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2191
2261
  case "stipple":
2192
2262
  {
2193
2263
  // Dot-fill texture — clip to shape, then scatter dots
2264
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2265
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2194
2266
  const savedAlphaS = ctx.globalAlpha;
2195
2267
  ctx.globalAlpha = savedAlphaS * 0.15;
2196
2268
  ctx.fill(); // ghost fill
@@ -2198,16 +2270,20 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2198
2270
  ctx.save();
2199
2271
  ctx.clip();
2200
2272
  const dotSpacing = Math.max(2, size * 0.03);
2201
- const extent = size * 0.55;
2273
+ const extentS = size * 0.55;
2274
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2275
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2276
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2202
2277
  ctx.globalAlpha = savedAlphaS * 0.7;
2203
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2204
- // Jitter each dot position for organic feel
2205
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2206
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2207
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2208
- ctx.beginPath();
2209
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2210
- ctx.fill();
2278
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2279
+ const dx = -extentS + xi * actualSpacing;
2280
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2281
+ const dy = -extentS + yi * actualSpacing;
2282
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2283
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2284
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2285
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2286
+ }
2211
2287
  }
2212
2288
  ctx.restore();
2213
2289
  ctx.globalAlpha = savedAlphaS;
@@ -2240,6 +2316,9 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2240
2316
  case "noise-grain":
2241
2317
  {
2242
2318
  // Procedural noise grain texture clipped to shape boundary
2319
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2320
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2321
+ // and batch dots by brightness (black/white) × alpha bucket
2243
2322
  const savedAlphaN = ctx.globalAlpha;
2244
2323
  ctx.globalAlpha = savedAlphaN * 0.25;
2245
2324
  ctx.fill(); // base tint
@@ -2248,17 +2327,47 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2248
2327
  ctx.clip();
2249
2328
  const grainSpacing = Math.max(1.5, size * 0.015);
2250
2329
  const extentN = size * 0.55;
2251
- ctx.globalAlpha = savedAlphaN * 0.6;
2252
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2253
- if (!rng) break;
2254
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2255
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2256
- const brightness = rng() > 0.5 ? 255 : 0;
2257
- const dotAlpha = 0.15 + rng() * 0.35;
2258
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2259
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2260
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2261
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2330
+ if (rng) {
2331
+ // Cap grid to max 40 dots per axis beyond this the grain is
2332
+ // visually indistinguishable but cost scales quadratically.
2333
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2334
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2335
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2336
+ const BUCKETS = 4;
2337
+ const bucketMin = 0.15;
2338
+ const bucketRange = 0.35;
2339
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2340
+ const buckets = [];
2341
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2342
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2343
+ const gx = -extentN + xi * actualGrainSpacing;
2344
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2345
+ const gy = -extentN + yi * actualGrainSpacing;
2346
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2347
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2348
+ const isWhite = rng() > 0.5;
2349
+ const dotAlpha = bucketMin + rng() * bucketRange;
2350
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2351
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2352
+ const offset = isWhite ? BUCKETS : 0;
2353
+ buckets[offset + bucketIdx].push({
2354
+ x: gx + jx,
2355
+ y: gy + jy,
2356
+ s: dotSize
2357
+ });
2358
+ }
2359
+ }
2360
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2361
+ for(let color = 0; color < 2; color++){
2362
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2363
+ for(let b = 0; b < BUCKETS; b++){
2364
+ const dots = buckets[color * BUCKETS + b];
2365
+ if (dots.length === 0) continue;
2366
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2367
+ ctx.globalAlpha = savedAlphaN * alpha;
2368
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2369
+ }
2370
+ }
2262
2371
  }
2263
2372
  ctx.restore();
2264
2373
  ctx.fillStyle = fillColor;
@@ -2271,6 +2380,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2271
2380
  case "wood-grain":
2272
2381
  {
2273
2382
  // Parallel wavy lines simulating wood grain, clipped to shape
2383
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2274
2384
  const savedAlphaW = ctx.globalAlpha;
2275
2385
  ctx.globalAlpha = savedAlphaW * 0.2;
2276
2386
  ctx.fill(); // base tint
@@ -2286,17 +2396,19 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2286
2396
  ctx.globalAlpha = savedAlphaW * 0.5;
2287
2397
  const cosG = Math.cos(grainAngle);
2288
2398
  const sinG = Math.sin(grainAngle);
2399
+ const waveCoeff = waveFreq * Math.PI;
2400
+ const invExtentW = 1 / extentW;
2401
+ // Batch all grain lines into a single path
2402
+ ctx.beginPath();
2289
2403
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2290
- ctx.beginPath();
2291
- for(let t = -extentW; t <= extentW; t += 2){
2292
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2293
- const px = t * cosG - (d + wave) * sinG;
2294
- const py = t * sinG + (d + wave) * cosG;
2295
- if (t === -extentW) ctx.moveTo(px, py);
2296
- else ctx.lineTo(px, py);
2404
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2405
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2406
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2407
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2408
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2297
2409
  }
2298
- ctx.stroke();
2299
2410
  }
2411
+ ctx.stroke();
2300
2412
  ctx.restore();
2301
2413
  ctx.globalAlpha = savedAlphaW;
2302
2414
  ctx.globalAlpha *= 0.35;
@@ -2358,6 +2470,7 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2358
2470
  case "fabric-weave":
2359
2471
  {
2360
2472
  // Interlocking horizontal/vertical threads clipped to shape
2473
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2361
2474
  const savedAlphaF = ctx.globalAlpha;
2362
2475
  ctx.globalAlpha = savedAlphaF * 0.15;
2363
2476
  ctx.fill(); // ghost base
@@ -2367,26 +2480,24 @@ function $e0f99502ff383dd8$export$71b514a25c47df50(ctx, shape, x, y, config) {
2367
2480
  const threadSpacing = Math.max(2, size * 0.04);
2368
2481
  const extentF = size * 0.55;
2369
2482
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2483
+ // Horizontal threads — batched
2370
2484
  ctx.globalAlpha = savedAlphaF * 0.55;
2371
- // Horizontal threads
2485
+ ctx.beginPath();
2372
2486
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2373
- ctx.beginPath();
2374
2487
  ctx.moveTo(-extentF, y);
2375
2488
  ctx.lineTo(extentF, y);
2376
- ctx.stroke();
2377
2489
  }
2378
- // Vertical threads (offset by half spacing for weave effect)
2490
+ ctx.stroke();
2491
+ // Vertical threads (offset by half spacing for weave effect) — batched
2379
2492
  ctx.globalAlpha = savedAlphaF * 0.45;
2380
2493
  ctx.strokeStyle = fillColor;
2381
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2382
- ctx.beginPath();
2383
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2384
- // Over-under: draw segment, skip segment
2385
- ctx.moveTo(x, y);
2386
- ctx.lineTo(x, y + threadSpacing);
2387
- }
2388
- ctx.stroke();
2494
+ ctx.beginPath();
2495
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2496
+ // Over-under: draw segment, skip segment
2497
+ ctx.moveTo(x, y);
2498
+ ctx.lineTo(x, y + threadSpacing);
2389
2499
  }
2500
+ ctx.stroke();
2390
2501
  ctx.strokeStyle = strokeColor;
2391
2502
  ctx.restore();
2392
2503
  ctx.globalAlpha = savedAlphaF;
@@ -2452,14 +2563,17 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2452
2563
  ctx.translate(x, y);
2453
2564
  ctx.rotate(rotation * Math.PI / 180);
2454
2565
  // ── Drop shadow — soft colored shadow offset along light direction ──
2455
- if (lightAngle !== undefined && size > 10) {
2566
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2567
+ // and visually imperceptible at that scale.
2568
+ const useShadow = size >= 20;
2569
+ if (useShadow && lightAngle !== undefined) {
2456
2570
  const shadowDist = size * 0.035;
2457
2571
  const shadowBlurR = size * 0.06;
2458
2572
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2459
2573
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2460
2574
  ctx.shadowBlur = shadowBlurR;
2461
2575
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2462
- } else if (glowRadius > 0) {
2576
+ } else if (useShadow && glowRadius > 0) {
2463
2577
  // Glow / shadow effect (legacy path)
2464
2578
  ctx.shadowBlur = glowRadius;
2465
2579
  ctx.shadowColor = glowColor || fillColor;
@@ -2483,30 +2597,27 @@ function $e0f99502ff383dd8$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2483
2597
  $e0f99502ff383dd8$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2484
2598
  }
2485
2599
  // Reset shadow so patterns and highlight aren't double-shadowed
2486
- ctx.shadowBlur = 0;
2487
- ctx.shadowOffsetX = 0;
2488
- ctx.shadowOffsetY = 0;
2489
- ctx.shadowColor = "transparent";
2600
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2601
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2602
+ ctx.shadowBlur = 0;
2603
+ ctx.shadowOffsetX = 0;
2604
+ ctx.shadowOffsetY = 0;
2605
+ ctx.shadowColor = "transparent";
2606
+ }
2490
2607
  // ── Specular highlight — tinted arc on the light-facing side ──
2491
- if (lightAngle !== undefined && size > 15 && rng) {
2608
+ // Skip for small shapes (< 30px) gradient creation + composite op
2609
+ // switch is expensive and the highlight is invisible at small sizes.
2610
+ if (lightAngle !== undefined && size > 30 && rng) {
2492
2611
  const hlRadius = size * 0.35;
2493
2612
  const hlDist = size * 0.15;
2494
2613
  const hlX = Math.cos(lightAngle) * hlDist;
2495
2614
  const hlY = Math.sin(lightAngle) * hlDist;
2496
2615
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2497
- // Tint highlight warm/cool based on fill color for cohesion
2498
- // Parse fill to detect warmth fallback to white for non-parseable
2499
- let hlBase = "255,255,255";
2500
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
2501
- const r = parseInt(fillColor.slice(1, 3), 16);
2502
- const g = parseInt(fillColor.slice(3, 5), 16);
2503
- const b = parseInt(fillColor.slice(5, 7), 16);
2504
- // Blend toward white but keep a hint of the fill's warmth
2505
- hlBase = `${Math.round(r * 0.15 + 216.75)},${Math.round(g * 0.15 + 216.75)},${Math.round(b * 0.15 + 216.75)}`;
2506
- }
2507
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
2508
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
2509
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
2616
+ // Use a simple white highlight the per-shape hex parse was expensive
2617
+ // and the visual difference from tinted highlights is negligible.
2618
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2619
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2620
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
2510
2621
  const savedOp = ctx.globalCompositeOperation;
2511
2622
  ctx.globalCompositeOperation = "soft-light";
2512
2623
  ctx.fillStyle = hlGrad;
@@ -4048,6 +4159,46 @@ function $68a238ccd77f2bcd$export$f1142fd7da4d6590(rng) {
4048
4159
  }
4049
4160
 
4050
4161
 
4162
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4163
+ // Based on benchmark measurements. Used by the complexity budget to
4164
+ // cap total rendering work and downgrade expensive styles when needed.
4165
+ const $1f63dc64b5593c73$var$RENDER_STYLE_COST = {
4166
+ "fill-and-stroke": 1,
4167
+ "fill-only": 0.5,
4168
+ "stroke-only": 1,
4169
+ "double-stroke": 1.5,
4170
+ "dashed": 1,
4171
+ "watercolor": 7,
4172
+ "hatched": 3,
4173
+ "incomplete": 1,
4174
+ "stipple": 90,
4175
+ "stencil": 2,
4176
+ "noise-grain": 400,
4177
+ "wood-grain": 10,
4178
+ "marble-vein": 4,
4179
+ "fabric-weave": 6,
4180
+ "hand-drawn": 5
4181
+ };
4182
+ function $1f63dc64b5593c73$var$downgradeRenderStyle(style) {
4183
+ switch(style){
4184
+ case "noise-grain":
4185
+ return "hatched";
4186
+ case "stipple":
4187
+ return "dashed";
4188
+ case "wood-grain":
4189
+ return "hatched";
4190
+ case "watercolor":
4191
+ return "fill-and-stroke";
4192
+ case "fabric-weave":
4193
+ return "hatched";
4194
+ case "hand-drawn":
4195
+ return "fill-and-stroke";
4196
+ case "marble-vein":
4197
+ return "stroke-only";
4198
+ default:
4199
+ return style;
4200
+ }
4201
+ }
4051
4202
  // ── Shape categories for weighted selection (legacy fallback) ───────
4052
4203
  const $1f63dc64b5593c73$var$SACRED_SHAPES = [
4053
4204
  "mandala",
@@ -4431,6 +4582,15 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4431
4582
  ...(0, $81c1b644006d48ec$export$c2f8e0cc249a8d8f),
4432
4583
  ...config
4433
4584
  };
4585
+ const _dt = finalConfig._debugTiming;
4586
+ const _t = _dt ? ()=>performance.now() : undefined;
4587
+ let _p = _t ? _t() : 0;
4588
+ function _mark(name) {
4589
+ if (!_dt || !_t) return;
4590
+ const now = _t();
4591
+ _dt.phases[name] = now - _p;
4592
+ _p = now;
4593
+ }
4434
4594
  const rng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash));
4435
4595
  // ── 0. Select archetype — fundamentally different visual personality ──
4436
4596
  const archetype = (0, $68a238ccd77f2bcd$export$f1142fd7da4d6590)(rng);
@@ -4464,12 +4624,14 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4464
4624
  const adjustedMaxSize = maxShapeSize * scaleFactor;
4465
4625
  const cx = width / 2;
4466
4626
  const cy = height / 2;
4627
+ _mark("0_setup");
4467
4628
  // ── 1. Background ──────────────────────────────────────────────
4468
4629
  const bgRadius = Math.hypot(cx, cy);
4469
4630
  $1f63dc64b5593c73$var$drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
4470
4631
  // Gradient mesh overlay — 3-4 color control points for richer backgrounds
4632
+ // Use source-over instead of soft-light for cheaper compositing
4471
4633
  const meshPoints = 3 + Math.floor(rng() * 2);
4472
- ctx.globalCompositeOperation = "soft-light";
4634
+ ctx.globalAlpha = 1;
4473
4635
  for(let i = 0; i < meshPoints; i++){
4474
4636
  const mx = rng() * width;
4475
4637
  const my = rng() * height;
@@ -4478,95 +4640,103 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4478
4640
  const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
4479
4641
  grad.addColorStop(0, (0, $b5a262d09b87e373$export$f2121afcad3d553f)(mColor, 0.08 + rng() * 0.06));
4480
4642
  grad.addColorStop(1, "rgba(0,0,0,0)");
4481
- ctx.globalAlpha = 1;
4482
4643
  ctx.fillStyle = grad;
4483
- ctx.fillRect(0, 0, width, height);
4644
+ // Clip to gradient bounding box — avoids blending transparent pixels
4645
+ const gx = Math.max(0, mx - mRadius);
4646
+ const gy = Math.max(0, my - mRadius);
4647
+ const gw = Math.min(width, mx + mRadius) - gx;
4648
+ const gh = Math.min(height, my + mRadius) - gy;
4649
+ ctx.fillRect(gx, gy, gw, gh);
4484
4650
  }
4485
- ctx.globalCompositeOperation = "source-over";
4486
4651
  // Compute average background luminance for contrast enforcement
4487
4652
  const bgLum = ((0, $b5a262d09b87e373$export$5c6e3c2b59b7fbbe)(bgStart) + (0, $b5a262d09b87e373$export$5c6e3c2b59b7fbbe)(bgEnd)) / 2;
4488
4653
  // ── 1b. Layered background — archetype-coherent shapes ─────────
4654
+ // Use source-over with pre-multiplied alpha instead of soft-light
4655
+ // for much cheaper compositing (soft-light requires per-pixel blend)
4489
4656
  const bgShapeCount = 3 + Math.floor(rng() * 4);
4490
- ctx.globalCompositeOperation = "soft-light";
4491
4657
  for(let i = 0; i < bgShapeCount; i++){
4492
4658
  const bx = rng() * width;
4493
4659
  const by = rng() * height;
4494
4660
  const bSize = width * 0.3 + rng() * width * 0.5;
4495
4661
  const bColor = (0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng);
4496
- ctx.globalAlpha = 0.03 + rng() * 0.05;
4662
+ ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
4497
4663
  ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(bColor, 0.15);
4498
4664
  ctx.beginPath();
4499
4665
  // Use archetype-appropriate background shapes
4500
- if (archetype.name === "geometric-precision" || archetype.name === "op-art") // Rectangular shapes for geometric archetypes
4501
- ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4666
+ if (archetype.name === "geometric-precision" || archetype.name === "op-art") ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4502
4667
  else ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
4503
4668
  ctx.fill();
4504
4669
  }
4505
- // Subtle concentric rings from center
4670
+ // Subtle concentric rings from center — batched into single stroke
4506
4671
  const ringCount = 2 + Math.floor(rng() * 3);
4507
4672
  ctx.globalAlpha = 0.02 + rng() * 0.03;
4508
4673
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
4509
4674
  ctx.lineWidth = 1 * scaleFactor;
4675
+ ctx.beginPath();
4510
4676
  for(let i = 1; i <= ringCount; i++){
4511
4677
  const r = Math.min(width, height) * 0.15 * i;
4512
- ctx.beginPath();
4678
+ ctx.moveTo(cx + r, cy);
4513
4679
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
4514
- ctx.stroke();
4515
4680
  }
4516
- ctx.globalCompositeOperation = "source-over";
4681
+ ctx.stroke();
4517
4682
  // ── 1c. Background pattern layer — subtle textured paper ───────
4518
4683
  const bgPatternRoll = rng();
4519
4684
  if (bgPatternRoll < 0.6) {
4520
4685
  ctx.save();
4521
- ctx.globalCompositeOperation = "soft-light";
4522
4686
  const patternOpacity = 0.02 + rng() * 0.04;
4523
4687
  const patternColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4524
4688
  if (bgPatternRoll < 0.2) {
4525
- // Dot grid — batched into a single path
4526
- const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4527
- const dotR = dotSpacing * 0.08;
4689
+ // Dot grid — use fillRect instead of arcs (much cheaper, no path building)
4690
+ const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
4691
+ const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
4528
4692
  ctx.globalAlpha = patternOpacity;
4529
4693
  ctx.fillStyle = patternColor;
4530
- ctx.beginPath();
4531
- for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4532
- ctx.moveTo(px + dotR, py);
4533
- ctx.arc(px, py, dotR, 0, Math.PI * 2);
4694
+ let dotCount = 0;
4695
+ for(let px = 0; px < width && dotCount < 2000; px += dotSpacing)for(let py = 0; py < height && dotCount < 2000; py += dotSpacing){
4696
+ ctx.fillRect(px, py, dotDiam, dotDiam);
4697
+ dotCount++;
4534
4698
  }
4535
- ctx.fill();
4536
4699
  } else if (bgPatternRoll < 0.4) {
4537
- // Diagonal lines — batched into a single path
4538
- const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4700
+ // Diagonal lines — batched into a single path, capped at 300 lines
4701
+ const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
4539
4702
  ctx.globalAlpha = patternOpacity;
4540
4703
  ctx.strokeStyle = patternColor;
4541
4704
  ctx.lineWidth = 0.5 * scaleFactor;
4542
4705
  const diag = Math.hypot(width, height);
4543
4706
  ctx.beginPath();
4544
- for(let d = -diag; d < diag; d += lineSpacing){
4707
+ let lineCount = 0;
4708
+ for(let d = -diag; d < diag && lineCount < 300; d += lineSpacing){
4545
4709
  ctx.moveTo(d, 0);
4546
4710
  ctx.lineTo(d + height, height);
4711
+ lineCount++;
4547
4712
  }
4548
4713
  ctx.stroke();
4549
4714
  } else {
4550
- // Tessellation — hexagonal grid, batched into a single path
4551
- const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4715
+ // Tessellation — hexagonal grid, capped at 500 hexagons
4716
+ const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
4552
4717
  const tessH = tessSize * Math.sqrt(3);
4553
4718
  ctx.globalAlpha = patternOpacity * 0.7;
4554
4719
  ctx.strokeStyle = patternColor;
4555
4720
  ctx.lineWidth = 0.4 * scaleFactor;
4721
+ // Pre-compute hex vertex offsets (avoid trig per vertex)
4722
+ const hexVx = [];
4723
+ const hexVy = [];
4724
+ for(let s = 0; s < 6; s++){
4725
+ const angle = Math.PI / 3 * s - Math.PI / 6;
4726
+ hexVx.push(Math.cos(angle) * tessSize * 0.5);
4727
+ hexVy.push(Math.sin(angle) * tessSize * 0.5);
4728
+ }
4556
4729
  ctx.beginPath();
4557
- for(let row = 0; row * tessH < height + tessH; row++){
4730
+ let hexCount = 0;
4731
+ for(let row = 0; row * tessH < height + tessH && hexCount < 500; row++){
4558
4732
  const offsetX = row % 2 * tessSize * 0.75;
4559
- for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4733
+ for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++){
4560
4734
  const hx = col * tessSize * 1.5 + offsetX;
4561
4735
  const hy = row * tessH;
4562
- for(let s = 0; s < 6; s++){
4563
- const angle = Math.PI / 3 * s - Math.PI / 6;
4564
- const vx = hx + Math.cos(angle) * tessSize * 0.5;
4565
- const vy = hy + Math.sin(angle) * tessSize * 0.5;
4566
- if (s === 0) ctx.moveTo(vx, vy);
4567
- else ctx.lineTo(vx, vy);
4568
- }
4736
+ ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
4737
+ for(let s = 1; s < 6; s++)ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
4569
4738
  ctx.closePath();
4739
+ hexCount++;
4570
4740
  }
4571
4741
  }
4572
4742
  ctx.stroke();
@@ -4574,6 +4744,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4574
4744
  ctx.restore();
4575
4745
  }
4576
4746
  ctx.globalCompositeOperation = "source-over";
4747
+ _mark("1_background");
4577
4748
  // ── 2. Composition mode — archetype-aware selection ──────────────
4578
4749
  const compositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : $1f63dc64b5593c73$var$ALL_COMPOSITION_MODES[Math.floor(rng() * $1f63dc64b5593c73$var$ALL_COMPOSITION_MODES.length)];
4579
4750
  const symRoll = rng();
@@ -4657,19 +4828,20 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4657
4828
  ctx.beginPath();
4658
4829
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4659
4830
  ctx.stroke();
4660
- // ~50% chance: scatter tiny dots inside the void
4831
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4661
4832
  if (rng() < 0.5) {
4662
4833
  const dotCount = 3 + Math.floor(rng() * 6);
4663
4834
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4664
4835
  ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4836
+ ctx.beginPath();
4665
4837
  for(let d = 0; d < dotCount; d++){
4666
4838
  const angle = rng() * Math.PI * 2;
4667
4839
  const dist = rng() * zone.radius * 0.7;
4668
4840
  const dotR = (1 + rng() * 3) * scaleFactor;
4669
- ctx.beginPath();
4841
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4670
4842
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4671
- ctx.fill();
4672
4843
  }
4844
+ ctx.fill();
4673
4845
  }
4674
4846
  // ~30% chance: thin concentric ring inside
4675
4847
  if (rng() < 0.3) {
@@ -4683,6 +4855,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4683
4855
  }
4684
4856
  }
4685
4857
  ctx.globalAlpha = 1;
4858
+ _mark("2_3_composition_focal");
4686
4859
  // ── 4. Flow field — simplex noise for organic variation ─────────
4687
4860
  // Create a seeded simplex noise field (unique per hash)
4688
4861
  const noiseFieldRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 333));
@@ -4759,8 +4932,29 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4759
4932
  shape: heroShape
4760
4933
  });
4761
4934
  }
4935
+ _mark("4_flowfield_hero");
4762
4936
  // ── 5. Shape layers ────────────────────────────────────────────
4763
4937
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4938
+ // ── Complexity budget — caps total rendering work ──────────────
4939
+ // Budget scales with pixel area so larger canvases get proportionally
4940
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4941
+ // constellations, rhythm) are gated behind the budget; when it runs
4942
+ // low they are skipped. When it's exhausted, expensive render styles
4943
+ // are downgraded to cheaper alternatives.
4944
+ //
4945
+ // RNG values are always consumed even when skipping, so the
4946
+ // deterministic sequence for shapes that *do* render is preserved.
4947
+ const pixelArea = width * height;
4948
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4949
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4950
+ const totalBudget = complexityBudget;
4951
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4952
+ let extrasSpent = 0;
4953
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4954
+ // These generate O(size²) fillRect calls per shape and dominate
4955
+ // worst-case render time. Cap scales with pixel area.
4956
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4957
+ let clipHeavyCount = 0;
4764
4958
  for(let layer = 0; layer < layers; layer++){
4765
4959
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4766
4960
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4849,7 +5043,26 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4849
5043
  const shapeRenderStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4850
5044
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4851
5045
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4852
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5046
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5047
+ // Budget check: downgrade expensive styles proportionally —
5048
+ // the more expensive the style, the earlier it gets downgraded.
5049
+ // noise-grain (400) downgrades when budget < 20% remaining,
5050
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5051
+ let styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5052
+ if (styleCost > 3) {
5053
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5054
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5055
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5056
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5057
+ }
5058
+ }
5059
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5060
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5061
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5062
+ finalRenderStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(finalRenderStyle);
5063
+ styleCost = $1f63dc64b5593c73$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5064
+ }
5065
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4853
5066
  // Consistent light direction — subtle shadow offset
4854
5067
  const shadowDist = hasGlow ? 0 : size * 0.02;
4855
5068
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4904,30 +5117,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4904
5117
  lightAngle: lightAngle,
4905
5118
  scaleFactor: scaleFactor
4906
5119
  };
4907
- if (shouldMirror) (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4908
- ...shapeConfig,
4909
- mirrorAxis: mirrorAxis,
4910
- mirrorGap: size * (0.1 + rng() * 0.3)
4911
- });
4912
- else (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5120
+ if (shouldMirror) {
5121
+ (0, $e0f99502ff383dd8$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5122
+ ...shapeConfig,
5123
+ mirrorAxis: mirrorAxis,
5124
+ mirrorGap: size * (0.1 + rng() * 0.3)
5125
+ });
5126
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5127
+ } else {
5128
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5129
+ complexityBudget -= styleCost;
5130
+ }
5131
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5132
+ const extrasAllowed = extrasSpent < budgetForExtras;
4913
5133
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4914
5134
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4915
5135
  const glazePasses = 2 + Math.floor(rng() * 2);
4916
- for(let g = 0; g < glazePasses; g++){
4917
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4918
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4919
- ctx.globalAlpha = glazeAlpha;
4920
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4921
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4922
- strokeColor: "rgba(0,0,0,0)",
4923
- strokeWidth: 0,
4924
- size: size * glazeScale,
4925
- rotation: rotation,
4926
- proportionType: "GOLDEN_RATIO",
4927
- renderStyle: "fill-only",
4928
- rng: rng
4929
- });
5136
+ if (extrasAllowed) {
5137
+ for(let g = 0; g < glazePasses; g++){
5138
+ const glazeScale = 1 - (g + 1) * 0.12;
5139
+ const glazeAlpha = 0.08 + g * 0.04;
5140
+ ctx.globalAlpha = glazeAlpha;
5141
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5142
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5143
+ strokeColor: "rgba(0,0,0,0)",
5144
+ strokeWidth: 0,
5145
+ size: size * glazeScale,
5146
+ rotation: rotation,
5147
+ proportionType: "GOLDEN_RATIO",
5148
+ renderStyle: "fill-only",
5149
+ rng: rng
5150
+ });
5151
+ }
5152
+ extrasSpent += glazePasses;
4930
5153
  }
5154
+ // RNG consumed by glazePasses calculation above regardless
4931
5155
  }
4932
5156
  shapePositions.push({
4933
5157
  x: finalX,
@@ -4945,37 +5169,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4945
5169
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4946
5170
  const echoCount = 2 + Math.floor(rng() * 2);
4947
5171
  const echoAngle = rng() * Math.PI * 2;
4948
- for(let e = 0; e < echoCount; e++){
4949
- const echoScale = 0.3 - e * 0.08;
4950
- const echoDist = size * (0.6 + e * 0.4);
4951
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4952
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4953
- const echoSize = size * Math.max(0.1, echoScale);
4954
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4955
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4956
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4957
- fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4958
- strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
4959
- strokeWidth: strokeWidth * 0.6,
4960
- size: echoSize,
4961
- rotation: rotation + (e + 1) * 15,
4962
- proportionType: "GOLDEN_RATIO",
4963
- renderStyle: finalRenderStyle,
4964
- rng: rng
4965
- });
4966
- shapePositions.push({
4967
- x: echoX,
4968
- y: echoY,
4969
- size: echoSize,
4970
- shape: shape
4971
- });
4972
- spatialGrid.insert({
4973
- x: echoX,
4974
- y: echoY,
4975
- size: echoSize,
4976
- shape: shape
4977
- });
5172
+ if (extrasAllowed) {
5173
+ for(let e = 0; e < echoCount; e++){
5174
+ const echoScale = 0.3 - e * 0.08;
5175
+ const echoDist = size * (0.6 + e * 0.4);
5176
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5177
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5178
+ const echoSize = size * Math.max(0.1, echoScale);
5179
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5180
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5181
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5182
+ fillColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5183
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.4),
5184
+ strokeWidth: strokeWidth * 0.6,
5185
+ size: echoSize,
5186
+ rotation: rotation + (e + 1) * 15,
5187
+ proportionType: "GOLDEN_RATIO",
5188
+ renderStyle: finalRenderStyle,
5189
+ rng: rng
5190
+ });
5191
+ shapePositions.push({
5192
+ x: echoX,
5193
+ y: echoY,
5194
+ size: echoSize,
5195
+ shape: shape
5196
+ });
5197
+ spatialGrid.insert({
5198
+ x: echoX,
5199
+ y: echoY,
5200
+ size: echoSize,
5201
+ shape: shape
5202
+ });
5203
+ }
5204
+ extrasSpent += echoCount * styleCost;
4978
5205
  }
5206
+ // RNG for echoCount + echoAngle consumed above regardless
4979
5207
  }
4980
5208
  // ── 5d. Recursive nesting ──────────────────────────────────
4981
5209
  // Focal depth: shapes near focal points get more detail
@@ -4983,7 +5211,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4983
5211
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4984
5212
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4985
5213
  const innerCount = 1 + Math.floor(rng() * 3);
4986
- for(let n = 0; n < innerCount; n++){
5214
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4987
5215
  // Pick inner shape from palette affinities
4988
5216
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
4989
5217
  const innerShape = (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -4992,6 +5220,10 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
4992
5220
  const innerOffY = (rng() - 0.5) * size * 0.4;
4993
5221
  const innerRot = rng() * 360;
4994
5222
  const innerFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5223
+ let innerStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5224
+ // Apply clip-heavy cap to nested shapes too
5225
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(innerStyle);
5226
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
4995
5227
  ctx.globalAlpha = layerOpacity * 0.7;
4996
5228
  (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
4997
5229
  fillColor: innerFill,
@@ -5000,9 +5232,21 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5000
5232
  size: innerSize,
5001
5233
  rotation: innerRot,
5002
5234
  proportionType: "GOLDEN_RATIO",
5003
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5235
+ renderStyle: innerStyle,
5004
5236
  rng: rng
5005
5237
  });
5238
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5239
+ }
5240
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5241
+ for(let n = 0; n < innerCount; n++){
5242
+ rng();
5243
+ rng();
5244
+ rng();
5245
+ rng();
5246
+ rng();
5247
+ rng();
5248
+ rng();
5249
+ rng();
5006
5250
  }
5007
5251
  }
5008
5252
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -5011,40 +5255,55 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5011
5255
  const constellation = $1f63dc64b5593c73$var$CONSTELLATIONS[Math.floor(rng() * $1f63dc64b5593c73$var$CONSTELLATIONS.length)];
5012
5256
  const members = constellation.build(rng, size);
5013
5257
  const groupRotation = rng() * Math.PI * 2;
5014
- const cosR = Math.cos(groupRotation);
5015
- const sinR = Math.sin(groupRotation);
5016
- for (const member of members){
5017
- // Rotate the group offset by the group rotation
5018
- const mx = finalX + member.dx * cosR - member.dy * sinR;
5019
- const my = finalY + member.dx * sinR + member.dy * cosR;
5020
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
5021
- const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5022
- const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5023
- ctx.globalAlpha = layerOpacity * 0.6;
5024
- // Use the member's shape if available, otherwise fall back to palette
5025
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5026
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5027
- fillColor: memberFill,
5028
- strokeColor: memberStroke,
5029
- strokeWidth: strokeWidth * 0.7,
5030
- size: member.size,
5031
- rotation: member.rotation + groupRotation * 180 / Math.PI,
5032
- proportionType: "GOLDEN_RATIO",
5033
- renderStyle: (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
5034
- rng: rng
5035
- });
5036
- shapePositions.push({
5037
- x: mx,
5038
- y: my,
5039
- size: member.size,
5040
- shape: memberShape
5041
- });
5042
- spatialGrid.insert({
5043
- x: mx,
5044
- y: my,
5045
- size: member.size,
5046
- shape: memberShape
5047
- });
5258
+ if (extrasAllowed) {
5259
+ const cosR = Math.cos(groupRotation);
5260
+ const sinR = Math.sin(groupRotation);
5261
+ for (const member of members){
5262
+ // Rotate the group offset by the group rotation
5263
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5264
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5265
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5266
+ const memberFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5267
+ const memberStroke = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5268
+ ctx.globalAlpha = layerOpacity * 0.6;
5269
+ // Use the member's shape if available, otherwise fall back to palette
5270
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $8286059160ee2e04$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5271
+ let memberStyle = (0, $8286059160ee2e04$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5272
+ // Apply clip-heavy cap to constellation members too
5273
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $1f63dc64b5593c73$var$downgradeRenderStyle(memberStyle);
5274
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5275
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5276
+ fillColor: memberFill,
5277
+ strokeColor: memberStroke,
5278
+ strokeWidth: strokeWidth * 0.7,
5279
+ size: member.size,
5280
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5281
+ proportionType: "GOLDEN_RATIO",
5282
+ renderStyle: memberStyle,
5283
+ rng: rng
5284
+ });
5285
+ shapePositions.push({
5286
+ x: mx,
5287
+ y: my,
5288
+ size: member.size,
5289
+ shape: memberShape
5290
+ });
5291
+ spatialGrid.insert({
5292
+ x: mx,
5293
+ y: my,
5294
+ size: member.size,
5295
+ shape: memberShape
5296
+ });
5297
+ extrasSpent += $1f63dc64b5593c73$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5298
+ }
5299
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5300
+ for(let m = 0; m < members.length; m++){
5301
+ rng();
5302
+ rng();
5303
+ rng();
5304
+ rng();
5305
+ rng();
5306
+ rng();
5048
5307
  }
5049
5308
  }
5050
5309
  // ── 5f. Rhythm placement — deliberate geometric progressions ──
@@ -5055,45 +5314,58 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5055
5314
  const rhythmSpacing = size * (0.8 + rng() * 0.6);
5056
5315
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5057
5316
  const rhythmShape = shape; // same shape for visual rhythm
5058
- let rhythmSize = size * 0.6;
5317
+ if (extrasAllowed) {
5318
+ let rhythmSize = size * 0.6;
5319
+ for(let r = 0; r < rhythmCount; r++){
5320
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5321
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5322
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5323
+ if ($1f63dc64b5593c73$var$isInVoidZone(rx, ry, voidZones)) break;
5324
+ rhythmSize *= rhythmDecay;
5325
+ if (rhythmSize < adjustedMinSize) break;
5326
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5327
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5328
+ const rhythmFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5329
+ (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5330
+ fillColor: rhythmFill,
5331
+ strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.5),
5332
+ strokeWidth: strokeWidth * 0.7,
5333
+ size: rhythmSize,
5334
+ rotation: rotation + (r + 1) * 12,
5335
+ proportionType: "GOLDEN_RATIO",
5336
+ renderStyle: finalRenderStyle,
5337
+ rng: rng
5338
+ });
5339
+ shapePositions.push({
5340
+ x: rx,
5341
+ y: ry,
5342
+ size: rhythmSize,
5343
+ shape: rhythmShape
5344
+ });
5345
+ spatialGrid.insert({
5346
+ x: rx,
5347
+ y: ry,
5348
+ size: rhythmSize,
5349
+ shape: rhythmShape
5350
+ });
5351
+ }
5352
+ extrasSpent += rhythmCount * styleCost;
5353
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5059
5354
  for(let r = 0; r < rhythmCount; r++){
5060
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5061
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5062
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5063
- if ($1f63dc64b5593c73$var$isInVoidZone(rx, ry, voidZones)) break;
5064
- rhythmSize *= rhythmDecay;
5065
- if (rhythmSize < adjustedMinSize) break;
5066
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5067
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5068
- const rhythmFill = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$18a34c25ea7e724b)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5069
- (0, $e0f99502ff383dd8$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5070
- fillColor: rhythmFill,
5071
- strokeColor: (0, $b5a262d09b87e373$export$f2121afcad3d553f)(strokeColor, 0.5),
5072
- strokeWidth: strokeWidth * 0.7,
5073
- size: rhythmSize,
5074
- rotation: rotation + (r + 1) * 12,
5075
- proportionType: "GOLDEN_RATIO",
5076
- renderStyle: finalRenderStyle,
5077
- rng: rng
5078
- });
5079
- shapePositions.push({
5080
- x: rx,
5081
- y: ry,
5082
- size: rhythmSize,
5083
- shape: rhythmShape
5084
- });
5085
- spatialGrid.insert({
5086
- x: rx,
5087
- y: ry,
5088
- size: rhythmSize,
5089
- shape: rhythmShape
5090
- });
5355
+ rng();
5356
+ rng();
5357
+ rng();
5091
5358
  }
5092
5359
  }
5093
5360
  }
5094
5361
  }
5095
5362
  // Reset blend mode for post-processing passes
5096
5363
  ctx.globalCompositeOperation = "source-over";
5364
+ if (_dt) {
5365
+ _dt.shapeCount = shapePositions.length;
5366
+ _dt.extraCount = extrasSpent;
5367
+ }
5368
+ _mark("5_shape_layers");
5097
5369
  // ── 5g. Layered masking / cutout portals ───────────────────────
5098
5370
  // ~18% of images get 1-3 portal windows that paint over foreground
5099
5371
  // with a tinted background wash, creating a "peek through" effect.
@@ -5152,15 +5424,28 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5152
5424
  ctx.restore();
5153
5425
  }
5154
5426
  }
5427
+ _mark("5g_portals");
5155
5428
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5429
+ // Optimized: collect all segments into width-quantized buckets, then
5430
+ // render each bucket as a single batched path. This reduces
5431
+ // beginPath/stroke calls from O(segments) to O(buckets).
5156
5432
  const baseFlowLines = 6 + Math.floor(rng() * 10);
5157
5433
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5434
+ // Width buckets — 6 buckets cover the taper×pressure range
5435
+ const FLOW_WIDTH_BUCKETS = 6;
5436
+ const flowBuckets = [];
5437
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5438
+ // Track the representative width for each bucket
5439
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5440
+ // Pre-compute max possible width for bucket assignment
5441
+ let globalMaxFlowWidth = 0;
5158
5442
  for(let i = 0; i < numFlowLines; i++){
5159
5443
  let fx = rng() * width;
5160
5444
  let fy = rng() * height;
5161
5445
  const steps = 30 + Math.floor(rng() * 40);
5162
5446
  const stepLen = (3 + rng() * 5) * scaleFactor;
5163
5447
  const startWidth = (1 + rng() * 3) * scaleFactor;
5448
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
5164
5449
  // Variable color: interpolate between two hierarchy colors along the stroke
5165
5450
  const lineColorStart = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
5166
5451
  const lineColorEnd = (0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -5182,19 +5467,22 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5182
5467
  continue;
5183
5468
  }
5184
5469
  const t = s / steps;
5185
- // Taper + pressure
5186
5470
  const taper = 1 - t * 0.8;
5187
5471
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
5188
- ctx.globalAlpha = lineAlpha * taper;
5189
- // Interpolate color along stroke
5472
+ const segWidth = startWidth * taper * pressure;
5473
+ const segAlpha = lineAlpha * taper;
5190
5474
  const lineColor = t < 0.5 ? (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $b5a262d09b87e373$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
5191
- ctx.strokeStyle = lineColor;
5192
- ctx.lineWidth = startWidth * taper * pressure;
5193
- ctx.lineCap = "round";
5194
- ctx.beginPath();
5195
- ctx.moveTo(prevX, prevY);
5196
- ctx.lineTo(fx, fy);
5197
- ctx.stroke();
5475
+ // Quantize width into bucket
5476
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5477
+ flowBuckets[bucketIdx].push({
5478
+ x1: prevX,
5479
+ y1: prevY,
5480
+ x2: fx,
5481
+ y2: fy,
5482
+ color: lineColor,
5483
+ alpha: segAlpha
5484
+ });
5485
+ flowBucketWidths[bucketIdx] = segWidth;
5198
5486
  // Branching: ~12% chance per step to spawn a thinner child stroke
5199
5487
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
5200
5488
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -5210,12 +5498,18 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5210
5498
  by += Math.sin(bAngle) * stepLen * 0.8;
5211
5499
  if (bx < 0 || bx > width || by < 0 || by > height) break;
5212
5500
  const bTaper = 1 - bs / branchSteps * 0.9;
5213
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
5214
- ctx.lineWidth = branchWidth * bTaper;
5215
- ctx.beginPath();
5216
- ctx.moveTo(bPrevX, bPrevY);
5217
- ctx.lineTo(bx, by);
5218
- ctx.stroke();
5501
+ const bSegWidth = branchWidth * bTaper;
5502
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5503
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5504
+ flowBuckets[bBucket].push({
5505
+ x1: bPrevX,
5506
+ y1: bPrevY,
5507
+ x2: bx,
5508
+ y2: by,
5509
+ color: lineColor,
5510
+ alpha: bAlpha
5511
+ });
5512
+ flowBucketWidths[bBucket] = bSegWidth;
5219
5513
  bPrevX = bx;
5220
5514
  bPrevY = by;
5221
5515
  }
@@ -5224,7 +5518,41 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5224
5518
  prevY = fy;
5225
5519
  }
5226
5520
  }
5521
+ // Render flow line buckets — one batched path per width bucket
5522
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5523
+ ctx.lineCap = "round";
5524
+ const FLOW_ALPHA_BUCKETS = 4;
5525
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5526
+ const segs = flowBuckets[wb];
5527
+ if (segs.length === 0) continue;
5528
+ ctx.lineWidth = flowBucketWidths[wb];
5529
+ // Sub-bucket by alpha
5530
+ const alphaSubs = [];
5531
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5532
+ let maxAlpha = 0;
5533
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5534
+ for(let j = 0; j < segs.length; j++){
5535
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5536
+ alphaSubs[ai].push(segs[j]);
5537
+ }
5538
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5539
+ const sub = alphaSubs[ai];
5540
+ if (sub.length === 0) continue;
5541
+ // Use the median segment's alpha and color as representative
5542
+ const rep = sub[Math.floor(sub.length / 2)];
5543
+ ctx.globalAlpha = rep.alpha;
5544
+ ctx.strokeStyle = rep.color;
5545
+ ctx.beginPath();
5546
+ for(let j = 0; j < sub.length; j++){
5547
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5548
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5549
+ }
5550
+ ctx.stroke();
5551
+ }
5552
+ }
5553
+ _mark("6_flow_lines");
5227
5554
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5555
+ // Optimized: collect all burst segments, then batch by quantized alpha
5228
5556
  const energyArchetypes = [
5229
5557
  "dense-chaotic",
5230
5558
  "cosmic",
@@ -5235,8 +5563,12 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5235
5563
  if (hasEnergyLines && shapePositions.length > 0) {
5236
5564
  const energyCount = 5 + Math.floor(rng() * 10);
5237
5565
  ctx.lineCap = "round";
5566
+ // Collect all energy segments with their computed state
5567
+ const ENERGY_ALPHA_BUCKETS = 3;
5568
+ const energyBuckets = [];
5569
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5570
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5238
5571
  for(let e = 0; e < energyCount; e++){
5239
- // Pick a random shape to radiate from
5240
5572
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5241
5573
  const burstCount = 2 + Math.floor(rng() * 4);
5242
5574
  const baseAngle = flowAngle(source.x, source.y);
@@ -5248,16 +5580,40 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5248
5580
  const sy = source.y + Math.sin(angle) * startDist;
5249
5581
  const ex = sx + Math.cos(angle) * lineLen;
5250
5582
  const ey = sy + Math.sin(angle) * lineLen;
5251
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5252
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5253
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5254
- ctx.beginPath();
5255
- ctx.moveTo(sx, sy);
5256
- ctx.lineTo(ex, ey);
5257
- ctx.stroke();
5583
+ const eAlpha = 0.04 + rng() * 0.06;
5584
+ const eColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5585
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5586
+ // Quantize alpha into bucket
5587
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5588
+ energyBuckets[bi].push({
5589
+ x1: sx,
5590
+ y1: sy,
5591
+ x2: ex,
5592
+ y2: ey,
5593
+ color: eColor,
5594
+ lw: eLw
5595
+ });
5596
+ energyAlphas[bi] = eAlpha;
5258
5597
  }
5259
5598
  }
5599
+ // Render batched energy lines
5600
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5601
+ const segs = energyBuckets[bi];
5602
+ if (segs.length === 0) continue;
5603
+ ctx.globalAlpha = energyAlphas[bi];
5604
+ // Use median segment's color and width as representative
5605
+ const rep = segs[Math.floor(segs.length / 2)];
5606
+ ctx.strokeStyle = rep.color;
5607
+ ctx.lineWidth = rep.lw;
5608
+ ctx.beginPath();
5609
+ for(let j = 0; j < segs.length; j++){
5610
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5611
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5612
+ }
5613
+ ctx.stroke();
5614
+ }
5260
5615
  }
5616
+ _mark("6b_energy_lines");
5261
5617
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
5262
5618
  if (symmetryMode !== "none") {
5263
5619
  const canvas = ctx.canvas;
@@ -5278,43 +5634,25 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5278
5634
  }
5279
5635
  ctx.restore();
5280
5636
  }
5281
- // ── 7. Noise texture overlay — batched via ImageData ─────────────
5637
+ _mark("6c_symmetry");
5638
+ // ── 7. Noise texture overlay ─────────────────────────────────────
5639
+ // With density capped at 2500 dots, direct fillRect calls are far cheaper
5640
+ // than the getImageData/putImageData round-trip which copies the entire
5641
+ // pixel buffer (4 × width × height bytes) twice.
5282
5642
  const noiseRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 777));
5283
- const noiseDensity = Math.floor(width * height / 800);
5284
- try {
5285
- const imageData = ctx.getImageData(0, 0, width, height);
5286
- const data = imageData.data;
5287
- const pixelScale = Math.max(1, Math.round(scaleFactor));
5288
- for(let i = 0; i < noiseDensity; i++){
5289
- const nx = Math.floor(noiseRng() * width);
5290
- const ny = Math.floor(noiseRng() * height);
5291
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5292
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
5293
- // Write a small block of pixels for scale
5294
- for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5295
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
5296
- // Alpha-blend the noise dot onto existing pixel data
5297
- const srcA = alpha / 255;
5298
- const invA = 1 - srcA;
5299
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
5300
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
5301
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
5302
- // Keep existing alpha
5303
- }
5304
- }
5305
- ctx.putImageData(imageData, 0, 0);
5306
- } catch {
5307
- // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5308
- for(let i = 0; i < noiseDensity; i++){
5309
- const nx = noiseRng() * width;
5310
- const ny = noiseRng() * height;
5311
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5312
- const alpha = 0.01 + noiseRng() * 0.03;
5313
- ctx.globalAlpha = alpha;
5314
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5315
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5316
- }
5643
+ const rawNoiseDensity = Math.floor(width * height / 800);
5644
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5645
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5646
+ for(let i = 0; i < noiseDensity; i++){
5647
+ const nx = noiseRng() * width;
5648
+ const ny = noiseRng() * height;
5649
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5650
+ const alpha = 0.01 + noiseRng() * 0.03;
5651
+ ctx.globalAlpha = alpha;
5652
+ ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
5653
+ ctx.fillRect(nx, ny, pixelScale, pixelScale);
5317
5654
  }
5655
+ _mark("7_noise_texture");
5318
5656
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5319
5657
  ctx.globalAlpha = 1;
5320
5658
  const vignetteStrength = 0.25 + rng() * 0.2;
@@ -5328,11 +5666,20 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5328
5666
  vigGrad.addColorStop(1, vignetteColor);
5329
5667
  ctx.fillStyle = vigGrad;
5330
5668
  ctx.fillRect(0, 0, width, height);
5669
+ _mark("8_vignette");
5331
5670
  // ── 9. Organic connecting curves — proximity-aware ───────────────
5671
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5672
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5332
5673
  if (shapePositions.length > 1) {
5333
5674
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5334
5675
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5335
5676
  ctx.lineWidth = 0.8 * scaleFactor;
5677
+ // Collect curves into 3 alpha buckets
5678
+ const CURVE_ALPHA_BUCKETS = 3;
5679
+ const curveBuckets = [];
5680
+ const curveColors = [];
5681
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5682
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5336
5683
  for(let i = 0; i < numCurves; i++){
5337
5684
  const idxA = Math.floor(rng() * shapePositions.length);
5338
5685
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
@@ -5349,14 +5696,36 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5349
5696
  const bulge = (rng() - 0.5) * dist * 0.4;
5350
5697
  const cpx = mx + -dy / (dist || 1) * bulge;
5351
5698
  const cpy = my + dx / (dist || 1) * bulge;
5352
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5353
- ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5699
+ const curveAlpha = 0.06 + rng() * 0.1;
5700
+ const curveColor = (0, $b5a262d09b87e373$export$f2121afcad3d553f)((0, $b5a262d09b87e373$export$90ad0e6170cf6af5)((0, $b5a262d09b87e373$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5701
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5702
+ curveBuckets[bi].push({
5703
+ ax: a.x,
5704
+ ay: a.y,
5705
+ cpx: cpx,
5706
+ cpy: cpy,
5707
+ bx: b.x,
5708
+ by: b.y
5709
+ });
5710
+ curveAlphas[bi] = curveAlpha;
5711
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5712
+ }
5713
+ // Render batched curves
5714
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5715
+ const curves = curveBuckets[bi];
5716
+ if (curves.length === 0) continue;
5717
+ ctx.globalAlpha = curveAlphas[bi];
5718
+ ctx.strokeStyle = curveColors[bi];
5354
5719
  ctx.beginPath();
5355
- ctx.moveTo(a.x, a.y);
5356
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5720
+ for(let j = 0; j < curves.length; j++){
5721
+ const c = curves[j];
5722
+ ctx.moveTo(c.ax, c.ay);
5723
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5724
+ }
5357
5725
  ctx.stroke();
5358
5726
  }
5359
5727
  }
5728
+ _mark("9_connecting_curves");
5360
5729
  // ── 10. Post-processing ────────────────────────────────────────
5361
5730
  // 10a. Color grading — unified tone across the whole image
5362
5731
  // Apply as a semi-transparent overlay in the grade hue
@@ -5416,6 +5785,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5416
5785
  ctx.fillRect(0, 0, width, height);
5417
5786
  ctx.globalCompositeOperation = "source-over";
5418
5787
  }
5788
+ _mark("10_post_processing");
5419
5789
  // ── 10e. Generative borders — archetype-driven decorative frames ──
5420
5790
  {
5421
5791
  ctx.save();
@@ -5471,11 +5841,14 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5471
5841
  }
5472
5842
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5473
5843
  // Vine tendrils — organic curving lines along edges
5844
+ // Optimized: batch all tendrils into a single path
5474
5845
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5475
5846
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5476
5847
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5477
5848
  ctx.lineCap = "round";
5478
5849
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5850
+ ctx.beginPath();
5851
+ const leafPositions = [];
5479
5852
  for(let t = 0; t < tendrilCount; t++){
5480
5853
  // Start from a random edge point
5481
5854
  const edge = Math.floor(borderRng() * 4);
@@ -5493,7 +5866,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5493
5866
  tx = width - borderPad;
5494
5867
  ty = borderRng() * height;
5495
5868
  }
5496
- ctx.beginPath();
5497
5869
  ctx.moveTo(tx, ty);
5498
5870
  const segs = 3 + Math.floor(borderRng() * 4);
5499
5871
  for(let s = 0; s < segs; s++){
@@ -5507,14 +5879,23 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5507
5879
  ty = cpy3;
5508
5880
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5509
5881
  }
5510
- ctx.stroke();
5511
- // Small leaf/dot at tendril end
5512
- if (borderRng() < 0.6) {
5513
- ctx.beginPath();
5514
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5515
- ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5516
- ctx.fill();
5882
+ // Collect leaf positions for batch fill
5883
+ if (borderRng() < 0.6) leafPositions.push({
5884
+ x: tx,
5885
+ y: ty,
5886
+ r: borderPad * (0.15 + borderRng() * 0.2)
5887
+ });
5888
+ }
5889
+ ctx.stroke();
5890
+ // Batch all leaf dots into a single fill
5891
+ if (leafPositions.length > 0) {
5892
+ ctx.fillStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5893
+ ctx.beginPath();
5894
+ for (const leaf of leafPositions){
5895
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5896
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5517
5897
  }
5898
+ ctx.fill();
5518
5899
  }
5519
5900
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5520
5901
  // Star-studded arcs along edges
@@ -5529,8 +5910,9 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5529
5910
  ctx.beginPath();
5530
5911
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5531
5912
  ctx.stroke();
5532
- // Scatter small stars along the border region
5913
+ // Scatter small stars along the border region — batched into single path
5533
5914
  const starCount = 15 + Math.floor(borderRng() * 15);
5915
+ ctx.beginPath();
5534
5916
  for(let s = 0; s < starCount; s++){
5535
5917
  const edge = Math.floor(borderRng() * 4);
5536
5918
  let sx, sy;
@@ -5549,7 +5931,6 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5549
5931
  }
5550
5932
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5551
5933
  // 4-point star
5552
- ctx.beginPath();
5553
5934
  for(let p = 0; p < 8; p++){
5554
5935
  const a = p / 8 * Math.PI * 2;
5555
5936
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5559,8 +5940,8 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5559
5940
  else ctx.lineTo(px2, py2);
5560
5941
  }
5561
5942
  ctx.closePath();
5562
- ctx.fill();
5563
5943
  }
5944
+ ctx.fill();
5564
5945
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5565
5946
  // Thin single rule — understated elegance
5566
5947
  ctx.strokeStyle = (0, $b5a262d09b87e373$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5571,6 +5952,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5571
5952
  // Other archetypes: no border (intentional — not every image needs one)
5572
5953
  ctx.restore();
5573
5954
  }
5955
+ _mark("10e_borders");
5574
5956
  // ── 11. Signature mark — placed in the least-dense corner ──────
5575
5957
  {
5576
5958
  const sigRng = (0, $616009579e3d72c5$export$eaf9227667332084)((0, $616009579e3d72c5$export$e9cc707de01b7042)(gitHash, 42));
@@ -5638,6 +6020,7 @@ function $1f63dc64b5593c73$export$29a844702096332e(ctx, gitHash, config = {}) {
5638
6020
  ctx.restore();
5639
6021
  }
5640
6022
  ctx.globalAlpha = 1;
6023
+ _mark("11_signature");
5641
6024
  }
5642
6025
 
5643
6026