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/module.js CHANGED
@@ -517,13 +517,21 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
517
517
  }
518
518
  }
519
519
  // ── Standalone color utilities ──────────────────────────────────────
520
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
521
- const c = hex.replace("#", "");
522
- return [
520
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
521
+ const $9d614e7d77fc2947$var$_rgbCache = new Map();
522
+ const $9d614e7d77fc2947$var$_RGB_CACHE_MAX = 512;
523
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */ function $9d614e7d77fc2947$var$hexToRgb(hex) {
524
+ let cached = $9d614e7d77fc2947$var$_rgbCache.get(hex);
525
+ if (cached) return cached;
526
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
527
+ cached = [
523
528
  parseInt(c.substring(0, 2), 16),
524
529
  parseInt(c.substring(2, 4), 16),
525
530
  parseInt(c.substring(4, 6), 16)
526
531
  ];
532
+ if ($9d614e7d77fc2947$var$_rgbCache.size >= $9d614e7d77fc2947$var$_RGB_CACHE_MAX) $9d614e7d77fc2947$var$_rgbCache.clear();
533
+ $9d614e7d77fc2947$var$_rgbCache.set(hex, cached);
534
+ return cached;
527
535
  }
528
536
  /** Format [r, g, b] back to #RRGGBB. */ function $9d614e7d77fc2947$var$rgbToHex(r, g, b) {
529
537
  const clamp = (v)=>Math.max(0, Math.min(255, Math.round(v)));
@@ -580,7 +588,9 @@ class $9d614e7d77fc2947$export$ab958c550f521376 {
580
588
  }
581
589
  function $9d614e7d77fc2947$export$f2121afcad3d553f(hex, alpha) {
582
590
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex);
583
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
591
+ // Quantize alpha to 3 decimal places without toFixed overhead
592
+ const a = Math.round(alpha * 1000) / 1000;
593
+ return `rgba(${r},${g},${b},${a})`;
584
594
  }
585
595
  function $9d614e7d77fc2947$export$fabac4600b87056(colors, rng) {
586
596
  if (colors.length < 3) return {
@@ -660,12 +670,21 @@ function $9d614e7d77fc2947$export$51ea55f869b7e0d3(hex, target, amount) {
660
670
  const [h, s, l] = $9d614e7d77fc2947$var$hexToHsl(hex);
661
671
  return $9d614e7d77fc2947$var$hslToHex($9d614e7d77fc2947$var$shiftHueToward(h, target, amount), s, l);
662
672
  }
673
+ /**
674
+ * Compute relative luminance of a hex color (0 = black, 1 = white).
675
+ * Uses the sRGB luminance formula from WCAG. Cached.
676
+ */ const $9d614e7d77fc2947$var$_lumCache = new Map();
663
677
  function $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(hex) {
678
+ let cached = $9d614e7d77fc2947$var$_lumCache.get(hex);
679
+ if (cached !== undefined) return cached;
664
680
  const [r, g, b] = $9d614e7d77fc2947$var$hexToRgb(hex).map((c)=>{
665
681
  const s = c / 255;
666
682
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
667
683
  });
668
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
684
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
685
+ if ($9d614e7d77fc2947$var$_lumCache.size >= 512) $9d614e7d77fc2947$var$_lumCache.clear();
686
+ $9d614e7d77fc2947$var$_lumCache.set(hex, cached);
687
+ return cached;
669
688
  }
670
689
  function $9d614e7d77fc2947$export$90ad0e6170cf6af5(fgHex, bgLuminance, minContrast = 0.15) {
671
690
  const fgLum = $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe(fgHex);
@@ -1101,21 +1120,31 @@ const $8bde0a7ee87832b5$export$c9043b89bcb14ed9 = (ctx, size, config = {})=>{
1101
1120
  (0, $79312e33271883e9$export$e46c5570db033611)(ctx, size, finalConfig);
1102
1121
  const gridSize = 8;
1103
1122
  const unit = size / gridSize;
1123
+ const radius = unit / 2;
1124
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
1125
+ const starPoints = [];
1126
+ for(let k = 0; k < 8; k++){
1127
+ const angle = Math.PI / 4 * k;
1128
+ const angle2 = angle + Math.PI / 4;
1129
+ starPoints.push({
1130
+ c1: Math.cos(angle) * radius,
1131
+ s1: Math.sin(angle) * radius,
1132
+ c2: Math.cos(angle2) * radius,
1133
+ s2: Math.sin(angle2) * radius
1134
+ });
1135
+ }
1104
1136
  ctx.beginPath();
1105
1137
  // Create base grid
1106
- for(let i = 0; i <= gridSize; i++)for(let j = 0; j <= gridSize; j++){
1138
+ for(let i = 0; i <= gridSize; i++){
1107
1139
  const x = (i - gridSize / 2) * unit;
1108
- const y = (j - gridSize / 2) * unit;
1109
- // Draw star pattern at each intersection
1110
- const radius = unit / 2;
1111
- for(let k = 0; k < 8; k++){
1112
- const angle = Math.PI / 4 * k;
1113
- const x1 = x + radius * Math.cos(angle);
1114
- const y1 = y + radius * Math.sin(angle);
1115
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
1116
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
1117
- ctx.moveTo(x1, y1);
1118
- ctx.lineTo(x2, y2);
1140
+ for(let j = 0; j <= gridSize; j++){
1141
+ const y = (j - gridSize / 2) * unit;
1142
+ // Draw star pattern at each intersection using pre-computed offsets
1143
+ for(let k = 0; k < 8; k++){
1144
+ const sp = starPoints[k];
1145
+ ctx.moveTo(x + sp.c1, y + sp.s1);
1146
+ ctx.lineTo(x + sp.c2, y + sp.s2);
1147
+ }
1119
1148
  }
1120
1149
  }
1121
1150
  ctx.stroke();
@@ -1435,20 +1464,23 @@ const $d63629e16208c310$export$eeae7765f05012e2 = (ctx, size)=>{
1435
1464
  const $d63629e16208c310$export$3355220a8108efc3 = (ctx, size)=>{
1436
1465
  const outerRadius = size / 2;
1437
1466
  const innerRadius = size / 4;
1438
- const steps = 36;
1467
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
1468
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
1469
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
1470
+ const TWO_PI = Math.PI * 2;
1471
+ const angleStep = TWO_PI / steps;
1439
1472
  ctx.beginPath();
1440
1473
  for(let i = 0; i < steps; i++){
1441
- const angle1 = i / steps * Math.PI * 2;
1442
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
1474
+ const angle1 = i * angleStep;
1475
+ const cosA = Math.cos(angle1);
1476
+ const sinA = Math.sin(angle1);
1443
1477
  for(let j = 0; j < steps; j++){
1444
- const phi1 = j / steps * Math.PI * 2;
1445
- const phi2 = (j + 1) / steps * Math.PI * 2;
1446
- const x1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
1447
- const y1 = (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
1448
- const x2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
1449
- const y2 = (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
1450
- ctx.moveTo(x1, y1);
1451
- ctx.lineTo(x2, y2);
1478
+ const phi1 = j * angleStep;
1479
+ const phi2 = phi1 + angleStep;
1480
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
1481
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
1482
+ ctx.moveTo(r1 * cosA, r1 * sinA);
1483
+ ctx.lineTo(r2 * cosA, r2 * sinA);
1452
1484
  }
1453
1485
  }
1454
1486
  };
@@ -2011,6 +2043,43 @@ const $9beb8f41637c29fd$var$RENDER_STYLES = [
2011
2043
  function $9beb8f41637c29fd$export$9fd4e64b2acd410e(rng) {
2012
2044
  return $9beb8f41637c29fd$var$RENDER_STYLES[Math.floor(rng() * $9beb8f41637c29fd$var$RENDER_STYLES.length)];
2013
2045
  }
2046
+ const $9beb8f41637c29fd$export$2f738f61a8c15e07 = {
2047
+ "fill-and-stroke": 1,
2048
+ "fill-only": 0.5,
2049
+ "stroke-only": 1,
2050
+ "double-stroke": 1.5,
2051
+ "dashed": 1,
2052
+ "watercolor": 7,
2053
+ "hatched": 3,
2054
+ "incomplete": 1,
2055
+ "stipple": 90,
2056
+ "stencil": 2,
2057
+ "noise-grain": 400,
2058
+ "wood-grain": 10,
2059
+ "marble-vein": 4,
2060
+ "fabric-weave": 6,
2061
+ "hand-drawn": 5
2062
+ };
2063
+ function $9beb8f41637c29fd$export$909ab0580e273f19(style) {
2064
+ switch(style){
2065
+ case "noise-grain":
2066
+ return "hatched";
2067
+ case "stipple":
2068
+ return "dashed";
2069
+ case "wood-grain":
2070
+ return "hatched";
2071
+ case "watercolor":
2072
+ return "fill-and-stroke";
2073
+ case "fabric-weave":
2074
+ return "hatched";
2075
+ case "hand-drawn":
2076
+ return "fill-and-stroke";
2077
+ case "marble-vein":
2078
+ return "stroke-only";
2079
+ default:
2080
+ return style;
2081
+ }
2082
+ }
2014
2083
  function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2015
2084
  const { fillColor: fillColor, strokeColor: strokeColor, strokeWidth: strokeWidth, size: size, rotation: rotation } = config;
2016
2085
  ctx.save();
@@ -2130,6 +2199,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2130
2199
  case "hatched":
2131
2200
  {
2132
2201
  // Fill normally at reduced opacity, then overlay cross-hatch lines
2202
+ // Optimized: batch all parallel lines into a single path per pass
2133
2203
  const savedAlphaH = ctx.globalAlpha;
2134
2204
  ctx.globalAlpha = savedAlphaH * 0.3;
2135
2205
  ctx.fill();
@@ -2141,28 +2211,28 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2141
2211
  const hatchAngle = rng ? rng() * Math.PI : Math.PI / 4;
2142
2212
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
2143
2213
  ctx.globalAlpha = savedAlphaH * 0.6;
2144
- // Draw parallel lines across the bounding box
2214
+ // Draw parallel lines across the bounding box — batched into single path
2145
2215
  const extent = size * 0.8;
2146
2216
  const cos = Math.cos(hatchAngle);
2147
2217
  const sin = Math.sin(hatchAngle);
2218
+ ctx.beginPath();
2148
2219
  for(let d = -extent; d <= extent; d += hatchSpacing){
2149
- ctx.beginPath();
2150
2220
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
2151
2221
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
2152
- ctx.stroke();
2153
2222
  }
2223
+ ctx.stroke();
2154
2224
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
2155
2225
  if (!rng || rng() < 0.5) {
2156
2226
  const crossAngle = hatchAngle + Math.PI / 2;
2157
2227
  const cos2 = Math.cos(crossAngle);
2158
2228
  const sin2 = Math.sin(crossAngle);
2159
2229
  ctx.globalAlpha = savedAlphaH * 0.35;
2230
+ ctx.beginPath();
2160
2231
  for(let d = -extent; d <= extent; d += hatchSpacing * 1.4){
2161
- ctx.beginPath();
2162
2232
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
2163
2233
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
2164
- ctx.stroke();
2165
2234
  }
2235
+ ctx.stroke();
2166
2236
  }
2167
2237
  ctx.restore();
2168
2238
  ctx.globalAlpha = savedAlphaH;
@@ -2200,6 +2270,8 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2200
2270
  case "stipple":
2201
2271
  {
2202
2272
  // Dot-fill texture — clip to shape, then scatter dots
2273
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
2274
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
2203
2275
  const savedAlphaS = ctx.globalAlpha;
2204
2276
  ctx.globalAlpha = savedAlphaS * 0.15;
2205
2277
  ctx.fill(); // ghost fill
@@ -2207,16 +2279,20 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2207
2279
  ctx.save();
2208
2280
  ctx.clip();
2209
2281
  const dotSpacing = Math.max(2, size * 0.03);
2210
- const extent = size * 0.55;
2282
+ const extentS = size * 0.55;
2283
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
2284
+ const maxDotsPerAxis = Math.min(Math.ceil(extentS * 2 / dotSpacing), 30);
2285
+ const actualSpacing = extentS * 2 / maxDotsPerAxis;
2211
2286
  ctx.globalAlpha = savedAlphaS * 0.7;
2212
- for(let dx = -extent; dx <= extent; dx += dotSpacing)for(let dy = -extent; dy <= extent; dy += dotSpacing){
2213
- // Jitter each dot position for organic feel
2214
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2215
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
2216
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
2217
- ctx.beginPath();
2218
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
2219
- ctx.fill();
2287
+ for(let xi = 0; xi < maxDotsPerAxis; xi++){
2288
+ const dx = -extentS + xi * actualSpacing;
2289
+ for(let yi = 0; yi < maxDotsPerAxis; yi++){
2290
+ const dy = -extentS + yi * actualSpacing;
2291
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2292
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
2293
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
2294
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
2295
+ }
2220
2296
  }
2221
2297
  ctx.restore();
2222
2298
  ctx.globalAlpha = savedAlphaS;
@@ -2249,6 +2325,9 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2249
2325
  case "noise-grain":
2250
2326
  {
2251
2327
  // Procedural noise grain texture clipped to shape boundary
2328
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
2329
+ // quantize alpha into buckets to minimize globalAlpha state changes,
2330
+ // and batch dots by brightness (black/white) × alpha bucket
2252
2331
  const savedAlphaN = ctx.globalAlpha;
2253
2332
  ctx.globalAlpha = savedAlphaN * 0.25;
2254
2333
  ctx.fill(); // base tint
@@ -2257,17 +2336,47 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2257
2336
  ctx.clip();
2258
2337
  const grainSpacing = Math.max(1.5, size * 0.015);
2259
2338
  const extentN = size * 0.55;
2260
- ctx.globalAlpha = savedAlphaN * 0.6;
2261
- for(let gx = -extentN; gx <= extentN; gx += grainSpacing)for(let gy = -extentN; gy <= extentN; gy += grainSpacing){
2262
- if (!rng) break;
2263
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
2264
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
2265
- const brightness = rng() > 0.5 ? 255 : 0;
2266
- const dotAlpha = 0.15 + rng() * 0.35;
2267
- ctx.globalAlpha = savedAlphaN * dotAlpha;
2268
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
2269
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
2270
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
2339
+ if (rng) {
2340
+ // Cap grid to max 40 dots per axis beyond this the grain is
2341
+ // visually indistinguishable but cost scales quadratically.
2342
+ const maxGrainPerAxis = Math.min(Math.ceil(extentN * 2 / grainSpacing), 40);
2343
+ const actualGrainSpacing = extentN * 2 / maxGrainPerAxis;
2344
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 covers the 0.15-0.50 range
2345
+ const BUCKETS = 4;
2346
+ const bucketMin = 0.15;
2347
+ const bucketRange = 0.35;
2348
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
2349
+ const buckets = [];
2350
+ for(let i = 0; i < BUCKETS * 2; i++)buckets.push([]);
2351
+ for(let xi = 0; xi < maxGrainPerAxis; xi++){
2352
+ const gx = -extentN + xi * actualGrainSpacing;
2353
+ for(let yi = 0; yi < maxGrainPerAxis; yi++){
2354
+ const gy = -extentN + yi * actualGrainSpacing;
2355
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
2356
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
2357
+ const isWhite = rng() > 0.5;
2358
+ const dotAlpha = bucketMin + rng() * bucketRange;
2359
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
2360
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
2361
+ const offset = isWhite ? BUCKETS : 0;
2362
+ buckets[offset + bucketIdx].push({
2363
+ x: gx + jx,
2364
+ y: gy + jy,
2365
+ s: dotSize
2366
+ });
2367
+ }
2368
+ }
2369
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
2370
+ for(let color = 0; color < 2; color++){
2371
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
2372
+ for(let b = 0; b < BUCKETS; b++){
2373
+ const dots = buckets[color * BUCKETS + b];
2374
+ if (dots.length === 0) continue;
2375
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
2376
+ ctx.globalAlpha = savedAlphaN * alpha;
2377
+ for(let i = 0; i < dots.length; i++)ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
2378
+ }
2379
+ }
2271
2380
  }
2272
2381
  ctx.restore();
2273
2382
  ctx.fillStyle = fillColor;
@@ -2280,6 +2389,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2280
2389
  case "wood-grain":
2281
2390
  {
2282
2391
  // Parallel wavy lines simulating wood grain, clipped to shape
2392
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
2283
2393
  const savedAlphaW = ctx.globalAlpha;
2284
2394
  ctx.globalAlpha = savedAlphaW * 0.2;
2285
2395
  ctx.fill(); // base tint
@@ -2295,17 +2405,19 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2295
2405
  ctx.globalAlpha = savedAlphaW * 0.5;
2296
2406
  const cosG = Math.cos(grainAngle);
2297
2407
  const sinG = Math.sin(grainAngle);
2408
+ const waveCoeff = waveFreq * Math.PI;
2409
+ const invExtentW = 1 / extentW;
2410
+ // Batch all grain lines into a single path
2411
+ ctx.beginPath();
2298
2412
  for(let d = -extentW; d <= extentW; d += grainLineSpacing){
2299
- ctx.beginPath();
2300
- for(let t = -extentW; t <= extentW; t += 2){
2301
- const wave = Math.sin(t / extentW * waveFreq * Math.PI) * waveAmp;
2302
- const px = t * cosG - (d + wave) * sinG;
2303
- const py = t * sinG + (d + wave) * cosG;
2304
- if (t === -extentW) ctx.moveTo(px, py);
2305
- else ctx.lineTo(px, py);
2413
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
2414
+ ctx.moveTo(-extentW * cosG - (d + firstWave) * sinG, -extentW * sinG + (d + firstWave) * cosG);
2415
+ for(let t = -extentW + 4; t <= extentW; t += 4){
2416
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
2417
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
2306
2418
  }
2307
- ctx.stroke();
2308
2419
  }
2420
+ ctx.stroke();
2309
2421
  ctx.restore();
2310
2422
  ctx.globalAlpha = savedAlphaW;
2311
2423
  ctx.globalAlpha *= 0.35;
@@ -2367,6 +2479,7 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2367
2479
  case "fabric-weave":
2368
2480
  {
2369
2481
  // Interlocking horizontal/vertical threads clipped to shape
2482
+ // Optimized: batch all horizontal threads into one path, all vertical into another
2370
2483
  const savedAlphaF = ctx.globalAlpha;
2371
2484
  ctx.globalAlpha = savedAlphaF * 0.15;
2372
2485
  ctx.fill(); // ghost base
@@ -2376,26 +2489,24 @@ function $9beb8f41637c29fd$export$71b514a25c47df50(ctx, shape, x, y, config) {
2376
2489
  const threadSpacing = Math.max(2, size * 0.04);
2377
2490
  const extentF = size * 0.55;
2378
2491
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
2492
+ // Horizontal threads — batched
2379
2493
  ctx.globalAlpha = savedAlphaF * 0.55;
2380
- // Horizontal threads
2494
+ ctx.beginPath();
2381
2495
  for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2382
- ctx.beginPath();
2383
2496
  ctx.moveTo(-extentF, y);
2384
2497
  ctx.lineTo(extentF, y);
2385
- ctx.stroke();
2386
2498
  }
2387
- // Vertical threads (offset by half spacing for weave effect)
2499
+ ctx.stroke();
2500
+ // Vertical threads (offset by half spacing for weave effect) — batched
2388
2501
  ctx.globalAlpha = savedAlphaF * 0.45;
2389
2502
  ctx.strokeStyle = fillColor;
2390
- for(let x = -extentF; x <= extentF; x += threadSpacing * 2){
2391
- ctx.beginPath();
2392
- for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2393
- // Over-under: draw segment, skip segment
2394
- ctx.moveTo(x, y);
2395
- ctx.lineTo(x, y + threadSpacing);
2396
- }
2397
- ctx.stroke();
2503
+ ctx.beginPath();
2504
+ for(let x = -extentF; x <= extentF; x += threadSpacing * 2)for(let y = -extentF; y <= extentF; y += threadSpacing * 2){
2505
+ // Over-under: draw segment, skip segment
2506
+ ctx.moveTo(x, y);
2507
+ ctx.lineTo(x, y + threadSpacing);
2398
2508
  }
2509
+ ctx.stroke();
2399
2510
  ctx.strokeStyle = strokeColor;
2400
2511
  ctx.restore();
2401
2512
  ctx.globalAlpha = savedAlphaF;
@@ -2461,14 +2572,17 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2461
2572
  ctx.translate(x, y);
2462
2573
  ctx.rotate(rotation * Math.PI / 180);
2463
2574
  // ── Drop shadow — soft colored shadow offset along light direction ──
2464
- if (lightAngle !== undefined && size > 10) {
2575
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
2576
+ // and visually imperceptible at that scale.
2577
+ const useShadow = size >= 20;
2578
+ if (useShadow && lightAngle !== undefined) {
2465
2579
  const shadowDist = size * 0.035;
2466
2580
  const shadowBlurR = size * 0.06;
2467
2581
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
2468
2582
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
2469
2583
  ctx.shadowBlur = shadowBlurR;
2470
2584
  ctx.shadowColor = "rgba(0,0,0,0.12)";
2471
- } else if (glowRadius > 0) {
2585
+ } else if (useShadow && glowRadius > 0) {
2472
2586
  // Glow / shadow effect (legacy path)
2473
2587
  ctx.shadowBlur = glowRadius;
2474
2588
  ctx.shadowColor = glowColor || fillColor;
@@ -2492,30 +2606,27 @@ function $9beb8f41637c29fd$export$bb35a6995ddbf32d(ctx, shape, x, y, config) {
2492
2606
  $9beb8f41637c29fd$var$applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
2493
2607
  }
2494
2608
  // Reset shadow so patterns and highlight aren't double-shadowed
2495
- ctx.shadowBlur = 0;
2496
- ctx.shadowOffsetX = 0;
2497
- ctx.shadowOffsetY = 0;
2498
- ctx.shadowColor = "transparent";
2609
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
2610
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
2611
+ ctx.shadowBlur = 0;
2612
+ ctx.shadowOffsetX = 0;
2613
+ ctx.shadowOffsetY = 0;
2614
+ ctx.shadowColor = "transparent";
2615
+ }
2499
2616
  // ── Specular highlight — tinted arc on the light-facing side ──
2500
- if (lightAngle !== undefined && size > 15 && rng) {
2617
+ // Skip for small shapes (< 30px) gradient creation + composite op
2618
+ // switch is expensive and the highlight is invisible at small sizes.
2619
+ if (lightAngle !== undefined && size > 30 && rng) {
2501
2620
  const hlRadius = size * 0.35;
2502
2621
  const hlDist = size * 0.15;
2503
2622
  const hlX = Math.cos(lightAngle) * hlDist;
2504
2623
  const hlY = Math.sin(lightAngle) * hlDist;
2505
2624
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
2506
- // Tint highlight warm/cool based on fill color for cohesion
2507
- // Parse fill to detect warmth fallback to white for non-parseable
2508
- let hlBase = "255,255,255";
2509
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
2510
- const r = parseInt(fillColor.slice(1, 3), 16);
2511
- const g = parseInt(fillColor.slice(3, 5), 16);
2512
- const b = parseInt(fillColor.slice(5, 7), 16);
2513
- // Blend toward white but keep a hint of the fill's warmth
2514
- hlBase = `${Math.round(r * 0.15 + 216.75)},${Math.round(g * 0.15 + 216.75)},${Math.round(b * 0.15 + 216.75)}`;
2515
- }
2516
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
2517
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
2518
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
2625
+ // Use a simple white highlight the per-shape hex parse was expensive
2626
+ // and the visual difference from tinted highlights is negligible.
2627
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
2628
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
2629
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
2519
2630
  const savedOp = ctx.globalCompositeOperation;
2520
2631
  ctx.globalCompositeOperation = "soft-light";
2521
2632
  ctx.fillStyle = hlGrad;
@@ -4057,6 +4168,46 @@ function $3faa2521b78398cf$export$f1142fd7da4d6590(rng) {
4057
4168
  }
4058
4169
 
4059
4170
 
4171
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
4172
+ // Based on benchmark measurements. Used by the complexity budget to
4173
+ // cap total rendering work and downgrade expensive styles when needed.
4174
+ const $b623126c6e9cbb71$var$RENDER_STYLE_COST = {
4175
+ "fill-and-stroke": 1,
4176
+ "fill-only": 0.5,
4177
+ "stroke-only": 1,
4178
+ "double-stroke": 1.5,
4179
+ "dashed": 1,
4180
+ "watercolor": 7,
4181
+ "hatched": 3,
4182
+ "incomplete": 1,
4183
+ "stipple": 90,
4184
+ "stencil": 2,
4185
+ "noise-grain": 400,
4186
+ "wood-grain": 10,
4187
+ "marble-vein": 4,
4188
+ "fabric-weave": 6,
4189
+ "hand-drawn": 5
4190
+ };
4191
+ function $b623126c6e9cbb71$var$downgradeRenderStyle(style) {
4192
+ switch(style){
4193
+ case "noise-grain":
4194
+ return "hatched";
4195
+ case "stipple":
4196
+ return "dashed";
4197
+ case "wood-grain":
4198
+ return "hatched";
4199
+ case "watercolor":
4200
+ return "fill-and-stroke";
4201
+ case "fabric-weave":
4202
+ return "hatched";
4203
+ case "hand-drawn":
4204
+ return "fill-and-stroke";
4205
+ case "marble-vein":
4206
+ return "stroke-only";
4207
+ default:
4208
+ return style;
4209
+ }
4210
+ }
4060
4211
  // ── Shape categories for weighted selection (legacy fallback) ───────
4061
4212
  const $b623126c6e9cbb71$var$SACRED_SHAPES = [
4062
4213
  "mandala",
@@ -4442,6 +4593,15 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4442
4593
  ...(0, $2bfb6a1ccb7a82ae$export$c2f8e0cc249a8d8f),
4443
4594
  ...config
4444
4595
  };
4596
+ const _dt = finalConfig._debugTiming;
4597
+ const _t = _dt ? ()=>performance.now() : undefined;
4598
+ let _p = _t ? _t() : 0;
4599
+ function _mark(name) {
4600
+ if (!_dt || !_t) return;
4601
+ const now = _t();
4602
+ _dt.phases[name] = now - _p;
4603
+ _p = now;
4604
+ }
4445
4605
  const rng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash));
4446
4606
  // ── 0. Select archetype — fundamentally different visual personality ──
4447
4607
  const archetype = (0, $3faa2521b78398cf$export$f1142fd7da4d6590)(rng);
@@ -4475,12 +4635,14 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4475
4635
  const adjustedMaxSize = maxShapeSize * scaleFactor;
4476
4636
  const cx = width / 2;
4477
4637
  const cy = height / 2;
4638
+ _mark("0_setup");
4478
4639
  // ── 1. Background ──────────────────────────────────────────────
4479
4640
  const bgRadius = Math.hypot(cx, cy);
4480
4641
  $b623126c6e9cbb71$var$drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
4481
4642
  // Gradient mesh overlay — 3-4 color control points for richer backgrounds
4643
+ // Use source-over instead of soft-light for cheaper compositing
4482
4644
  const meshPoints = 3 + Math.floor(rng() * 2);
4483
- ctx.globalCompositeOperation = "soft-light";
4645
+ ctx.globalAlpha = 1;
4484
4646
  for(let i = 0; i < meshPoints; i++){
4485
4647
  const mx = rng() * width;
4486
4648
  const my = rng() * height;
@@ -4489,95 +4651,103 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4489
4651
  const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
4490
4652
  grad.addColorStop(0, (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(mColor, 0.08 + rng() * 0.06));
4491
4653
  grad.addColorStop(1, "rgba(0,0,0,0)");
4492
- ctx.globalAlpha = 1;
4493
4654
  ctx.fillStyle = grad;
4494
- ctx.fillRect(0, 0, width, height);
4655
+ // Clip to gradient bounding box — avoids blending transparent pixels
4656
+ const gx = Math.max(0, mx - mRadius);
4657
+ const gy = Math.max(0, my - mRadius);
4658
+ const gw = Math.min(width, mx + mRadius) - gx;
4659
+ const gh = Math.min(height, my + mRadius) - gy;
4660
+ ctx.fillRect(gx, gy, gw, gh);
4495
4661
  }
4496
- ctx.globalCompositeOperation = "source-over";
4497
4662
  // Compute average background luminance for contrast enforcement
4498
4663
  const bgLum = ((0, $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe)(bgStart) + (0, $9d614e7d77fc2947$export$5c6e3c2b59b7fbbe)(bgEnd)) / 2;
4499
4664
  // ── 1b. Layered background — archetype-coherent shapes ─────────
4665
+ // Use source-over with pre-multiplied alpha instead of soft-light
4666
+ // for much cheaper compositing (soft-light requires per-pixel blend)
4500
4667
  const bgShapeCount = 3 + Math.floor(rng() * 4);
4501
- ctx.globalCompositeOperation = "soft-light";
4502
4668
  for(let i = 0; i < bgShapeCount; i++){
4503
4669
  const bx = rng() * width;
4504
4670
  const by = rng() * height;
4505
4671
  const bSize = width * 0.3 + rng() * width * 0.5;
4506
4672
  const bColor = (0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng);
4507
- ctx.globalAlpha = 0.03 + rng() * 0.05;
4673
+ ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
4508
4674
  ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(bColor, 0.15);
4509
4675
  ctx.beginPath();
4510
4676
  // Use archetype-appropriate background shapes
4511
- if (archetype.name === "geometric-precision" || archetype.name === "op-art") // Rectangular shapes for geometric archetypes
4512
- ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4677
+ if (archetype.name === "geometric-precision" || archetype.name === "op-art") ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
4513
4678
  else ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
4514
4679
  ctx.fill();
4515
4680
  }
4516
- // Subtle concentric rings from center
4681
+ // Subtle concentric rings from center — batched into single stroke
4517
4682
  const ringCount = 2 + Math.floor(rng() * 3);
4518
4683
  ctx.globalAlpha = 0.02 + rng() * 0.03;
4519
4684
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
4520
4685
  ctx.lineWidth = 1 * scaleFactor;
4686
+ ctx.beginPath();
4521
4687
  for(let i = 1; i <= ringCount; i++){
4522
4688
  const r = Math.min(width, height) * 0.15 * i;
4523
- ctx.beginPath();
4689
+ ctx.moveTo(cx + r, cy);
4524
4690
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
4525
- ctx.stroke();
4526
4691
  }
4527
- ctx.globalCompositeOperation = "source-over";
4692
+ ctx.stroke();
4528
4693
  // ── 1c. Background pattern layer — subtle textured paper ───────
4529
4694
  const bgPatternRoll = rng();
4530
4695
  if (bgPatternRoll < 0.6) {
4531
4696
  ctx.save();
4532
- ctx.globalCompositeOperation = "soft-light";
4533
4697
  const patternOpacity = 0.02 + rng() * 0.04;
4534
4698
  const patternColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.15);
4535
4699
  if (bgPatternRoll < 0.2) {
4536
- // Dot grid — batched into a single path
4537
- const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
4538
- const dotR = dotSpacing * 0.08;
4700
+ // Dot grid — use fillRect instead of arcs (much cheaper, no path building)
4701
+ const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
4702
+ const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
4539
4703
  ctx.globalAlpha = patternOpacity;
4540
4704
  ctx.fillStyle = patternColor;
4541
- ctx.beginPath();
4542
- for(let px = 0; px < width; px += dotSpacing)for(let py = 0; py < height; py += dotSpacing){
4543
- ctx.moveTo(px + dotR, py);
4544
- ctx.arc(px, py, dotR, 0, Math.PI * 2);
4705
+ let dotCount = 0;
4706
+ for(let px = 0; px < width && dotCount < 2000; px += dotSpacing)for(let py = 0; py < height && dotCount < 2000; py += dotSpacing){
4707
+ ctx.fillRect(px, py, dotDiam, dotDiam);
4708
+ dotCount++;
4545
4709
  }
4546
- ctx.fill();
4547
4710
  } else if (bgPatternRoll < 0.4) {
4548
- // Diagonal lines — batched into a single path
4549
- const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
4711
+ // Diagonal lines — batched into a single path, capped at 300 lines
4712
+ const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
4550
4713
  ctx.globalAlpha = patternOpacity;
4551
4714
  ctx.strokeStyle = patternColor;
4552
4715
  ctx.lineWidth = 0.5 * scaleFactor;
4553
4716
  const diag = Math.hypot(width, height);
4554
4717
  ctx.beginPath();
4555
- for(let d = -diag; d < diag; d += lineSpacing){
4718
+ let lineCount = 0;
4719
+ for(let d = -diag; d < diag && lineCount < 300; d += lineSpacing){
4556
4720
  ctx.moveTo(d, 0);
4557
4721
  ctx.lineTo(d + height, height);
4722
+ lineCount++;
4558
4723
  }
4559
4724
  ctx.stroke();
4560
4725
  } else {
4561
- // Tessellation — hexagonal grid, batched into a single path
4562
- const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
4726
+ // Tessellation — hexagonal grid, capped at 500 hexagons
4727
+ const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
4563
4728
  const tessH = tessSize * Math.sqrt(3);
4564
4729
  ctx.globalAlpha = patternOpacity * 0.7;
4565
4730
  ctx.strokeStyle = patternColor;
4566
4731
  ctx.lineWidth = 0.4 * scaleFactor;
4732
+ // Pre-compute hex vertex offsets (avoid trig per vertex)
4733
+ const hexVx = [];
4734
+ const hexVy = [];
4735
+ for(let s = 0; s < 6; s++){
4736
+ const angle = Math.PI / 3 * s - Math.PI / 6;
4737
+ hexVx.push(Math.cos(angle) * tessSize * 0.5);
4738
+ hexVy.push(Math.sin(angle) * tessSize * 0.5);
4739
+ }
4567
4740
  ctx.beginPath();
4568
- for(let row = 0; row * tessH < height + tessH; row++){
4741
+ let hexCount = 0;
4742
+ for(let row = 0; row * tessH < height + tessH && hexCount < 500; row++){
4569
4743
  const offsetX = row % 2 * tessSize * 0.75;
4570
- for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++){
4744
+ for(let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++){
4571
4745
  const hx = col * tessSize * 1.5 + offsetX;
4572
4746
  const hy = row * tessH;
4573
- for(let s = 0; s < 6; s++){
4574
- const angle = Math.PI / 3 * s - Math.PI / 6;
4575
- const vx = hx + Math.cos(angle) * tessSize * 0.5;
4576
- const vy = hy + Math.sin(angle) * tessSize * 0.5;
4577
- if (s === 0) ctx.moveTo(vx, vy);
4578
- else ctx.lineTo(vx, vy);
4579
- }
4747
+ ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
4748
+ for(let s = 1; s < 6; s++)ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
4580
4749
  ctx.closePath();
4750
+ hexCount++;
4581
4751
  }
4582
4752
  }
4583
4753
  ctx.stroke();
@@ -4585,6 +4755,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4585
4755
  ctx.restore();
4586
4756
  }
4587
4757
  ctx.globalCompositeOperation = "source-over";
4758
+ _mark("1_background");
4588
4759
  // ── 2. Composition mode — archetype-aware selection ──────────────
4589
4760
  const compositionMode = rng() < 0.7 ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)] : $b623126c6e9cbb71$var$ALL_COMPOSITION_MODES[Math.floor(rng() * $b623126c6e9cbb71$var$ALL_COMPOSITION_MODES.length)];
4590
4761
  const symRoll = rng();
@@ -4668,19 +4839,20 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4668
4839
  ctx.beginPath();
4669
4840
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
4670
4841
  ctx.stroke();
4671
- // ~50% chance: scatter tiny dots inside the void
4842
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
4672
4843
  if (rng() < 0.5) {
4673
4844
  const dotCount = 3 + Math.floor(rng() * 6);
4674
4845
  ctx.globalAlpha = 0.06 + rng() * 0.04;
4675
4846
  ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
4847
+ ctx.beginPath();
4676
4848
  for(let d = 0; d < dotCount; d++){
4677
4849
  const angle = rng() * Math.PI * 2;
4678
4850
  const dist = rng() * zone.radius * 0.7;
4679
4851
  const dotR = (1 + rng() * 3) * scaleFactor;
4680
- ctx.beginPath();
4852
+ ctx.moveTo(zone.x + Math.cos(angle) * dist + dotR, zone.y + Math.sin(angle) * dist);
4681
4853
  ctx.arc(zone.x + Math.cos(angle) * dist, zone.y + Math.sin(angle) * dist, dotR, 0, Math.PI * 2);
4682
- ctx.fill();
4683
4854
  }
4855
+ ctx.fill();
4684
4856
  }
4685
4857
  // ~30% chance: thin concentric ring inside
4686
4858
  if (rng() < 0.3) {
@@ -4694,6 +4866,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4694
4866
  }
4695
4867
  }
4696
4868
  ctx.globalAlpha = 1;
4869
+ _mark("2_3_composition_focal");
4697
4870
  // ── 4. Flow field — simplex noise for organic variation ─────────
4698
4871
  // Create a seeded simplex noise field (unique per hash)
4699
4872
  const noiseFieldRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 333));
@@ -4770,8 +4943,29 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4770
4943
  shape: heroShape
4771
4944
  });
4772
4945
  }
4946
+ _mark("4_flowfield_hero");
4773
4947
  // ── 5. Shape layers ────────────────────────────────────────────
4774
4948
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
4949
+ // ── Complexity budget — caps total rendering work ──────────────
4950
+ // Budget scales with pixel area so larger canvases get proportionally
4951
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
4952
+ // constellations, rhythm) are gated behind the budget; when it runs
4953
+ // low they are skipped. When it's exhausted, expensive render styles
4954
+ // are downgraded to cheaper alternatives.
4955
+ //
4956
+ // RNG values are always consumed even when skipping, so the
4957
+ // deterministic sequence for shapes that *do* render is preserved.
4958
+ const pixelArea = width * height;
4959
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
4960
+ let complexityBudget = pixelArea / 1000000 * BUDGET_PER_MEGAPIXEL;
4961
+ const totalBudget = complexityBudget;
4962
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
4963
+ let extrasSpent = 0;
4964
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
4965
+ // These generate O(size²) fillRect calls per shape and dominate
4966
+ // worst-case render time. Cap scales with pixel area.
4967
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1000000)));
4968
+ let clipHeavyCount = 0;
4775
4969
  for(let layer = 0; layer < layers; layer++){
4776
4970
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
4777
4971
  const numShapes = shapesPerLayer + Math.floor(rng() * shapesPerLayer * 0.3);
@@ -4860,7 +5054,26 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4860
5054
  const shapeRenderStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(shape, layerRenderStyle, rng);
4861
5055
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
4862
5056
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
4863
- const finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5057
+ let finalRenderStyle = useOrganicEdges ? "watercolor" : shapeRenderStyle;
5058
+ // Budget check: downgrade expensive styles proportionally —
5059
+ // the more expensive the style, the earlier it gets downgraded.
5060
+ // noise-grain (400) downgrades when budget < 20% remaining,
5061
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
5062
+ let styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5063
+ if (styleCost > 3) {
5064
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
5065
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
5066
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5067
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5068
+ }
5069
+ }
5070
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
5071
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
5072
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
5073
+ finalRenderStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(finalRenderStyle);
5074
+ styleCost = $b623126c6e9cbb71$var$RENDER_STYLE_COST[finalRenderStyle] ?? 1;
5075
+ }
5076
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") clipHeavyCount++;
4864
5077
  // Consistent light direction — subtle shadow offset
4865
5078
  const shadowDist = hasGlow ? 0 : size * 0.02;
4866
5079
  const shadowOffX = shadowDist * Math.cos(lightAngle);
@@ -4915,30 +5128,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4915
5128
  lightAngle: lightAngle,
4916
5129
  scaleFactor: scaleFactor
4917
5130
  };
4918
- if (shouldMirror) (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
4919
- ...shapeConfig,
4920
- mirrorAxis: mirrorAxis,
4921
- mirrorGap: size * (0.1 + rng() * 0.3)
4922
- });
4923
- else (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5131
+ if (shouldMirror) {
5132
+ (0, $9beb8f41637c29fd$export$8bd8bbd1a8e53689)(ctx, shape, finalX, finalY, {
5133
+ ...shapeConfig,
5134
+ mirrorAxis: mirrorAxis,
5135
+ mirrorGap: size * (0.1 + rng() * 0.3)
5136
+ });
5137
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
5138
+ } else {
5139
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, shapeConfig);
5140
+ complexityBudget -= styleCost;
5141
+ }
5142
+ // ── Extras budget gate — skip multiplier sections when over budget ──
5143
+ const extrasAllowed = extrasSpent < budgetForExtras;
4924
5144
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
4925
5145
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
4926
5146
  const glazePasses = 2 + Math.floor(rng() * 2);
4927
- for(let g = 0; g < glazePasses; g++){
4928
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
4929
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
4930
- ctx.globalAlpha = glazeAlpha;
4931
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
4932
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
4933
- strokeColor: "rgba(0,0,0,0)",
4934
- strokeWidth: 0,
4935
- size: size * glazeScale,
4936
- rotation: rotation,
4937
- proportionType: "GOLDEN_RATIO",
4938
- renderStyle: "fill-only",
4939
- rng: rng
4940
- });
5147
+ if (extrasAllowed) {
5148
+ for(let g = 0; g < glazePasses; g++){
5149
+ const glazeScale = 1 - (g + 1) * 0.12;
5150
+ const glazeAlpha = 0.08 + g * 0.04;
5151
+ ctx.globalAlpha = glazeAlpha;
5152
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, finalX, finalY, {
5153
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, 0.15 + g * 0.1),
5154
+ strokeColor: "rgba(0,0,0,0)",
5155
+ strokeWidth: 0,
5156
+ size: size * glazeScale,
5157
+ rotation: rotation,
5158
+ proportionType: "GOLDEN_RATIO",
5159
+ renderStyle: "fill-only",
5160
+ rng: rng
5161
+ });
5162
+ }
5163
+ extrasSpent += glazePasses;
4941
5164
  }
5165
+ // RNG consumed by glazePasses calculation above regardless
4942
5166
  }
4943
5167
  shapePositions.push({
4944
5168
  x: finalX,
@@ -4956,37 +5180,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4956
5180
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
4957
5181
  const echoCount = 2 + Math.floor(rng() * 2);
4958
5182
  const echoAngle = rng() * Math.PI * 2;
4959
- for(let e = 0; e < echoCount; e++){
4960
- const echoScale = 0.3 - e * 0.08;
4961
- const echoDist = size * (0.6 + e * 0.4);
4962
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
4963
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
4964
- const echoSize = size * Math.max(0.1, echoScale);
4965
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
4966
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
4967
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
4968
- fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
4969
- strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
4970
- strokeWidth: strokeWidth * 0.6,
4971
- size: echoSize,
4972
- rotation: rotation + (e + 1) * 15,
4973
- proportionType: "GOLDEN_RATIO",
4974
- renderStyle: finalRenderStyle,
4975
- rng: rng
4976
- });
4977
- shapePositions.push({
4978
- x: echoX,
4979
- y: echoY,
4980
- size: echoSize,
4981
- shape: shape
4982
- });
4983
- spatialGrid.insert({
4984
- x: echoX,
4985
- y: echoY,
4986
- size: echoSize,
4987
- shape: shape
4988
- });
5183
+ if (extrasAllowed) {
5184
+ for(let e = 0; e < echoCount; e++){
5185
+ const echoScale = 0.3 - e * 0.08;
5186
+ const echoDist = size * (0.6 + e * 0.4);
5187
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
5188
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
5189
+ const echoSize = size * Math.max(0.1, echoScale);
5190
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
5191
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
5192
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, shape, echoX, echoY, {
5193
+ fillColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(fillColor, fillAlpha * 0.6),
5194
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.4),
5195
+ strokeWidth: strokeWidth * 0.6,
5196
+ size: echoSize,
5197
+ rotation: rotation + (e + 1) * 15,
5198
+ proportionType: "GOLDEN_RATIO",
5199
+ renderStyle: finalRenderStyle,
5200
+ rng: rng
5201
+ });
5202
+ shapePositions.push({
5203
+ x: echoX,
5204
+ y: echoY,
5205
+ size: echoSize,
5206
+ shape: shape
5207
+ });
5208
+ spatialGrid.insert({
5209
+ x: echoX,
5210
+ y: echoY,
5211
+ size: echoSize,
5212
+ shape: shape
5213
+ });
5214
+ }
5215
+ extrasSpent += echoCount * styleCost;
4989
5216
  }
5217
+ // RNG for echoCount + echoAngle consumed above regardless
4990
5218
  }
4991
5219
  // ── 5d. Recursive nesting ──────────────────────────────────
4992
5220
  // Focal depth: shapes near focal points get more detail
@@ -4994,7 +5222,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
4994
5222
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
4995
5223
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
4996
5224
  const innerCount = 1 + Math.floor(rng() * 3);
4997
- for(let n = 0; n < innerCount; n++){
5225
+ if (extrasAllowed) for(let n = 0; n < innerCount; n++){
4998
5226
  // Pick inner shape from palette affinities
4999
5227
  const innerSizeFraction = size * 0.25 / adjustedMaxSize;
5000
5228
  const innerShape = (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, innerSizeFraction);
@@ -5003,6 +5231,10 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5003
5231
  const innerOffY = (rng() - 0.5) * size * 0.4;
5004
5232
  const innerRot = rng() * 360;
5005
5233
  const innerFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 10, 0.1), 0.3 + rng() * 0.4);
5234
+ let innerStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng);
5235
+ // Apply clip-heavy cap to nested shapes too
5236
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) innerStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(innerStyle);
5237
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
5006
5238
  ctx.globalAlpha = layerOpacity * 0.7;
5007
5239
  (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, innerShape, finalX + innerOffX, finalY + innerOffY, {
5008
5240
  fillColor: innerFill,
@@ -5011,9 +5243,21 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5011
5243
  size: innerSize,
5012
5244
  rotation: innerRot,
5013
5245
  proportionType: "GOLDEN_RATIO",
5014
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(innerShape, layerRenderStyle, rng),
5246
+ renderStyle: innerStyle,
5015
5247
  rng: rng
5016
5248
  });
5249
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[innerStyle] ?? 1;
5250
+ }
5251
+ else // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
5252
+ for(let n = 0; n < innerCount; n++){
5253
+ rng();
5254
+ rng();
5255
+ rng();
5256
+ rng();
5257
+ rng();
5258
+ rng();
5259
+ rng();
5260
+ rng();
5017
5261
  }
5018
5262
  }
5019
5263
  // ── 5e. Shape constellations — pre-composed groups ─────────
@@ -5022,40 +5266,55 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5022
5266
  const constellation = $b623126c6e9cbb71$var$CONSTELLATIONS[Math.floor(rng() * $b623126c6e9cbb71$var$CONSTELLATIONS.length)];
5023
5267
  const members = constellation.build(rng, size);
5024
5268
  const groupRotation = rng() * Math.PI * 2;
5025
- const cosR = Math.cos(groupRotation);
5026
- const sinR = Math.sin(groupRotation);
5027
- for (const member of members){
5028
- // Rotate the group offset by the group rotation
5029
- const mx = finalX + member.dx * cosR - member.dy * sinR;
5030
- const my = finalY + member.dx * sinR + member.dy * cosR;
5031
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
5032
- const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5033
- const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5034
- ctx.globalAlpha = layerOpacity * 0.6;
5035
- // Use the member's shape if available, otherwise fall back to palette
5036
- const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5037
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5038
- fillColor: memberFill,
5039
- strokeColor: memberStroke,
5040
- strokeWidth: strokeWidth * 0.7,
5041
- size: member.size,
5042
- rotation: member.rotation + groupRotation * 180 / Math.PI,
5043
- proportionType: "GOLDEN_RATIO",
5044
- renderStyle: (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng),
5045
- rng: rng
5046
- });
5047
- shapePositions.push({
5048
- x: mx,
5049
- y: my,
5050
- size: member.size,
5051
- shape: memberShape
5052
- });
5053
- spatialGrid.insert({
5054
- x: mx,
5055
- y: my,
5056
- size: member.size,
5057
- shape: memberShape
5058
- });
5269
+ if (extrasAllowed) {
5270
+ const cosR = Math.cos(groupRotation);
5271
+ const sinR = Math.sin(groupRotation);
5272
+ for (const member of members){
5273
+ // Rotate the group offset by the group rotation
5274
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
5275
+ const my = finalY + member.dx * sinR + member.dy * cosR;
5276
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
5277
+ const memberFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), rng, 8, 0.06), fillAlpha * 0.8);
5278
+ const memberStroke = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)(strokeBase, rng, 5, 0.04), bgLum);
5279
+ ctx.globalAlpha = layerOpacity * 0.6;
5280
+ // Use the member's shape if available, otherwise fall back to palette
5281
+ const memberShape = shapeNames.includes(member.shape) ? member.shape : (0, $24064302523652b1$export$3c37d9a045754d0e)(shapePalette, rng, member.size / adjustedMaxSize);
5282
+ let memberStyle = (0, $24064302523652b1$export$ab873bb6fb56c1a8)(memberShape, layerRenderStyle, rng);
5283
+ // Apply clip-heavy cap to constellation members too
5284
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") && clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) memberStyle = $b623126c6e9cbb71$var$downgradeRenderStyle(memberStyle);
5285
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
5286
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, memberShape, mx, my, {
5287
+ fillColor: memberFill,
5288
+ strokeColor: memberStroke,
5289
+ strokeWidth: strokeWidth * 0.7,
5290
+ size: member.size,
5291
+ rotation: member.rotation + groupRotation * 180 / Math.PI,
5292
+ proportionType: "GOLDEN_RATIO",
5293
+ renderStyle: memberStyle,
5294
+ rng: rng
5295
+ });
5296
+ shapePositions.push({
5297
+ x: mx,
5298
+ y: my,
5299
+ size: member.size,
5300
+ shape: memberShape
5301
+ });
5302
+ spatialGrid.insert({
5303
+ x: mx,
5304
+ y: my,
5305
+ size: member.size,
5306
+ shape: memberShape
5307
+ });
5308
+ extrasSpent += $b623126c6e9cbb71$var$RENDER_STYLE_COST[memberStyle] ?? 1;
5309
+ }
5310
+ } else // Drain RNG — each member consumes ~6 rng calls for colors/style
5311
+ for(let m = 0; m < members.length; m++){
5312
+ rng();
5313
+ rng();
5314
+ rng();
5315
+ rng();
5316
+ rng();
5317
+ rng();
5059
5318
  }
5060
5319
  }
5061
5320
  // ── 5f. Rhythm placement — deliberate geometric progressions ──
@@ -5066,45 +5325,58 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5066
5325
  const rhythmSpacing = size * (0.8 + rng() * 0.6);
5067
5326
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
5068
5327
  const rhythmShape = shape; // same shape for visual rhythm
5069
- let rhythmSize = size * 0.6;
5328
+ if (extrasAllowed) {
5329
+ let rhythmSize = size * 0.6;
5330
+ for(let r = 0; r < rhythmCount; r++){
5331
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5332
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5333
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5334
+ if ($b623126c6e9cbb71$var$isInVoidZone(rx, ry, voidZones)) break;
5335
+ rhythmSize *= rhythmDecay;
5336
+ if (rhythmSize < adjustedMinSize) break;
5337
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5338
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5339
+ const rhythmFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5340
+ (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5341
+ fillColor: rhythmFill,
5342
+ strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.5),
5343
+ strokeWidth: strokeWidth * 0.7,
5344
+ size: rhythmSize,
5345
+ rotation: rotation + (r + 1) * 12,
5346
+ proportionType: "GOLDEN_RATIO",
5347
+ renderStyle: finalRenderStyle,
5348
+ rng: rng
5349
+ });
5350
+ shapePositions.push({
5351
+ x: rx,
5352
+ y: ry,
5353
+ size: rhythmSize,
5354
+ shape: rhythmShape
5355
+ });
5356
+ spatialGrid.insert({
5357
+ x: rx,
5358
+ y: ry,
5359
+ size: rhythmSize,
5360
+ shape: rhythmShape
5361
+ });
5362
+ }
5363
+ extrasSpent += rhythmCount * styleCost;
5364
+ } else // Drain RNG — each rhythm step consumes ~3 rng calls for colors
5070
5365
  for(let r = 0; r < rhythmCount; r++){
5071
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
5072
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
5073
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
5074
- if ($b623126c6e9cbb71$var$isInVoidZone(rx, ry, voidZones)) break;
5075
- rhythmSize *= rhythmDecay;
5076
- if (rhythmSize < adjustedMinSize) break;
5077
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
5078
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
5079
- const rhythmFill = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$18a34c25ea7e724b)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(layerHierarchy, rng), rng, 5, 0.04), fillAlpha * 0.7);
5080
- (0, $9beb8f41637c29fd$export$bb35a6995ddbf32d)(ctx, rhythmShape, rx, ry, {
5081
- fillColor: rhythmFill,
5082
- strokeColor: (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(strokeColor, 0.5),
5083
- strokeWidth: strokeWidth * 0.7,
5084
- size: rhythmSize,
5085
- rotation: rotation + (r + 1) * 12,
5086
- proportionType: "GOLDEN_RATIO",
5087
- renderStyle: finalRenderStyle,
5088
- rng: rng
5089
- });
5090
- shapePositions.push({
5091
- x: rx,
5092
- y: ry,
5093
- size: rhythmSize,
5094
- shape: rhythmShape
5095
- });
5096
- spatialGrid.insert({
5097
- x: rx,
5098
- y: ry,
5099
- size: rhythmSize,
5100
- shape: rhythmShape
5101
- });
5366
+ rng();
5367
+ rng();
5368
+ rng();
5102
5369
  }
5103
5370
  }
5104
5371
  }
5105
5372
  }
5106
5373
  // Reset blend mode for post-processing passes
5107
5374
  ctx.globalCompositeOperation = "source-over";
5375
+ if (_dt) {
5376
+ _dt.shapeCount = shapePositions.length;
5377
+ _dt.extraCount = extrasSpent;
5378
+ }
5379
+ _mark("5_shape_layers");
5108
5380
  // ── 5g. Layered masking / cutout portals ───────────────────────
5109
5381
  // ~18% of images get 1-3 portal windows that paint over foreground
5110
5382
  // with a tinted background wash, creating a "peek through" effect.
@@ -5163,15 +5435,28 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5163
5435
  ctx.restore();
5164
5436
  }
5165
5437
  }
5438
+ _mark("5g_portals");
5166
5439
  // ── 6. Flow-line pass — variable color, branching, pressure ────
5440
+ // Optimized: collect all segments into width-quantized buckets, then
5441
+ // render each bucket as a single batched path. This reduces
5442
+ // beginPath/stroke calls from O(segments) to O(buckets).
5167
5443
  const baseFlowLines = 6 + Math.floor(rng() * 10);
5168
5444
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
5445
+ // Width buckets — 6 buckets cover the taper×pressure range
5446
+ const FLOW_WIDTH_BUCKETS = 6;
5447
+ const flowBuckets = [];
5448
+ for(let b = 0; b < FLOW_WIDTH_BUCKETS; b++)flowBuckets.push([]);
5449
+ // Track the representative width for each bucket
5450
+ const flowBucketWidths = new Array(FLOW_WIDTH_BUCKETS);
5451
+ // Pre-compute max possible width for bucket assignment
5452
+ let globalMaxFlowWidth = 0;
5169
5453
  for(let i = 0; i < numFlowLines; i++){
5170
5454
  let fx = rng() * width;
5171
5455
  let fy = rng() * height;
5172
5456
  const steps = 30 + Math.floor(rng() * 40);
5173
5457
  const stepLen = (3 + rng() * 5) * scaleFactor;
5174
5458
  const startWidth = (1 + rng() * 3) * scaleFactor;
5459
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
5175
5460
  // Variable color: interpolate between two hierarchy colors along the stroke
5176
5461
  const lineColorStart = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
5177
5462
  const lineColorEnd = (0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum);
@@ -5193,19 +5478,22 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5193
5478
  continue;
5194
5479
  }
5195
5480
  const t = s / steps;
5196
- // Taper + pressure
5197
5481
  const taper = 1 - t * 0.8;
5198
5482
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
5199
- ctx.globalAlpha = lineAlpha * taper;
5200
- // Interpolate color along stroke
5483
+ const segWidth = startWidth * taper * pressure;
5484
+ const segAlpha = lineAlpha * taper;
5201
5485
  const lineColor = t < 0.5 ? (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorStart, 0.4 + t * 0.2) : (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(lineColorEnd, 0.4 + (1 - t) * 0.2);
5202
- ctx.strokeStyle = lineColor;
5203
- ctx.lineWidth = startWidth * taper * pressure;
5204
- ctx.lineCap = "round";
5205
- ctx.beginPath();
5206
- ctx.moveTo(prevX, prevY);
5207
- ctx.lineTo(fx, fy);
5208
- ctx.stroke();
5486
+ // Quantize width into bucket
5487
+ const bucketIdx = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(segWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5488
+ flowBuckets[bucketIdx].push({
5489
+ x1: prevX,
5490
+ y1: prevY,
5491
+ x2: fx,
5492
+ y2: fy,
5493
+ color: lineColor,
5494
+ alpha: segAlpha
5495
+ });
5496
+ flowBucketWidths[bucketIdx] = segWidth;
5209
5497
  // Branching: ~12% chance per step to spawn a thinner child stroke
5210
5498
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
5211
5499
  const branchAngle = angle + (rng() < 0.5 ? 1 : -1) * (0.3 + rng() * 0.5);
@@ -5221,12 +5509,18 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5221
5509
  by += Math.sin(bAngle) * stepLen * 0.8;
5222
5510
  if (bx < 0 || bx > width || by < 0 || by > height) break;
5223
5511
  const bTaper = 1 - bs / branchSteps * 0.9;
5224
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
5225
- ctx.lineWidth = branchWidth * bTaper;
5226
- ctx.beginPath();
5227
- ctx.moveTo(bPrevX, bPrevY);
5228
- ctx.lineTo(bx, by);
5229
- ctx.stroke();
5512
+ const bSegWidth = branchWidth * bTaper;
5513
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
5514
+ const bBucket = Math.min(FLOW_WIDTH_BUCKETS - 1, Math.floor(bSegWidth / (globalMaxFlowWidth || 1) * FLOW_WIDTH_BUCKETS));
5515
+ flowBuckets[bBucket].push({
5516
+ x1: bPrevX,
5517
+ y1: bPrevY,
5518
+ x2: bx,
5519
+ y2: by,
5520
+ color: lineColor,
5521
+ alpha: bAlpha
5522
+ });
5523
+ flowBucketWidths[bBucket] = bSegWidth;
5230
5524
  bPrevX = bx;
5231
5525
  bPrevY = by;
5232
5526
  }
@@ -5235,7 +5529,41 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5235
5529
  prevY = fy;
5236
5530
  }
5237
5531
  }
5532
+ // Render flow line buckets — one batched path per width bucket
5533
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
5534
+ ctx.lineCap = "round";
5535
+ const FLOW_ALPHA_BUCKETS = 4;
5536
+ for(let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++){
5537
+ const segs = flowBuckets[wb];
5538
+ if (segs.length === 0) continue;
5539
+ ctx.lineWidth = flowBucketWidths[wb];
5540
+ // Sub-bucket by alpha
5541
+ const alphaSubs = [];
5542
+ for(let a = 0; a < FLOW_ALPHA_BUCKETS; a++)alphaSubs.push([]);
5543
+ let maxAlpha = 0;
5544
+ for(let j = 0; j < segs.length; j++)if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
5545
+ for(let j = 0; j < segs.length; j++){
5546
+ const ai = Math.min(FLOW_ALPHA_BUCKETS - 1, Math.floor(segs[j].alpha / (maxAlpha || 1) * FLOW_ALPHA_BUCKETS));
5547
+ alphaSubs[ai].push(segs[j]);
5548
+ }
5549
+ for(let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++){
5550
+ const sub = alphaSubs[ai];
5551
+ if (sub.length === 0) continue;
5552
+ // Use the median segment's alpha and color as representative
5553
+ const rep = sub[Math.floor(sub.length / 2)];
5554
+ ctx.globalAlpha = rep.alpha;
5555
+ ctx.strokeStyle = rep.color;
5556
+ ctx.beginPath();
5557
+ for(let j = 0; j < sub.length; j++){
5558
+ ctx.moveTo(sub[j].x1, sub[j].y1);
5559
+ ctx.lineTo(sub[j].x2, sub[j].y2);
5560
+ }
5561
+ ctx.stroke();
5562
+ }
5563
+ }
5564
+ _mark("6_flow_lines");
5238
5565
  // ── 6b. Motion/energy lines — short directional bursts ─────────
5566
+ // Optimized: collect all burst segments, then batch by quantized alpha
5239
5567
  const energyArchetypes = [
5240
5568
  "dense-chaotic",
5241
5569
  "cosmic",
@@ -5246,8 +5574,12 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5246
5574
  if (hasEnergyLines && shapePositions.length > 0) {
5247
5575
  const energyCount = 5 + Math.floor(rng() * 10);
5248
5576
  ctx.lineCap = "round";
5577
+ // Collect all energy segments with their computed state
5578
+ const ENERGY_ALPHA_BUCKETS = 3;
5579
+ const energyBuckets = [];
5580
+ for(let b = 0; b < ENERGY_ALPHA_BUCKETS; b++)energyBuckets.push([]);
5581
+ const energyAlphas = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
5249
5582
  for(let e = 0; e < energyCount; e++){
5250
- // Pick a random shape to radiate from
5251
5583
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
5252
5584
  const burstCount = 2 + Math.floor(rng() * 4);
5253
5585
  const baseAngle = flowAngle(source.x, source.y);
@@ -5259,16 +5591,40 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5259
5591
  const sy = source.y + Math.sin(angle) * startDist;
5260
5592
  const ex = sx + Math.cos(angle) * lineLen;
5261
5593
  const ey = sy + Math.sin(angle) * lineLen;
5262
- ctx.globalAlpha = 0.04 + rng() * 0.06;
5263
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5264
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
5265
- ctx.beginPath();
5266
- ctx.moveTo(sx, sy);
5267
- ctx.lineTo(ex, ey);
5268
- ctx.stroke();
5594
+ const eAlpha = 0.04 + rng() * 0.06;
5595
+ const eColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5596
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
5597
+ // Quantize alpha into bucket
5598
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
5599
+ energyBuckets[bi].push({
5600
+ x1: sx,
5601
+ y1: sy,
5602
+ x2: ex,
5603
+ y2: ey,
5604
+ color: eColor,
5605
+ lw: eLw
5606
+ });
5607
+ energyAlphas[bi] = eAlpha;
5269
5608
  }
5270
5609
  }
5610
+ // Render batched energy lines
5611
+ for(let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++){
5612
+ const segs = energyBuckets[bi];
5613
+ if (segs.length === 0) continue;
5614
+ ctx.globalAlpha = energyAlphas[bi];
5615
+ // Use median segment's color and width as representative
5616
+ const rep = segs[Math.floor(segs.length / 2)];
5617
+ ctx.strokeStyle = rep.color;
5618
+ ctx.lineWidth = rep.lw;
5619
+ ctx.beginPath();
5620
+ for(let j = 0; j < segs.length; j++){
5621
+ ctx.moveTo(segs[j].x1, segs[j].y1);
5622
+ ctx.lineTo(segs[j].x2, segs[j].y2);
5623
+ }
5624
+ ctx.stroke();
5625
+ }
5271
5626
  }
5627
+ _mark("6b_energy_lines");
5272
5628
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
5273
5629
  if (symmetryMode !== "none") {
5274
5630
  const canvas = ctx.canvas;
@@ -5289,43 +5645,25 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5289
5645
  }
5290
5646
  ctx.restore();
5291
5647
  }
5292
- // ── 7. Noise texture overlay — batched via ImageData ─────────────
5648
+ _mark("6c_symmetry");
5649
+ // ── 7. Noise texture overlay ─────────────────────────────────────
5650
+ // With density capped at 2500 dots, direct fillRect calls are far cheaper
5651
+ // than the getImageData/putImageData round-trip which copies the entire
5652
+ // pixel buffer (4 × width × height bytes) twice.
5293
5653
  const noiseRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 777));
5294
- const noiseDensity = Math.floor(width * height / 800);
5295
- try {
5296
- const imageData = ctx.getImageData(0, 0, width, height);
5297
- const data = imageData.data;
5298
- const pixelScale = Math.max(1, Math.round(scaleFactor));
5299
- for(let i = 0; i < noiseDensity; i++){
5300
- const nx = Math.floor(noiseRng() * width);
5301
- const ny = Math.floor(noiseRng() * height);
5302
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5303
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
5304
- // Write a small block of pixels for scale
5305
- for(let dy = 0; dy < pixelScale && ny + dy < height; dy++)for(let dx = 0; dx < pixelScale && nx + dx < width; dx++){
5306
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
5307
- // Alpha-blend the noise dot onto existing pixel data
5308
- const srcA = alpha / 255;
5309
- const invA = 1 - srcA;
5310
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
5311
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
5312
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
5313
- // Keep existing alpha
5314
- }
5315
- }
5316
- ctx.putImageData(imageData, 0, 0);
5317
- } catch {
5318
- // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
5319
- for(let i = 0; i < noiseDensity; i++){
5320
- const nx = noiseRng() * width;
5321
- const ny = noiseRng() * height;
5322
- const brightness = noiseRng() > 0.5 ? 255 : 0;
5323
- const alpha = 0.01 + noiseRng() * 0.03;
5324
- ctx.globalAlpha = alpha;
5325
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
5326
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
5327
- }
5654
+ const rawNoiseDensity = Math.floor(width * height / 800);
5655
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
5656
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
5657
+ for(let i = 0; i < noiseDensity; i++){
5658
+ const nx = noiseRng() * width;
5659
+ const ny = noiseRng() * height;
5660
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
5661
+ const alpha = 0.01 + noiseRng() * 0.03;
5662
+ ctx.globalAlpha = alpha;
5663
+ ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
5664
+ ctx.fillRect(nx, ny, pixelScale, pixelScale);
5328
5665
  }
5666
+ _mark("7_noise_texture");
5329
5667
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
5330
5668
  ctx.globalAlpha = 1;
5331
5669
  const vignetteStrength = 0.25 + rng() * 0.2;
@@ -5339,11 +5677,20 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5339
5677
  vigGrad.addColorStop(1, vignetteColor);
5340
5678
  ctx.fillStyle = vigGrad;
5341
5679
  ctx.fillRect(0, 0, width, height);
5680
+ _mark("8_vignette");
5342
5681
  // ── 9. Organic connecting curves — proximity-aware ───────────────
5682
+ // Optimized: batch all curves into alpha-quantized groups to reduce
5683
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
5343
5684
  if (shapePositions.length > 1) {
5344
5685
  const numCurves = Math.floor(8 * (width * height) / 1048576);
5345
5686
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
5346
5687
  ctx.lineWidth = 0.8 * scaleFactor;
5688
+ // Collect curves into 3 alpha buckets
5689
+ const CURVE_ALPHA_BUCKETS = 3;
5690
+ const curveBuckets = [];
5691
+ const curveColors = [];
5692
+ const curveAlphas = new Array(CURVE_ALPHA_BUCKETS).fill(0);
5693
+ for(let b = 0; b < CURVE_ALPHA_BUCKETS; b++)curveBuckets.push([]);
5347
5694
  for(let i = 0; i < numCurves; i++){
5348
5695
  const idxA = Math.floor(rng() * shapePositions.length);
5349
5696
  const offset = 1 + Math.floor(rng() * Math.min(5, shapePositions.length - 1));
@@ -5360,14 +5707,36 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5360
5707
  const bulge = (rng() - 0.5) * dist * 0.4;
5361
5708
  const cpx = mx + -dy / (dist || 1) * bulge;
5362
5709
  const cpy = my + dx / (dist || 1) * bulge;
5363
- ctx.globalAlpha = 0.06 + rng() * 0.1;
5364
- ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5710
+ const curveAlpha = 0.06 + rng() * 0.1;
5711
+ const curveColor = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)((0, $9d614e7d77fc2947$export$90ad0e6170cf6af5)((0, $9d614e7d77fc2947$export$b49f62f0a99da0e8)(colorHierarchy, rng), bgLum), 0.3);
5712
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
5713
+ curveBuckets[bi].push({
5714
+ ax: a.x,
5715
+ ay: a.y,
5716
+ cpx: cpx,
5717
+ cpy: cpy,
5718
+ bx: b.x,
5719
+ by: b.y
5720
+ });
5721
+ curveAlphas[bi] = curveAlpha;
5722
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
5723
+ }
5724
+ // Render batched curves
5725
+ for(let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++){
5726
+ const curves = curveBuckets[bi];
5727
+ if (curves.length === 0) continue;
5728
+ ctx.globalAlpha = curveAlphas[bi];
5729
+ ctx.strokeStyle = curveColors[bi];
5365
5730
  ctx.beginPath();
5366
- ctx.moveTo(a.x, a.y);
5367
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
5731
+ for(let j = 0; j < curves.length; j++){
5732
+ const c = curves[j];
5733
+ ctx.moveTo(c.ax, c.ay);
5734
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
5735
+ }
5368
5736
  ctx.stroke();
5369
5737
  }
5370
5738
  }
5739
+ _mark("9_connecting_curves");
5371
5740
  // ── 10. Post-processing ────────────────────────────────────────
5372
5741
  // 10a. Color grading — unified tone across the whole image
5373
5742
  // Apply as a semi-transparent overlay in the grade hue
@@ -5427,6 +5796,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5427
5796
  ctx.fillRect(0, 0, width, height);
5428
5797
  ctx.globalCompositeOperation = "source-over";
5429
5798
  }
5799
+ _mark("10_post_processing");
5430
5800
  // ── 10e. Generative borders — archetype-driven decorative frames ──
5431
5801
  {
5432
5802
  ctx.save();
@@ -5482,11 +5852,14 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5482
5852
  }
5483
5853
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
5484
5854
  // Vine tendrils — organic curving lines along edges
5855
+ // Optimized: batch all tendrils into a single path
5485
5856
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.15);
5486
5857
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
5487
5858
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
5488
5859
  ctx.lineCap = "round";
5489
5860
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
5861
+ ctx.beginPath();
5862
+ const leafPositions = [];
5490
5863
  for(let t = 0; t < tendrilCount; t++){
5491
5864
  // Start from a random edge point
5492
5865
  const edge = Math.floor(borderRng() * 4);
@@ -5504,7 +5877,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5504
5877
  tx = width - borderPad;
5505
5878
  ty = borderRng() * height;
5506
5879
  }
5507
- ctx.beginPath();
5508
5880
  ctx.moveTo(tx, ty);
5509
5881
  const segs = 3 + Math.floor(borderRng() * 4);
5510
5882
  for(let s = 0; s < segs; s++){
@@ -5518,14 +5890,23 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5518
5890
  ty = cpy3;
5519
5891
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
5520
5892
  }
5521
- ctx.stroke();
5522
- // Small leaf/dot at tendril end
5523
- if (borderRng() < 0.6) {
5524
- ctx.beginPath();
5525
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
5526
- ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5527
- ctx.fill();
5893
+ // Collect leaf positions for batch fill
5894
+ if (borderRng() < 0.6) leafPositions.push({
5895
+ x: tx,
5896
+ y: ty,
5897
+ r: borderPad * (0.15 + borderRng() * 0.2)
5898
+ });
5899
+ }
5900
+ ctx.stroke();
5901
+ // Batch all leaf dots into a single fill
5902
+ if (leafPositions.length > 0) {
5903
+ ctx.fillStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.secondary, 0.08);
5904
+ ctx.beginPath();
5905
+ for (const leaf of leafPositions){
5906
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
5907
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
5528
5908
  }
5909
+ ctx.fill();
5529
5910
  }
5530
5911
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
5531
5912
  // Star-studded arcs along edges
@@ -5540,8 +5921,9 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5540
5921
  ctx.beginPath();
5541
5922
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
5542
5923
  ctx.stroke();
5543
- // Scatter small stars along the border region
5924
+ // Scatter small stars along the border region — batched into single path
5544
5925
  const starCount = 15 + Math.floor(borderRng() * 15);
5926
+ ctx.beginPath();
5545
5927
  for(let s = 0; s < starCount; s++){
5546
5928
  const edge = Math.floor(borderRng() * 4);
5547
5929
  let sx, sy;
@@ -5560,7 +5942,6 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5560
5942
  }
5561
5943
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
5562
5944
  // 4-point star
5563
- ctx.beginPath();
5564
5945
  for(let p = 0; p < 8; p++){
5565
5946
  const a = p / 8 * Math.PI * 2;
5566
5947
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -5570,8 +5951,8 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5570
5951
  else ctx.lineTo(px2, py2);
5571
5952
  }
5572
5953
  ctx.closePath();
5573
- ctx.fill();
5574
5954
  }
5955
+ ctx.fill();
5575
5956
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
5576
5957
  // Thin single rule — understated elegance
5577
5958
  ctx.strokeStyle = (0, $9d614e7d77fc2947$export$f2121afcad3d553f)(colorHierarchy.dominant, 0.1);
@@ -5582,6 +5963,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5582
5963
  // Other archetypes: no border (intentional — not every image needs one)
5583
5964
  ctx.restore();
5584
5965
  }
5966
+ _mark("10e_borders");
5585
5967
  // ── 11. Signature mark — placed in the least-dense corner ──────
5586
5968
  {
5587
5969
  const sigRng = (0, $461134e0b6ce0619$export$eaf9227667332084)((0, $461134e0b6ce0619$export$e9cc707de01b7042)(gitHash, 42));
@@ -5649,6 +6031,7 @@ function $b623126c6e9cbb71$export$29a844702096332e(ctx, gitHash, config = {}) {
5649
6031
  ctx.restore();
5650
6032
  }
5651
6033
  ctx.globalAlpha = 1;
6034
+ _mark("11_signature");
5652
6035
  }
5653
6036
 
5654
6037