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.
- package/.github/workflows/deploy-www.yml +13 -3
- package/ALGORITHM.md +76 -24
- package/CHANGELOG.md +18 -0
- package/dist/browser.js +938 -251
- package/dist/browser.js.map +1 -1
- package/dist/main.js +940 -251
- package/dist/main.js.map +1 -1
- package/dist/module.js +940 -251
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/performance.test.ts +243 -0
- package/src/__tests__/phase-timing.test.ts +260 -0
- package/src/__tests__/profile-pipeline.test.ts +160 -0
- package/src/lib/archetypes.ts +29 -0
- package/src/lib/canvas/colors.ts +30 -11
- package/src/lib/canvas/draw.ts +147 -50
- package/src/lib/canvas/shapes/complex.ts +19 -10
- package/src/lib/canvas/shapes/sacred.ts +16 -17
- package/src/lib/render.ts +663 -204
package/src/lib/archetypes.ts
CHANGED
|
@@ -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),
|
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
|
/**
|
|
@@ -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
|
|
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
|
|
406
|
+
let maxChroma = -1;
|
|
396
407
|
for (let i = 0; i < hsls.length; i++) {
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
/**
|
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,18 +753,25 @@ 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
|
-
// ── Specular highlight —
|
|
672
|
-
|
|
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
|
|
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
|
}
|