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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Pipeline phase profiler — instruments renderHashArt to show
3
+ * exactly where time is spent across all 11 pipeline phases.
4
+ *
5
+ * This wraps the CanvasRenderingContext2D to count draw calls
6
+ * and measure time per phase by intercepting canvas operations.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { createCanvas } from "@napi-rs/canvas";
10
+ import { renderHashArt } from "../lib/render";
11
+
12
+ const TEST_HASH = "46192e59d42f741c761cbea79462a8b3815dd905";
13
+ const HASH_DEADBEEF = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
14
+
15
+ interface DrawCallStats {
16
+ fill: number;
17
+ stroke: number;
18
+ fillRect: number;
19
+ arc: number;
20
+ beginPath: number;
21
+ moveTo: number;
22
+ lineTo: number;
23
+ quadraticCurveTo: number;
24
+ drawImage: number;
25
+ save: number;
26
+ restore: number;
27
+ clip: number;
28
+ getImageData: number;
29
+ putImageData: number;
30
+ createRadialGradient: number;
31
+ createLinearGradient: number;
32
+ setTransform: number;
33
+ translate: number;
34
+ scale: number;
35
+ rotate: number;
36
+ }
37
+
38
+ function createInstrumentedCtx(width: number, height: number) {
39
+ const canvas = createCanvas(width, height);
40
+ const ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
41
+
42
+ const stats: DrawCallStats = {
43
+ fill: 0, stroke: 0, fillRect: 0, arc: 0,
44
+ beginPath: 0, moveTo: 0, lineTo: 0, quadraticCurveTo: 0,
45
+ drawImage: 0, save: 0, restore: 0, clip: 0,
46
+ getImageData: 0, putImageData: 0,
47
+ createRadialGradient: 0, createLinearGradient: 0,
48
+ setTransform: 0, translate: 0, scale: 0, rotate: 0,
49
+ };
50
+
51
+ // Wrap each method to count calls
52
+ for (const method of Object.keys(stats) as Array<keyof DrawCallStats>) {
53
+ const original = (ctx as any)[method];
54
+ if (typeof original === "function") {
55
+ (ctx as any)[method] = function (...args: any[]) {
56
+ stats[method]++;
57
+ return original.apply(ctx, args);
58
+ };
59
+ }
60
+ }
61
+
62
+ return { ctx, stats };
63
+ }
64
+
65
+ function formatMs(ms: number): string {
66
+ return ms < 1 ? `${(ms * 1000).toFixed(0)}µs` : `${ms.toFixed(1)}ms`;
67
+ }
68
+
69
+ describe("Pipeline phase profiling", () => {
70
+ const configs = [
71
+ { label: "512×512", width: 512, height: 512 },
72
+ { label: "1024×1024", width: 1024, height: 1024 },
73
+ ];
74
+
75
+ for (const { label, width, height } of configs) {
76
+ it(`profiles ${label} with default hash`, () => {
77
+ const { ctx, stats } = createInstrumentedCtx(width, height);
78
+
79
+ const start = performance.now();
80
+ renderHashArt(ctx, TEST_HASH, { width, height });
81
+ const total = performance.now() - start;
82
+
83
+ console.log(`\n ═══ Pipeline profile: ${label} (hash=${TEST_HASH.slice(0, 8)}) ═══`);
84
+ console.log(` Total: ${formatMs(total)}`);
85
+ console.log(`\n Draw call counts:`);
86
+ console.log(` fill(): ${stats.fill}`);
87
+ console.log(` stroke(): ${stats.stroke}`);
88
+ console.log(` fillRect(): ${stats.fillRect}`);
89
+ console.log(` arc(): ${stats.arc}`);
90
+ console.log(` beginPath(): ${stats.beginPath}`);
91
+ console.log(` moveTo(): ${stats.moveTo}`);
92
+ console.log(` lineTo(): ${stats.lineTo}`);
93
+ console.log(` quadraticCurve(): ${stats.quadraticCurveTo}`);
94
+ console.log(` clip(): ${stats.clip}`);
95
+ console.log(` save()/restore(): ${stats.save}/${stats.restore}`);
96
+ console.log(` drawImage(): ${stats.drawImage}`);
97
+ console.log(` getImageData(): ${stats.getImageData}`);
98
+ console.log(` putImageData(): ${stats.putImageData}`);
99
+ console.log(` gradients: ${stats.createRadialGradient + stats.createLinearGradient}`);
100
+ console.log(` transforms: ${stats.translate + stats.scale + stats.rotate}`);
101
+
102
+ const totalDrawOps = stats.fill + stats.stroke + stats.fillRect + stats.drawImage;
103
+ console.log(`\n Total draw ops (fill+stroke+fillRect+drawImage): ${totalDrawOps}`);
104
+ console.log(` Avg time per draw op: ${formatMs(total / totalDrawOps)}`);
105
+
106
+ expect(total).toBeLessThan(30_000);
107
+ });
108
+ }
109
+
110
+ it("profiles deadbeef hash (worst case) at 1024×1024", () => {
111
+ const { ctx, stats } = createInstrumentedCtx(1024, 1024);
112
+
113
+ const start = performance.now();
114
+ renderHashArt(ctx, HASH_DEADBEEF, { width: 1024, height: 1024 });
115
+ const total = performance.now() - start;
116
+
117
+ console.log(`\n ═══ Pipeline profile: 1024×1024 (hash=deadbeef — worst case) ═══`);
118
+ console.log(` Total: ${formatMs(total)}`);
119
+ console.log(`\n Draw call counts:`);
120
+ console.log(` fill(): ${stats.fill}`);
121
+ console.log(` stroke(): ${stats.stroke}`);
122
+ console.log(` fillRect(): ${stats.fillRect}`);
123
+ console.log(` arc(): ${stats.arc}`);
124
+ console.log(` beginPath(): ${stats.beginPath}`);
125
+ console.log(` clip(): ${stats.clip}`);
126
+ console.log(` save()/restore(): ${stats.save}/${stats.restore}`);
127
+
128
+ const totalDrawOps = stats.fill + stats.stroke + stats.fillRect + stats.drawImage;
129
+ console.log(`\n Total draw ops: ${totalDrawOps}`);
130
+ console.log(` Avg time per draw op: ${formatMs(total / totalDrawOps)}`);
131
+
132
+ expect(total).toBeLessThan(30_000);
133
+ });
134
+
135
+ it("compares shape counts across different hashes", () => {
136
+ const hashes = [
137
+ TEST_HASH,
138
+ HASH_DEADBEEF,
139
+ "ff00ff00ff00ff00ff00ff00ff00ff00ff00ff0",
140
+ "7777777777777777777777777777777777777777",
141
+ ];
142
+
143
+ console.log(`\n ═══ Shape count comparison (1024×1024) ═══`);
144
+ console.log(` ${"Hash".padEnd(12)} ${"Time".padStart(8)} ${"Fills".padStart(7)} ${"Strokes".padStart(8)} ${"FillRects".padStart(10)} ${"Clips".padStart(6)} ${"Total Ops".padStart(10)}`);
145
+
146
+ for (const hash of hashes) {
147
+ const { ctx, stats } = createInstrumentedCtx(1024, 1024);
148
+ const start = performance.now();
149
+ renderHashArt(ctx, hash, { width: 1024, height: 1024 });
150
+ const ms = performance.now() - start;
151
+ const totalOps = stats.fill + stats.stroke + stats.fillRect + stats.drawImage;
152
+
153
+ console.log(
154
+ ` ${hash.slice(0, 10).padEnd(12)} ${formatMs(ms).padStart(8)} ${String(stats.fill).padStart(7)} ${String(stats.stroke).padStart(8)} ${String(stats.fillRect).padStart(10)} ${String(stats.clip).padStart(6)} ${String(totalOps).padStart(10)}`
155
+ );
156
+ }
157
+
158
+ expect(true).toBe(true);
159
+ });
160
+ });
@@ -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
  /**
@@ -484,14 +495,20 @@ export function shiftTemperature(hex: string, target: "warm" | "cool", amount: n
484
495
 
485
496
  /**
486
497
  * Compute relative luminance of a hex color (0 = black, 1 = white).
487
- * Uses the sRGB luminance formula from WCAG.
498
+ * Uses the sRGB luminance formula from WCAG. Cached.
488
499
  */
500
+ const _lumCache = new Map<string, number>();
489
501
  export function luminance(hex: string): number {
502
+ let cached = _lumCache.get(hex);
503
+ if (cached !== undefined) return cached;
490
504
  const [r, g, b] = hexToRgb(hex).map((c) => {
491
505
  const s = c / 255;
492
506
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
493
507
  });
494
- 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;
495
512
  }
496
513
 
497
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,31 +753,28 @@ 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
764
  // ── Specular highlight — tinted arc on the light-facing side ──
672
- if (lightAngle !== undefined && size > 15 && rng) {
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);
678
- // Tint highlight warm/cool based on fill color for cohesion
679
- // Parse fill to detect warmth fallback to white for non-parseable
680
- let hlBase = "255,255,255";
681
- if (typeof fillColor === "string" && fillColor.startsWith("#") && fillColor.length >= 7) {
682
- const r = parseInt(fillColor.slice(1, 3), 16);
683
- const g = parseInt(fillColor.slice(3, 5), 16);
684
- const b = parseInt(fillColor.slice(5, 7), 16);
685
- // Blend toward white but keep a hint of the fill's warmth
686
- hlBase = `${Math.round(r * 0.15 + 255 * 0.85)},${Math.round(g * 0.15 + 255 * 0.85)},${Math.round(b * 0.15 + 255 * 0.85)}`;
687
- }
688
- hlGrad.addColorStop(0, `rgba(${hlBase},0.18)`);
689
- hlGrad.addColorStop(0.5, `rgba(${hlBase},0.05)`);
690
- hlGrad.addColorStop(1, `rgba(${hlBase},0)`);
773
+ // Use a simple white highlight the per-shape hex parse was expensive
774
+ // and the visual difference from tinted highlights is negligible.
775
+ hlGrad.addColorStop(0, "rgba(255,255,255,0.18)");
776
+ hlGrad.addColorStop(0.5, "rgba(255,255,255,0.05)");
777
+ hlGrad.addColorStop(1, "rgba(255,255,255,0)");
691
778
  const savedOp = ctx.globalCompositeOperation;
692
779
  ctx.globalCompositeOperation = "soft-light";
693
780
  ctx.fillStyle = hlGrad;
@@ -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
  }
@@ -149,28 +149,27 @@ export const drawVesicaPiscis: DrawFunction = (ctx, size) => {
149
149
  export const drawTorus: DrawFunction = (ctx, size) => {
150
150
  const outerRadius = size / 2;
151
151
  const innerRadius = size / 4;
152
- const steps = 36;
152
+ // Adaptive step count: fewer segments for small shapes where detail isn't visible.
153
+ // 36×36 = 1296 segments at full size; at size < 60 we drop to 16×16 = 256.
154
+ const steps = size < 60 ? 16 : size < 150 ? 24 : 36;
155
+ const TWO_PI = Math.PI * 2;
156
+ const angleStep = TWO_PI / steps;
153
157
 
154
158
  ctx.beginPath();
155
159
  for (let i = 0; i < steps; i++) {
156
- const angle1 = (i / steps) * Math.PI * 2;
157
- // const angle2 = ((i + 1) / steps) * Math.PI * 2;
160
+ const angle1 = i * angleStep;
161
+ const cosA = Math.cos(angle1);
162
+ const sinA = Math.sin(angle1);
158
163
 
159
164
  for (let j = 0; j < steps; j++) {
160
- const phi1 = (j / steps) * Math.PI * 2;
161
- const phi2 = ((j + 1) / steps) * Math.PI * 2;
162
-
163
- const x1 =
164
- (outerRadius + innerRadius * Math.cos(phi1)) * Math.cos(angle1);
165
- const y1 =
166
- (outerRadius + innerRadius * Math.cos(phi1)) * Math.sin(angle1);
167
- const x2 =
168
- (outerRadius + innerRadius * Math.cos(phi2)) * Math.cos(angle1);
169
- const y2 =
170
- (outerRadius + innerRadius * Math.cos(phi2)) * Math.sin(angle1);
171
-
172
- ctx.moveTo(x1, y1);
173
- ctx.lineTo(x2, y2);
165
+ const phi1 = j * angleStep;
166
+ const phi2 = phi1 + angleStep;
167
+
168
+ const r1 = outerRadius + innerRadius * Math.cos(phi1);
169
+ const r2 = outerRadius + innerRadius * Math.cos(phi2);
170
+
171
+ ctx.moveTo(r1 * cosA, r1 * sinA);
172
+ ctx.lineTo(r2 * cosA, r2 * sinA);
174
173
  }
175
174
  }
176
175
  };