git-hash-art 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,14 @@ export type PaletteMode =
34
34
 
35
35
  // ── Archetype definition ────────────────────────────────────────────
36
36
 
37
+ export type CompositionMode =
38
+ | "radial"
39
+ | "flow-field"
40
+ | "spiral"
41
+ | "grid-subdivision"
42
+ | "clustered"
43
+ | "golden-spiral";
44
+
37
45
  export interface Archetype {
38
46
  name: string;
39
47
  /** Override gridSize (controls shape count) */
@@ -54,6 +62,8 @@ export interface Archetype {
54
62
  paletteMode: PaletteMode;
55
63
  /** Preferred render styles (weighted toward these) */
56
64
  preferredStyles: RenderStyle[];
65
+ /** Preferred composition modes (70% chance of using one of these) */
66
+ preferredCompositions: CompositionMode[];
57
67
  /** Flow line count multiplier (1 = default) */
58
68
  flowLineMultiplier: number;
59
69
  /** Whether to draw the hero shape */
@@ -80,6 +90,7 @@ const ARCHETYPES: Archetype[] = [
80
90
  backgroundStyle: "radial-dark",
81
91
  paletteMode: "harmonious",
82
92
  preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
93
+ preferredCompositions: ["clustered", "flow-field", "radial"],
83
94
  flowLineMultiplier: 2.5,
84
95
  heroShape: false,
85
96
  glowMultiplier: 0.5,
@@ -97,6 +108,7 @@ const ARCHETYPES: Archetype[] = [
97
108
  backgroundStyle: "solid-light",
98
109
  paletteMode: "duotone",
99
110
  preferredStyles: ["fill-and-stroke", "stroke-only", "incomplete"],
111
+ preferredCompositions: ["golden-spiral", "grid-subdivision"],
100
112
  flowLineMultiplier: 0.3,
101
113
  heroShape: true,
102
114
  glowMultiplier: 0,
@@ -114,6 +126,7 @@ const ARCHETYPES: Archetype[] = [
114
126
  backgroundStyle: "radial-dark",
115
127
  paletteMode: "earth",
116
128
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
129
+ preferredCompositions: ["flow-field", "golden-spiral", "spiral"],
117
130
  flowLineMultiplier: 4,
118
131
  heroShape: false,
119
132
  glowMultiplier: 0.3,
@@ -131,6 +144,7 @@ const ARCHETYPES: Archetype[] = [
131
144
  backgroundStyle: "solid-dark",
132
145
  paletteMode: "high-contrast",
133
146
  preferredStyles: ["stroke-only", "dashed", "double-stroke", "hatched"],
147
+ preferredCompositions: ["grid-subdivision", "radial"],
134
148
  flowLineMultiplier: 0,
135
149
  heroShape: false,
136
150
  glowMultiplier: 0,
@@ -148,6 +162,7 @@ const ARCHETYPES: Archetype[] = [
148
162
  backgroundStyle: "radial-light",
149
163
  paletteMode: "pastel-light",
150
164
  preferredStyles: ["watercolor", "incomplete", "fill-only"],
165
+ preferredCompositions: ["golden-spiral", "radial", "spiral"],
151
166
  flowLineMultiplier: 1.5,
152
167
  heroShape: true,
153
168
  glowMultiplier: 2,
@@ -165,6 +180,7 @@ const ARCHETYPES: Archetype[] = [
165
180
  backgroundStyle: "linear-diagonal",
166
181
  paletteMode: "duotone",
167
182
  preferredStyles: ["fill-and-stroke", "double-stroke"],
183
+ preferredCompositions: ["grid-subdivision", "golden-spiral"],
168
184
  flowLineMultiplier: 0,
169
185
  heroShape: true,
170
186
  glowMultiplier: 0,
@@ -182,6 +198,7 @@ const ARCHETYPES: Archetype[] = [
182
198
  backgroundStyle: "solid-dark",
183
199
  paletteMode: "neon",
184
200
  preferredStyles: ["stroke-only", "double-stroke", "dashed"],
201
+ preferredCompositions: ["radial", "spiral", "clustered"],
185
202
  flowLineMultiplier: 2,
186
203
  heroShape: true,
187
204
  glowMultiplier: 3,
@@ -199,6 +216,7 @@ const ARCHETYPES: Archetype[] = [
199
216
  backgroundStyle: "solid-light",
200
217
  paletteMode: "monochrome",
201
218
  preferredStyles: ["hatched", "incomplete", "stroke-only", "dashed"],
219
+ preferredCompositions: ["flow-field", "grid-subdivision", "clustered"],
202
220
  flowLineMultiplier: 1.5,
203
221
  heroShape: false,
204
222
  glowMultiplier: 0,
@@ -216,6 +234,7 @@ const ARCHETYPES: Archetype[] = [
216
234
  backgroundStyle: "radial-dark",
217
235
  paletteMode: "neon",
218
236
  preferredStyles: ["fill-only", "watercolor", "fill-and-stroke"],
237
+ preferredCompositions: ["radial", "spiral", "golden-spiral"],
219
238
  flowLineMultiplier: 3,
220
239
  heroShape: true,
221
240
  glowMultiplier: 2.5,
@@ -233,6 +252,7 @@ const ARCHETYPES: Archetype[] = [
233
252
  backgroundStyle: "radial-light",
234
253
  paletteMode: "harmonious",
235
254
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
255
+ preferredCompositions: ["golden-spiral", "flow-field", "radial"],
236
256
  flowLineMultiplier: 0.5,
237
257
  heroShape: false,
238
258
  glowMultiplier: 0.3,
@@ -250,6 +270,7 @@ const ARCHETYPES: Archetype[] = [
250
270
  backgroundStyle: "solid-light",
251
271
  paletteMode: "high-contrast",
252
272
  preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
273
+ preferredCompositions: ["grid-subdivision", "radial"],
253
274
  flowLineMultiplier: 0,
254
275
  heroShape: false,
255
276
  glowMultiplier: 0,
@@ -267,6 +288,7 @@ const ARCHETYPES: Archetype[] = [
267
288
  backgroundStyle: "solid-light",
268
289
  paletteMode: "duotone",
269
290
  preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
291
+ preferredCompositions: ["grid-subdivision", "clustered"],
270
292
  flowLineMultiplier: 0,
271
293
  heroShape: true,
272
294
  glowMultiplier: 0,
@@ -284,6 +306,7 @@ const ARCHETYPES: Archetype[] = [
284
306
  backgroundStyle: "radial-dark",
285
307
  paletteMode: "harmonious",
286
308
  preferredStyles: ["fill-and-stroke", "watercolor", "fill-only"],
309
+ preferredCompositions: ["radial", "golden-spiral", "flow-field"],
287
310
  flowLineMultiplier: 1,
288
311
  heroShape: true,
289
312
  glowMultiplier: 1,
@@ -301,6 +324,7 @@ const ARCHETYPES: Archetype[] = [
301
324
  backgroundStyle: "solid-dark",
302
325
  paletteMode: "high-contrast",
303
326
  preferredStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
327
+ preferredCompositions: ["clustered", "grid-subdivision", "radial"],
304
328
  flowLineMultiplier: 0,
305
329
  heroShape: false,
306
330
  glowMultiplier: 0.3,
@@ -318,6 +342,7 @@ const ARCHETYPES: Archetype[] = [
318
342
  backgroundStyle: "radial-light",
319
343
  paletteMode: "earth",
320
344
  preferredStyles: ["watercolor", "fill-only", "incomplete"],
345
+ preferredCompositions: ["flow-field", "golden-spiral", "spiral"],
321
346
  flowLineMultiplier: 3,
322
347
  heroShape: true,
323
348
  glowMultiplier: 0.2,
@@ -335,6 +360,7 @@ const ARCHETYPES: Archetype[] = [
335
360
  backgroundStyle: "solid-light",
336
361
  paletteMode: "monochrome",
337
362
  preferredStyles: ["stipple", "fill-only", "hatched"],
363
+ preferredCompositions: ["radial", "clustered", "flow-field"],
338
364
  flowLineMultiplier: 0,
339
365
  heroShape: false,
340
366
  glowMultiplier: 0,
@@ -352,6 +378,7 @@ const ARCHETYPES: Archetype[] = [
352
378
  backgroundStyle: "radial-dark",
353
379
  paletteMode: "neon",
354
380
  preferredStyles: ["fill-only", "watercolor", "stroke-only", "incomplete"],
381
+ preferredCompositions: ["spiral", "radial", "golden-spiral"],
355
382
  flowLineMultiplier: 2,
356
383
  heroShape: true,
357
384
  glowMultiplier: 2.5,
@@ -374,6 +401,7 @@ function lerpNum(a: number, b: number, t: number): number {
374
401
  function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
375
402
  // Merge preferred styles — unique union, primary archetype first
376
403
  const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
404
+ const mergedCompositions = [...new Set([...a.preferredCompositions, ...b.preferredCompositions])] as CompositionMode[];
377
405
 
378
406
  return {
379
407
  name: `${a.name}+${b.name}`,
@@ -386,6 +414,7 @@ function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
386
414
  backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
387
415
  paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
388
416
  preferredStyles: mergedStyles,
417
+ preferredCompositions: mergedCompositions,
389
418
  flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
390
419
  heroShape: t < 0.5 ? a.heroShape : b.heroShape,
391
420
  glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
@@ -311,14 +311,23 @@ export class SacredColorScheme {
311
311
 
312
312
  // ── Standalone color utilities ──────────────────────────────────────
313
313
 
314
- /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. */
314
+ // ── Cached hex→RGB parse avoids repeated parseInt/substring on hot path ──
315
+ const _rgbCache = new Map<string, [number, number, number]>();
316
+ const _RGB_CACHE_MAX = 512;
317
+
318
+ /** Parse a hex color (#RRGGBB) into [r, g, b] 0-255. Cached. */
315
319
  function hexToRgb(hex: string): [number, number, number] {
316
- const c = hex.replace("#", "");
317
- return [
320
+ let cached = _rgbCache.get(hex);
321
+ if (cached) return cached;
322
+ const c = hex.charAt(0) === "#" ? hex.substring(1) : hex;
323
+ cached = [
318
324
  parseInt(c.substring(0, 2), 16),
319
325
  parseInt(c.substring(2, 4), 16),
320
326
  parseInt(c.substring(4, 6), 16),
321
327
  ];
328
+ if (_rgbCache.size >= _RGB_CACHE_MAX) _rgbCache.clear();
329
+ _rgbCache.set(hex, cached);
330
+ return cached;
322
331
  }
323
332
 
324
333
  /** Format [r, g, b] back to #RRGGBB. */
@@ -365,7 +374,9 @@ function hslToHex(h: number, s: number, l: number): string {
365
374
  */
366
375
  export function hexWithAlpha(hex: string, alpha: number): string {
367
376
  const [r, g, b] = hexToRgb(hex);
368
- return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
377
+ // Quantize alpha to 3 decimal places without toFixed overhead
378
+ const a = Math.round(alpha * 1000) / 1000;
379
+ return `rgba(${r},${g},${b},${a})`;
369
380
  }
370
381
 
371
382
  /**
@@ -388,14 +399,16 @@ export function buildColorHierarchy(colors: string[], rng: () => number): ColorH
388
399
  all: colors,
389
400
  };
390
401
  }
391
- // Pick dominant as the color closest to the palette's average hue
402
+ // Pick dominant as the color with the highest chroma (saturation × distance from gray)
403
+ // This selects the most visually prominent color rather than the average
392
404
  const hsls = colors.map((c) => hexToHsl(c));
393
- const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
394
405
  let dominantIdx = 0;
395
- let minDist = 360;
406
+ let maxChroma = -1;
396
407
  for (let i = 0; i < hsls.length; i++) {
397
- const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
398
- if (d < minDist) { minDist = d; dominantIdx = i; }
408
+ // Chroma approximation: saturation × how far lightness is from 50% (gray)
409
+ const lightnessVibrancy = 1 - Math.abs(hsls[i][2] - 0.5) * 2; // peaks at L=0.5
410
+ const chroma = hsls[i][1] * lightnessVibrancy;
411
+ if (chroma > maxChroma) { maxChroma = chroma; dominantIdx = i; }
399
412
  }
400
413
  // Accent is the color most distant from dominant in hue
401
414
  let accentIdx = 0;
@@ -482,14 +495,20 @@ export function shiftTemperature(hex: string, target: "warm" | "cool", amount: n
482
495
 
483
496
  /**
484
497
  * Compute relative luminance of a hex color (0 = black, 1 = white).
485
- * Uses the sRGB luminance formula from WCAG.
498
+ * Uses the sRGB luminance formula from WCAG. Cached.
486
499
  */
500
+ const _lumCache = new Map<string, number>();
487
501
  export function luminance(hex: string): number {
502
+ let cached = _lumCache.get(hex);
503
+ if (cached !== undefined) return cached;
488
504
  const [r, g, b] = hexToRgb(hex).map((c) => {
489
505
  const s = c / 255;
490
506
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
491
507
  });
492
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
508
+ cached = 0.2126 * r + 0.7152 * g + 0.0722 * b;
509
+ if (_lumCache.size >= 512) _lumCache.clear();
510
+ _lumCache.set(hex, cached);
511
+ return cached;
493
512
  }
494
513
 
495
514
  /**
@@ -67,6 +67,45 @@ export function pickRenderStyle(rng: () => number): RenderStyle {
67
67
  return RENDER_STYLES[Math.floor(rng() * RENDER_STYLES.length)];
68
68
  }
69
69
 
70
+ /**
71
+ * Approximate cost weight for each render style, normalized so
72
+ * fill-and-stroke = 1. Based on benchmark measurements.
73
+ */
74
+ export const RENDER_STYLE_COST: Record<RenderStyle, number> = {
75
+ "fill-and-stroke": 1,
76
+ "fill-only": 0.5,
77
+ "stroke-only": 1,
78
+ "double-stroke": 1.5,
79
+ "dashed": 1,
80
+ "watercolor": 7,
81
+ "hatched": 3,
82
+ "incomplete": 1,
83
+ "stipple": 90,
84
+ "stencil": 2,
85
+ "noise-grain": 400,
86
+ "wood-grain": 10,
87
+ "marble-vein": 4,
88
+ "fabric-weave": 6,
89
+ "hand-drawn": 5,
90
+ };
91
+
92
+ /**
93
+ * Downgrade an expensive render style to a cheaper alternative
94
+ * that preserves a similar visual feel.
95
+ */
96
+ export function downgradeRenderStyle(style: RenderStyle): RenderStyle {
97
+ switch (style) {
98
+ case "noise-grain": return "hatched";
99
+ case "stipple": return "dashed";
100
+ case "wood-grain": return "hatched";
101
+ case "watercolor": return "fill-and-stroke";
102
+ case "fabric-weave": return "hatched";
103
+ case "hand-drawn": return "fill-and-stroke";
104
+ case "marble-vein": return "stroke-only";
105
+ default: return style;
106
+ }
107
+ }
108
+
70
109
  // ── Config interfaces ───────────────────────────────────────────────
71
110
 
72
111
  interface DrawShapeConfig {
@@ -246,6 +285,7 @@ function applyRenderStyle(
246
285
 
247
286
  case "hatched": {
248
287
  // Fill normally at reduced opacity, then overlay cross-hatch lines
288
+ // Optimized: batch all parallel lines into a single path per pass
249
289
  const savedAlphaH = ctx.globalAlpha;
250
290
  ctx.globalAlpha = savedAlphaH * 0.3;
251
291
  ctx.fill();
@@ -259,28 +299,28 @@ function applyRenderStyle(
259
299
  ctx.lineWidth = Math.max(0.5, strokeWidth * 0.4);
260
300
  ctx.globalAlpha = savedAlphaH * 0.6;
261
301
 
262
- // Draw parallel lines across the bounding box
302
+ // Draw parallel lines across the bounding box — batched into single path
263
303
  const extent = size * 0.8;
264
304
  const cos = Math.cos(hatchAngle);
265
305
  const sin = Math.sin(hatchAngle);
306
+ ctx.beginPath();
266
307
  for (let d = -extent; d <= extent; d += hatchSpacing) {
267
- ctx.beginPath();
268
308
  ctx.moveTo(d * cos - extent * sin, d * sin + extent * cos);
269
309
  ctx.lineTo(d * cos + extent * sin, d * sin - extent * cos);
270
- ctx.stroke();
271
310
  }
311
+ ctx.stroke();
272
312
  // Second pass at perpendicular angle for cross-hatch (~50% chance)
273
313
  if (!rng || rng() < 0.5) {
274
314
  const crossAngle = hatchAngle + Math.PI / 2;
275
315
  const cos2 = Math.cos(crossAngle);
276
316
  const sin2 = Math.sin(crossAngle);
277
317
  ctx.globalAlpha = savedAlphaH * 0.35;
318
+ ctx.beginPath();
278
319
  for (let d = -extent; d <= extent; d += hatchSpacing * 1.4) {
279
- ctx.beginPath();
280
320
  ctx.moveTo(d * cos2 - extent * sin2, d * sin2 + extent * cos2);
281
321
  ctx.lineTo(d * cos2 + extent * sin2, d * sin2 - extent * cos2);
282
- ctx.stroke();
283
322
  }
323
+ ctx.stroke();
284
324
  }
285
325
  ctx.restore();
286
326
  ctx.globalAlpha = savedAlphaH;
@@ -316,6 +356,8 @@ function applyRenderStyle(
316
356
 
317
357
  case "stipple": {
318
358
  // Dot-fill texture — clip to shape, then scatter dots
359
+ // Optimized: use fillRect instead of arc for dots (much cheaper to render),
360
+ // and cap total dot count to avoid O(size²) blowup on large shapes.
319
361
  const savedAlphaS = ctx.globalAlpha;
320
362
  ctx.globalAlpha = savedAlphaS * 0.15;
321
363
  ctx.fill(); // ghost fill
@@ -324,17 +366,19 @@ function applyRenderStyle(
324
366
  ctx.save();
325
367
  ctx.clip();
326
368
  const dotSpacing = Math.max(2, size * 0.03);
327
- const extent = size * 0.55;
369
+ const extentS = size * 0.55;
370
+ // Cap total dots: beyond ~900 (30×30 grid) the visual density plateaus
371
+ const maxDotsPerAxis = Math.min(Math.ceil((extentS * 2) / dotSpacing), 30);
372
+ const actualSpacing = (extentS * 2) / maxDotsPerAxis;
328
373
  ctx.globalAlpha = savedAlphaS * 0.7;
329
- for (let dx = -extent; dx <= extent; dx += dotSpacing) {
330
- for (let dy = -extent; dy <= extent; dy += dotSpacing) {
331
- // Jitter each dot position for organic feel
332
- const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
333
- const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
334
- const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
335
- ctx.beginPath();
336
- ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
337
- ctx.fill();
374
+ for (let xi = 0; xi < maxDotsPerAxis; xi++) {
375
+ const dx = -extentS + xi * actualSpacing;
376
+ for (let yi = 0; yi < maxDotsPerAxis; yi++) {
377
+ const dy = -extentS + yi * actualSpacing;
378
+ const jx = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
379
+ const jy = rng ? (rng() - 0.5) * actualSpacing * 0.6 : 0;
380
+ const dotD = rng ? actualSpacing * (0.3 + rng() * 0.4) : actualSpacing * 0.4;
381
+ ctx.fillRect(dx + jx - dotD * 0.5, dy + jy - dotD * 0.5, dotD, dotD);
338
382
  }
339
383
  }
340
384
  ctx.restore();
@@ -368,6 +412,9 @@ function applyRenderStyle(
368
412
 
369
413
  case "noise-grain": {
370
414
  // Procedural noise grain texture clipped to shape boundary
415
+ // Optimized: cap grid to max 40×40 = 1600 dots (was unbounded at O(size²)),
416
+ // quantize alpha into buckets to minimize globalAlpha state changes,
417
+ // and batch dots by brightness (black/white) × alpha bucket
371
418
  const savedAlphaN = ctx.globalAlpha;
372
419
  ctx.globalAlpha = savedAlphaN * 0.25;
373
420
  ctx.fill(); // base tint
@@ -377,20 +424,51 @@ function applyRenderStyle(
377
424
  ctx.clip();
378
425
  const grainSpacing = Math.max(1.5, size * 0.015);
379
426
  const extentN = size * 0.55;
380
- ctx.globalAlpha = savedAlphaN * 0.6;
381
- for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
382
- for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
383
- if (!rng) break;
384
- const jx = (rng() - 0.5) * grainSpacing * 1.2;
385
- const jy = (rng() - 0.5) * grainSpacing * 1.2;
386
- const brightness = rng() > 0.5 ? 255 : 0;
387
- const dotAlpha = 0.15 + rng() * 0.35;
388
- ctx.globalAlpha = savedAlphaN * dotAlpha;
389
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
390
- const dotSize = grainSpacing * (0.3 + rng() * 0.5);
391
- ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
427
+
428
+ if (rng) {
429
+ // Cap grid to max 40 dots per axis beyond this the grain is
430
+ // visually indistinguishable but cost scales quadratically.
431
+ const maxGrainPerAxis = Math.min(Math.ceil((extentN * 2) / grainSpacing), 40);
432
+ const actualGrainSpacing = (extentN * 2) / maxGrainPerAxis;
433
+
434
+ // 4 alpha buckets: 0.2, 0.3, 0.4, 0.5 — covers the 0.15-0.50 range
435
+ const BUCKETS = 4;
436
+ const bucketMin = 0.15;
437
+ const bucketRange = 0.35;
438
+ // [black_bucket0, black_bucket1, ..., white_bucket0, ...]
439
+ const buckets: Array<Array<{ x: number; y: number; s: number }>> = [];
440
+ for (let i = 0; i < BUCKETS * 2; i++) buckets.push([]);
441
+
442
+ for (let xi = 0; xi < maxGrainPerAxis; xi++) {
443
+ const gx = -extentN + xi * actualGrainSpacing;
444
+ for (let yi = 0; yi < maxGrainPerAxis; yi++) {
445
+ const gy = -extentN + yi * actualGrainSpacing;
446
+ const jx = (rng() - 0.5) * actualGrainSpacing * 1.2;
447
+ const jy = (rng() - 0.5) * actualGrainSpacing * 1.2;
448
+ const isWhite = rng() > 0.5;
449
+ const dotAlpha = bucketMin + rng() * bucketRange;
450
+ const dotSize = actualGrainSpacing * (0.3 + rng() * 0.5);
451
+ const bucketIdx = Math.min(BUCKETS - 1, Math.floor((dotAlpha - bucketMin) / bucketRange * BUCKETS));
452
+ const offset = isWhite ? BUCKETS : 0;
453
+ buckets[offset + bucketIdx].push({ x: gx + jx, y: gy + jy, s: dotSize });
454
+ }
455
+ }
456
+
457
+ // Render each bucket: 2 colors × 4 alpha levels = 8 state changes total
458
+ for (let color = 0; color < 2; color++) {
459
+ ctx.fillStyle = color === 0 ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)";
460
+ for (let b = 0; b < BUCKETS; b++) {
461
+ const dots = buckets[color * BUCKETS + b];
462
+ if (dots.length === 0) continue;
463
+ const alpha = bucketMin + (b + 0.5) / BUCKETS * bucketRange;
464
+ ctx.globalAlpha = savedAlphaN * alpha;
465
+ for (let i = 0; i < dots.length; i++) {
466
+ ctx.fillRect(dots[i].x, dots[i].y, dots[i].s, dots[i].s);
467
+ }
468
+ }
392
469
  }
393
470
  }
471
+
394
472
  ctx.restore();
395
473
  ctx.fillStyle = fillColor;
396
474
  ctx.globalAlpha = savedAlphaN;
@@ -402,6 +480,7 @@ function applyRenderStyle(
402
480
 
403
481
  case "wood-grain": {
404
482
  // Parallel wavy lines simulating wood grain, clipped to shape
483
+ // Optimized: batch all grain lines into a single path, increased step from 2 to 4
405
484
  const savedAlphaW = ctx.globalAlpha;
406
485
  ctx.globalAlpha = savedAlphaW * 0.2;
407
486
  ctx.fill(); // base tint
@@ -419,17 +498,22 @@ function applyRenderStyle(
419
498
 
420
499
  const cosG = Math.cos(grainAngle);
421
500
  const sinG = Math.sin(grainAngle);
501
+ const waveCoeff = waveFreq * Math.PI;
502
+ const invExtentW = 1 / extentW;
503
+ // Batch all grain lines into a single path
504
+ ctx.beginPath();
422
505
  for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
423
- ctx.beginPath();
424
- for (let t = -extentW; t <= extentW; t += 2) {
425
- const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
426
- const px = t * cosG - (d + wave) * sinG;
427
- const py = t * sinG + (d + wave) * cosG;
428
- if (t === -extentW) ctx.moveTo(px, py);
429
- else ctx.lineTo(px, py);
506
+ const firstWave = Math.sin(-extentW * invExtentW * waveCoeff) * waveAmp;
507
+ ctx.moveTo(
508
+ -extentW * cosG - (d + firstWave) * sinG,
509
+ -extentW * sinG + (d + firstWave) * cosG,
510
+ );
511
+ for (let t = -extentW + 4; t <= extentW; t += 4) {
512
+ const wave = Math.sin(t * invExtentW * waveCoeff) * waveAmp;
513
+ ctx.lineTo(t * cosG - (d + wave) * sinG, t * sinG + (d + wave) * cosG);
430
514
  }
431
- ctx.stroke();
432
515
  }
516
+ ctx.stroke();
433
517
  ctx.restore();
434
518
  ctx.globalAlpha = savedAlphaW;
435
519
  ctx.globalAlpha *= 0.35;
@@ -494,6 +578,7 @@ function applyRenderStyle(
494
578
 
495
579
  case "fabric-weave": {
496
580
  // Interlocking horizontal/vertical threads clipped to shape
581
+ // Optimized: batch all horizontal threads into one path, all vertical into another
497
582
  const savedAlphaF = ctx.globalAlpha;
498
583
  ctx.globalAlpha = savedAlphaF * 0.15;
499
584
  ctx.fill(); // ghost base
@@ -504,27 +589,29 @@ function applyRenderStyle(
504
589
  const threadSpacing = Math.max(2, size * 0.04);
505
590
  const extentF = size * 0.55;
506
591
  ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
507
- ctx.globalAlpha = savedAlphaF * 0.55;
508
592
 
509
- // Horizontal threads
593
+ // Horizontal threads — batched
594
+ ctx.globalAlpha = savedAlphaF * 0.55;
595
+ ctx.beginPath();
510
596
  for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
511
- ctx.beginPath();
512
597
  ctx.moveTo(-extentF, y);
513
598
  ctx.lineTo(extentF, y);
514
- ctx.stroke();
515
599
  }
516
- // Vertical threads (offset by half spacing for weave effect)
600
+ ctx.stroke();
601
+
602
+ // Vertical threads (offset by half spacing for weave effect) — batched
517
603
  ctx.globalAlpha = savedAlphaF * 0.45;
518
604
  ctx.strokeStyle = fillColor;
605
+ ctx.beginPath();
519
606
  for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
520
- ctx.beginPath();
521
607
  for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
522
608
  // Over-under: draw segment, skip segment
523
609
  ctx.moveTo(x, y);
524
610
  ctx.lineTo(x, y + threadSpacing);
525
611
  }
526
- ctx.stroke();
527
612
  }
613
+ ctx.stroke();
614
+
528
615
  ctx.strokeStyle = strokeColor;
529
616
  ctx.restore();
530
617
  ctx.globalAlpha = savedAlphaF;
@@ -628,14 +715,17 @@ export function enhanceShapeGeneration(
628
715
  ctx.rotate((rotation * Math.PI) / 180);
629
716
 
630
717
  // ── Drop shadow — soft colored shadow offset along light direction ──
631
- if (lightAngle !== undefined && size > 10) {
718
+ // Skip shadow entirely for small shapes (< 20px) — the blur is expensive
719
+ // and visually imperceptible at that scale.
720
+ const useShadow = size >= 20;
721
+ if (useShadow && lightAngle !== undefined) {
632
722
  const shadowDist = size * 0.035;
633
723
  const shadowBlurR = size * 0.06;
634
724
  ctx.shadowOffsetX = Math.cos(lightAngle + Math.PI) * shadowDist;
635
725
  ctx.shadowOffsetY = Math.sin(lightAngle + Math.PI) * shadowDist;
636
726
  ctx.shadowBlur = shadowBlurR;
637
727
  ctx.shadowColor = "rgba(0,0,0,0.12)";
638
- } else if (glowRadius > 0) {
728
+ } else if (useShadow && glowRadius > 0) {
639
729
  // Glow / shadow effect (legacy path)
640
730
  ctx.shadowBlur = glowRadius;
641
731
  ctx.shadowColor = glowColor || fillColor;
@@ -663,18 +753,25 @@ export function enhanceShapeGeneration(
663
753
  }
664
754
 
665
755
  // Reset shadow so patterns and highlight aren't double-shadowed
666
- ctx.shadowBlur = 0;
667
- ctx.shadowOffsetX = 0;
668
- ctx.shadowOffsetY = 0;
669
- ctx.shadowColor = "transparent";
756
+ // Only reset if we actually set shadow (avoids unnecessary state changes)
757
+ if (useShadow && (lightAngle !== undefined || glowRadius > 0)) {
758
+ ctx.shadowBlur = 0;
759
+ ctx.shadowOffsetX = 0;
760
+ ctx.shadowOffsetY = 0;
761
+ ctx.shadowColor = "transparent";
762
+ }
670
763
 
671
- // ── Specular highlight — bright arc on the light-facing side ──
672
- if (lightAngle !== undefined && size > 15 && rng) {
764
+ // ── Specular highlight — tinted arc on the light-facing side ──
765
+ // Skip for small shapes (< 30px) gradient creation + composite op
766
+ // switch is expensive and the highlight is invisible at small sizes.
767
+ if (lightAngle !== undefined && size > 30 && rng) {
673
768
  const hlRadius = size * 0.35;
674
769
  const hlDist = size * 0.15;
675
770
  const hlX = Math.cos(lightAngle) * hlDist;
676
771
  const hlY = Math.sin(lightAngle) * hlDist;
677
772
  const hlGrad = ctx.createRadialGradient(hlX, hlY, 0, hlX, hlY, hlRadius);
773
+ // Use a simple white highlight — the per-shape hex parse was expensive
774
+ // and the visual difference from tinted highlights is negligible.
678
775
  hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
679
776
  hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
680
777
  hlGrad.addColorStop(1, "rgba(255,255,255,0)");
@@ -122,24 +122,33 @@ export const drawIslamicPattern: DrawFunction = (ctx, size, config = {}) => {
122
122
 
123
123
  const gridSize = 8;
124
124
  const unit = size / gridSize;
125
+ const radius = unit / 2;
126
+
127
+ // Pre-compute the 8 star-point angle pairs (cos/sin) — avoids 648 trig calls
128
+ const starPoints: Array<{ c1: number; s1: number; c2: number; s2: number }> = [];
129
+ for (let k = 0; k < 8; k++) {
130
+ const angle = (Math.PI / 4) * k;
131
+ const angle2 = angle + Math.PI / 4;
132
+ starPoints.push({
133
+ c1: Math.cos(angle) * radius,
134
+ s1: Math.sin(angle) * radius,
135
+ c2: Math.cos(angle2) * radius,
136
+ s2: Math.sin(angle2) * radius,
137
+ });
138
+ }
125
139
 
126
140
  ctx.beginPath();
127
141
  // Create base grid
128
142
  for (let i = 0; i <= gridSize; i++) {
143
+ const x = (i - gridSize / 2) * unit;
129
144
  for (let j = 0; j <= gridSize; j++) {
130
- const x = (i - gridSize / 2) * unit;
131
145
  const y = (j - gridSize / 2) * unit;
132
146
 
133
- // Draw star pattern at each intersection
134
- const radius = unit / 2;
147
+ // Draw star pattern at each intersection using pre-computed offsets
135
148
  for (let k = 0; k < 8; k++) {
136
- const angle = (Math.PI / 4) * k;
137
- const x1 = x + radius * Math.cos(angle);
138
- const y1 = y + radius * Math.sin(angle);
139
- const x2 = x + radius * Math.cos(angle + Math.PI / 4);
140
- const y2 = y + radius * Math.sin(angle + Math.PI / 4);
141
- ctx.moveTo(x1, y1);
142
- ctx.lineTo(x2, y2);
149
+ const sp = starPoints[k];
150
+ ctx.moveTo(x + sp.c1, y + sp.s1);
151
+ ctx.lineTo(x + sp.c2, y + sp.s2);
143
152
  }
144
153
  }
145
154
  }