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/ALGORITHM.md +76 -24
- package/CHANGELOG.md +17 -1
- package/dist/browser.js +707 -324
- package/dist/browser.js.map +1 -1
- package/dist/main.js +707 -324
- package/dist/main.js.map +1 -1
- package/dist/module.js +707 -324
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/performance.test.ts +243 -0
- package/src/__tests__/phase-breakdown.test.ts +44 -0
- package/src/__tests__/phase-timing.test.ts +260 -0
- package/src/__tests__/profile-pipeline.test.ts +160 -0
- package/src/lib/canvas/colors.ts +23 -6
- package/src/lib/canvas/draw.ts +149 -62
- package/src/lib/canvas/shapes/complex.ts +19 -10
- package/src/lib/canvas/shapes/sacred.ts +16 -17
- package/src/lib/render.ts +521 -244
- package/src/types.ts +2 -0
|
@@ -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
|
+
});
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -311,14 +311,23 @@ export class SacredColorScheme {
|
|
|
311
311
|
|
|
312
312
|
// ── Standalone color utilities ──────────────────────────────────────
|
|
313
313
|
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -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
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
ctx.
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 =
|
|
157
|
-
|
|
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 =
|
|
161
|
-
const phi2 =
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
};
|