git-hash-art 0.8.0 → 0.10.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 +425 -274
- package/CHANGELOG.md +18 -0
- package/bin/cli.js +17 -14
- package/bin/generateVersionComparison.js +353 -0
- package/dist/browser.js +1563 -123
- package/dist/browser.js.map +1 -1
- package/dist/main.js +1563 -123
- package/dist/main.js.map +1 -1
- package/dist/module.js +1563 -123
- package/dist/module.js.map +1 -1
- package/package.json +2 -1
- package/src/lib/archetypes.ts +115 -3
- package/src/lib/canvas/colors.ts +25 -0
- package/src/lib/canvas/draw.ts +348 -1
- package/src/lib/canvas/shapes/affinity.ts +149 -4
- package/src/lib/canvas/shapes/procedural.ts +395 -32
- package/src/lib/render.ts +426 -19
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-hash-art",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"author": "gfargo <ghfargo@gmail.com>",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"watch": "parcel watch",
|
|
7
7
|
"prebuild": "rm -rf .parcel-cache",
|
|
8
8
|
"build": "parcel build",
|
|
9
9
|
"build:examples": "node bin/generateExamples.js",
|
|
10
|
+
"build:versions": "node bin/generateVersionComparison.js",
|
|
10
11
|
"format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
|
11
12
|
"format:check": "prettier --check 'src/**/*.{ts,tsx,js,jsx,json,css,md}'",
|
|
12
13
|
"release": "release-it",
|
package/src/lib/archetypes.ts
CHANGED
|
@@ -287,13 +287,125 @@ const ARCHETYPES: Archetype[] = [
|
|
|
287
287
|
sizePower: 1.8,
|
|
288
288
|
invertForeground: false,
|
|
289
289
|
},
|
|
290
|
+
{
|
|
291
|
+
name: "shattered-glass",
|
|
292
|
+
gridSize: 8,
|
|
293
|
+
layers: 3,
|
|
294
|
+
baseOpacity: 0.85,
|
|
295
|
+
opacityReduction: 0.1,
|
|
296
|
+
minShapeSize: 15,
|
|
297
|
+
maxShapeSize: 250,
|
|
298
|
+
backgroundStyle: "solid-dark",
|
|
299
|
+
paletteMode: "high-contrast",
|
|
300
|
+
preferredStyles: ["fill-and-stroke", "stroke-only", "fill-only"],
|
|
301
|
+
flowLineMultiplier: 0,
|
|
302
|
+
heroShape: false,
|
|
303
|
+
glowMultiplier: 0.3,
|
|
304
|
+
sizePower: 1.0,
|
|
305
|
+
invertForeground: false,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "botanical",
|
|
309
|
+
gridSize: 4,
|
|
310
|
+
layers: 4,
|
|
311
|
+
baseOpacity: 0.5,
|
|
312
|
+
opacityReduction: 0.06,
|
|
313
|
+
minShapeSize: 30,
|
|
314
|
+
maxShapeSize: 400,
|
|
315
|
+
backgroundStyle: "radial-light",
|
|
316
|
+
paletteMode: "earth",
|
|
317
|
+
preferredStyles: ["watercolor", "fill-only", "incomplete"],
|
|
318
|
+
flowLineMultiplier: 3,
|
|
319
|
+
heroShape: true,
|
|
320
|
+
glowMultiplier: 0.2,
|
|
321
|
+
sizePower: 1.6,
|
|
322
|
+
invertForeground: false,
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: "stipple-portrait",
|
|
326
|
+
gridSize: 9,
|
|
327
|
+
layers: 2,
|
|
328
|
+
baseOpacity: 0.8,
|
|
329
|
+
opacityReduction: 0.05,
|
|
330
|
+
minShapeSize: 5,
|
|
331
|
+
maxShapeSize: 120,
|
|
332
|
+
backgroundStyle: "solid-light",
|
|
333
|
+
paletteMode: "monochrome",
|
|
334
|
+
preferredStyles: ["stipple", "fill-only", "hatched"],
|
|
335
|
+
flowLineMultiplier: 0,
|
|
336
|
+
heroShape: false,
|
|
337
|
+
glowMultiplier: 0,
|
|
338
|
+
sizePower: 2.8,
|
|
339
|
+
invertForeground: false,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "celestial",
|
|
343
|
+
gridSize: 7,
|
|
344
|
+
layers: 5,
|
|
345
|
+
baseOpacity: 0.45,
|
|
346
|
+
opacityReduction: 0.04,
|
|
347
|
+
minShapeSize: 8,
|
|
348
|
+
maxShapeSize: 450,
|
|
349
|
+
backgroundStyle: "radial-dark",
|
|
350
|
+
paletteMode: "neon",
|
|
351
|
+
preferredStyles: ["fill-only", "watercolor", "stroke-only", "incomplete"],
|
|
352
|
+
flowLineMultiplier: 2,
|
|
353
|
+
heroShape: true,
|
|
354
|
+
glowMultiplier: 2.5,
|
|
355
|
+
sizePower: 2.2,
|
|
356
|
+
invertForeground: false,
|
|
357
|
+
},
|
|
290
358
|
];
|
|
291
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Linearly interpolate between two archetype numeric parameters.
|
|
362
|
+
*/
|
|
363
|
+
function lerpNum(a: number, b: number, t: number): number {
|
|
364
|
+
return a + (b - a) * t;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Blend two archetypes by interpolating their numeric parameters
|
|
369
|
+
* and merging their style arrays.
|
|
370
|
+
*/
|
|
371
|
+
function blendArchetypes(a: Archetype, b: Archetype, t: number): Archetype {
|
|
372
|
+
// Merge preferred styles — unique union, primary archetype first
|
|
373
|
+
const mergedStyles = [...new Set([...a.preferredStyles, ...b.preferredStyles])] as RenderStyle[];
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
name: `${a.name}+${b.name}`,
|
|
377
|
+
gridSize: Math.round(lerpNum(a.gridSize, b.gridSize, t)),
|
|
378
|
+
layers: Math.round(lerpNum(a.layers, b.layers, t)),
|
|
379
|
+
baseOpacity: lerpNum(a.baseOpacity, b.baseOpacity, t),
|
|
380
|
+
opacityReduction: lerpNum(a.opacityReduction, b.opacityReduction, t),
|
|
381
|
+
minShapeSize: Math.round(lerpNum(a.minShapeSize, b.minShapeSize, t)),
|
|
382
|
+
maxShapeSize: Math.round(lerpNum(a.maxShapeSize, b.maxShapeSize, t)),
|
|
383
|
+
backgroundStyle: t < 0.5 ? a.backgroundStyle : b.backgroundStyle,
|
|
384
|
+
paletteMode: t < 0.5 ? a.paletteMode : b.paletteMode,
|
|
385
|
+
preferredStyles: mergedStyles,
|
|
386
|
+
flowLineMultiplier: lerpNum(a.flowLineMultiplier, b.flowLineMultiplier, t),
|
|
387
|
+
heroShape: t < 0.5 ? a.heroShape : b.heroShape,
|
|
388
|
+
glowMultiplier: lerpNum(a.glowMultiplier, b.glowMultiplier, t),
|
|
389
|
+
sizePower: lerpNum(a.sizePower, b.sizePower, t),
|
|
390
|
+
invertForeground: t < 0.5 ? a.invertForeground : b.invertForeground,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
292
394
|
/**
|
|
293
395
|
* Select an archetype deterministically from the hash.
|
|
294
|
-
*
|
|
295
|
-
* but only gets ~10% of hashes.
|
|
396
|
+
* ~15% of hashes produce a blended archetype (interpolation of two).
|
|
296
397
|
*/
|
|
297
398
|
export function selectArchetype(rng: () => number): Archetype {
|
|
298
|
-
|
|
399
|
+
const primary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
|
|
400
|
+
|
|
401
|
+
// ~15% chance of blending with a second archetype
|
|
402
|
+
if (rng() < 0.15) {
|
|
403
|
+
const secondary = ARCHETYPES[Math.floor(rng() * ARCHETYPES.length)];
|
|
404
|
+
if (secondary.name !== primary.name) {
|
|
405
|
+
const blendT = 0.25 + rng() * 0.25; // 25-50% blend toward secondary
|
|
406
|
+
return blendArchetypes(primary, secondary, blendT);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return primary;
|
|
299
411
|
}
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -510,3 +510,28 @@ export function pickColorGrade(rng: () => number): { hue: number; intensity: num
|
|
|
510
510
|
const intensity = 0.15 + rng() * 0.25;
|
|
511
511
|
return { hue: (hue + 360) % 360, intensity };
|
|
512
512
|
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Rotate the hue of a hex color by a given number of degrees.
|
|
516
|
+
*/
|
|
517
|
+
export function hueRotate(hex: string, degrees: number): string {
|
|
518
|
+
const [h, s, l] = hexToHsl(hex);
|
|
519
|
+
return hslToHex((h + degrees + 360) % 360, s, l);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Evolve a color hierarchy for a given layer — shifts hue progressively.
|
|
524
|
+
* Creates atmospheric color perspective (like distant mountains shifting blue).
|
|
525
|
+
*/
|
|
526
|
+
export function evolveHierarchy(
|
|
527
|
+
base: ColorHierarchy,
|
|
528
|
+
layerRatio: number,
|
|
529
|
+
hueShiftPerLayer: number,
|
|
530
|
+
): ColorHierarchy {
|
|
531
|
+
const shift = layerRatio * hueShiftPerLayer;
|
|
532
|
+
return {
|
|
533
|
+
dominant: hueRotate(base.dominant, shift),
|
|
534
|
+
secondary: hueRotate(base.secondary, shift * 0.7),
|
|
535
|
+
accent: hueRotate(base.accent, shift * 0.5),
|
|
536
|
+
};
|
|
537
|
+
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -35,7 +35,14 @@ export type RenderStyle =
|
|
|
35
35
|
| "dashed" // dashed outline
|
|
36
36
|
| "watercolor" // multiple offset passes at low opacity
|
|
37
37
|
| "hatched" // cross-hatch texture fill
|
|
38
|
-
| "incomplete"
|
|
38
|
+
| "incomplete" // draw only 60-85% of the stroke path
|
|
39
|
+
| "stipple" // dot-fill texture
|
|
40
|
+
| "stencil" // negative-space cutout effect
|
|
41
|
+
| "noise-grain" // procedural noise grain texture clipped to shape
|
|
42
|
+
| "wood-grain" // parallel wavy lines simulating wood
|
|
43
|
+
| "marble-vein" // branching vein lines on a soft fill
|
|
44
|
+
| "fabric-weave" // interlocking horizontal/vertical threads
|
|
45
|
+
| "hand-drawn"; // wobbly hand-drawn edge treatment
|
|
39
46
|
|
|
40
47
|
const RENDER_STYLES: RenderStyle[] = [
|
|
41
48
|
"fill-and-stroke",
|
|
@@ -47,6 +54,13 @@ const RENDER_STYLES: RenderStyle[] = [
|
|
|
47
54
|
"watercolor",
|
|
48
55
|
"hatched",
|
|
49
56
|
"incomplete",
|
|
57
|
+
"stipple",
|
|
58
|
+
"stencil",
|
|
59
|
+
"noise-grain",
|
|
60
|
+
"wood-grain",
|
|
61
|
+
"marble-vein",
|
|
62
|
+
"fabric-weave",
|
|
63
|
+
"hand-drawn",
|
|
50
64
|
];
|
|
51
65
|
|
|
52
66
|
export function pickRenderStyle(rng: () => number): RenderStyle {
|
|
@@ -274,6 +288,254 @@ function applyRenderStyle(
|
|
|
274
288
|
break;
|
|
275
289
|
}
|
|
276
290
|
|
|
291
|
+
case "stipple": {
|
|
292
|
+
// Dot-fill texture — clip to shape, then scatter dots
|
|
293
|
+
const savedAlphaS = ctx.globalAlpha;
|
|
294
|
+
ctx.globalAlpha = savedAlphaS * 0.15;
|
|
295
|
+
ctx.fill(); // ghost fill
|
|
296
|
+
ctx.globalAlpha = savedAlphaS;
|
|
297
|
+
|
|
298
|
+
ctx.save();
|
|
299
|
+
ctx.clip();
|
|
300
|
+
const dotSpacing = Math.max(2, size * 0.03);
|
|
301
|
+
const extent = size * 0.55;
|
|
302
|
+
ctx.globalAlpha = savedAlphaS * 0.7;
|
|
303
|
+
for (let dx = -extent; dx <= extent; dx += dotSpacing) {
|
|
304
|
+
for (let dy = -extent; dy <= extent; dy += dotSpacing) {
|
|
305
|
+
// Jitter each dot position for organic feel
|
|
306
|
+
const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
|
|
307
|
+
const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
|
|
308
|
+
const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
|
|
309
|
+
ctx.beginPath();
|
|
310
|
+
ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
|
|
311
|
+
ctx.fill();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
ctx.restore();
|
|
315
|
+
ctx.globalAlpha = savedAlphaS;
|
|
316
|
+
// Outline
|
|
317
|
+
ctx.globalAlpha *= 0.4;
|
|
318
|
+
ctx.stroke();
|
|
319
|
+
ctx.globalAlpha /= 0.4;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case "stencil": {
|
|
324
|
+
// Negative-space cutout — fill a rectangle, then erase the shape
|
|
325
|
+
const savedAlphaSt = ctx.globalAlpha;
|
|
326
|
+
// Fill a bounding area with the stroke color
|
|
327
|
+
ctx.globalAlpha = savedAlphaSt * 0.5;
|
|
328
|
+
ctx.fillStyle = strokeColor;
|
|
329
|
+
ctx.fillRect(-size * 0.6, -size * 0.6, size * 1.2, size * 1.2);
|
|
330
|
+
// Cut out the shape using destination-out
|
|
331
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
332
|
+
ctx.globalAlpha = 1;
|
|
333
|
+
ctx.fill();
|
|
334
|
+
ctx.globalCompositeOperation = "source-over";
|
|
335
|
+
ctx.globalAlpha = savedAlphaSt;
|
|
336
|
+
// Subtle outline of the cutout
|
|
337
|
+
ctx.globalAlpha *= 0.3;
|
|
338
|
+
ctx.stroke();
|
|
339
|
+
ctx.globalAlpha /= 0.3;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case "noise-grain": {
|
|
344
|
+
// Procedural noise grain texture clipped to shape boundary
|
|
345
|
+
const savedAlphaN = ctx.globalAlpha;
|
|
346
|
+
ctx.globalAlpha = savedAlphaN * 0.25;
|
|
347
|
+
ctx.fill(); // base tint
|
|
348
|
+
ctx.globalAlpha = savedAlphaN;
|
|
349
|
+
|
|
350
|
+
ctx.save();
|
|
351
|
+
ctx.clip();
|
|
352
|
+
const grainSpacing = Math.max(1.5, size * 0.015);
|
|
353
|
+
const extentN = size * 0.55;
|
|
354
|
+
ctx.globalAlpha = savedAlphaN * 0.6;
|
|
355
|
+
for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
|
|
356
|
+
for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
|
|
357
|
+
if (!rng) break;
|
|
358
|
+
const jx = (rng() - 0.5) * grainSpacing * 1.2;
|
|
359
|
+
const jy = (rng() - 0.5) * grainSpacing * 1.2;
|
|
360
|
+
const brightness = rng() > 0.5 ? 255 : 0;
|
|
361
|
+
const dotAlpha = 0.15 + rng() * 0.35;
|
|
362
|
+
ctx.globalAlpha = savedAlphaN * dotAlpha;
|
|
363
|
+
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
364
|
+
const dotSize = grainSpacing * (0.3 + rng() * 0.5);
|
|
365
|
+
ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
ctx.restore();
|
|
369
|
+
ctx.fillStyle = fillColor;
|
|
370
|
+
ctx.globalAlpha = savedAlphaN;
|
|
371
|
+
ctx.globalAlpha *= 0.4;
|
|
372
|
+
ctx.stroke();
|
|
373
|
+
ctx.globalAlpha /= 0.4;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "wood-grain": {
|
|
378
|
+
// Parallel wavy lines simulating wood grain, clipped to shape
|
|
379
|
+
const savedAlphaW = ctx.globalAlpha;
|
|
380
|
+
ctx.globalAlpha = savedAlphaW * 0.2;
|
|
381
|
+
ctx.fill(); // base tint
|
|
382
|
+
ctx.globalAlpha = savedAlphaW;
|
|
383
|
+
|
|
384
|
+
ctx.save();
|
|
385
|
+
ctx.clip();
|
|
386
|
+
const grainLineSpacing = Math.max(2, size * 0.035);
|
|
387
|
+
const extentW = size * 0.55;
|
|
388
|
+
const waveFreq = rng ? 3 + rng() * 5 : 5;
|
|
389
|
+
const waveAmp = rng ? size * (0.01 + rng() * 0.03) : size * 0.02;
|
|
390
|
+
const grainAngle = rng ? rng() * Math.PI : Math.PI * 0.25;
|
|
391
|
+
ctx.lineWidth = Math.max(0.5, strokeWidth * 0.3);
|
|
392
|
+
ctx.globalAlpha = savedAlphaW * 0.5;
|
|
393
|
+
|
|
394
|
+
const cosG = Math.cos(grainAngle);
|
|
395
|
+
const sinG = Math.sin(grainAngle);
|
|
396
|
+
for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
|
|
397
|
+
ctx.beginPath();
|
|
398
|
+
for (let t = -extentW; t <= extentW; t += 2) {
|
|
399
|
+
const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
|
|
400
|
+
const px = t * cosG - (d + wave) * sinG;
|
|
401
|
+
const py = t * sinG + (d + wave) * cosG;
|
|
402
|
+
if (t === -extentW) ctx.moveTo(px, py);
|
|
403
|
+
else ctx.lineTo(px, py);
|
|
404
|
+
}
|
|
405
|
+
ctx.stroke();
|
|
406
|
+
}
|
|
407
|
+
ctx.restore();
|
|
408
|
+
ctx.globalAlpha = savedAlphaW;
|
|
409
|
+
ctx.globalAlpha *= 0.35;
|
|
410
|
+
ctx.stroke();
|
|
411
|
+
ctx.globalAlpha /= 0.35;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case "marble-vein": {
|
|
416
|
+
// Branching vein lines on a soft fill, clipped to shape
|
|
417
|
+
const savedAlphaM = ctx.globalAlpha;
|
|
418
|
+
ctx.globalAlpha = savedAlphaM * 0.35;
|
|
419
|
+
ctx.fill(); // soft base
|
|
420
|
+
ctx.globalAlpha = savedAlphaM;
|
|
421
|
+
|
|
422
|
+
ctx.save();
|
|
423
|
+
ctx.clip();
|
|
424
|
+
const veinCount = rng ? 2 + Math.floor(rng() * 3) : 3;
|
|
425
|
+
const extentM = size * 0.45;
|
|
426
|
+
ctx.lineWidth = Math.max(0.5, strokeWidth * 0.5);
|
|
427
|
+
ctx.globalAlpha = savedAlphaM * 0.4;
|
|
428
|
+
|
|
429
|
+
for (let v = 0; v < veinCount; v++) {
|
|
430
|
+
const startX = rng ? (rng() - 0.5) * extentM * 2 : 0;
|
|
431
|
+
const startY = rng ? -extentM + rng() * extentM * 0.5 : -extentM;
|
|
432
|
+
let vx = startX;
|
|
433
|
+
let vy = startY;
|
|
434
|
+
const steps = 15 + (rng ? Math.floor(rng() * 15) : 10);
|
|
435
|
+
const stepLen = size * 0.04;
|
|
436
|
+
|
|
437
|
+
ctx.beginPath();
|
|
438
|
+
ctx.moveTo(vx, vy);
|
|
439
|
+
for (let s = 0; s < steps; s++) {
|
|
440
|
+
const drift = rng ? (rng() - 0.5) * stepLen * 1.5 : 0;
|
|
441
|
+
vx += drift;
|
|
442
|
+
vy += stepLen;
|
|
443
|
+
ctx.lineTo(vx, vy);
|
|
444
|
+
// Branch ~20% of the time
|
|
445
|
+
if (rng && rng() < 0.2 && s > 2 && s < steps - 3) {
|
|
446
|
+
const branchDir = rng() < 0.5 ? -1 : 1;
|
|
447
|
+
let bx = vx;
|
|
448
|
+
let by = vy;
|
|
449
|
+
const bSteps = 3 + Math.floor(rng() * 5);
|
|
450
|
+
ctx.moveTo(bx, by);
|
|
451
|
+
for (let bs = 0; bs < bSteps; bs++) {
|
|
452
|
+
bx += branchDir * stepLen * (0.5 + rng() * 0.5);
|
|
453
|
+
by += stepLen * 0.6;
|
|
454
|
+
ctx.lineTo(bx, by);
|
|
455
|
+
}
|
|
456
|
+
ctx.moveTo(vx, vy); // return to main vein
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
ctx.stroke();
|
|
460
|
+
}
|
|
461
|
+
ctx.restore();
|
|
462
|
+
ctx.globalAlpha = savedAlphaM;
|
|
463
|
+
ctx.globalAlpha *= 0.3;
|
|
464
|
+
ctx.stroke();
|
|
465
|
+
ctx.globalAlpha /= 0.3;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
case "fabric-weave": {
|
|
470
|
+
// Interlocking horizontal/vertical threads clipped to shape
|
|
471
|
+
const savedAlphaF = ctx.globalAlpha;
|
|
472
|
+
ctx.globalAlpha = savedAlphaF * 0.15;
|
|
473
|
+
ctx.fill(); // ghost base
|
|
474
|
+
ctx.globalAlpha = savedAlphaF;
|
|
475
|
+
|
|
476
|
+
ctx.save();
|
|
477
|
+
ctx.clip();
|
|
478
|
+
const threadSpacing = Math.max(2, size * 0.04);
|
|
479
|
+
const extentF = size * 0.55;
|
|
480
|
+
ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
|
|
481
|
+
ctx.globalAlpha = savedAlphaF * 0.55;
|
|
482
|
+
|
|
483
|
+
// Horizontal threads
|
|
484
|
+
for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
|
|
485
|
+
ctx.beginPath();
|
|
486
|
+
ctx.moveTo(-extentF, y);
|
|
487
|
+
ctx.lineTo(extentF, y);
|
|
488
|
+
ctx.stroke();
|
|
489
|
+
}
|
|
490
|
+
// Vertical threads (offset by half spacing for weave effect)
|
|
491
|
+
ctx.globalAlpha = savedAlphaF * 0.45;
|
|
492
|
+
ctx.strokeStyle = fillColor;
|
|
493
|
+
for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
|
|
494
|
+
ctx.beginPath();
|
|
495
|
+
for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
|
|
496
|
+
// Over-under: draw segment, skip segment
|
|
497
|
+
ctx.moveTo(x, y);
|
|
498
|
+
ctx.lineTo(x, y + threadSpacing);
|
|
499
|
+
}
|
|
500
|
+
ctx.stroke();
|
|
501
|
+
}
|
|
502
|
+
ctx.strokeStyle = strokeColor;
|
|
503
|
+
ctx.restore();
|
|
504
|
+
ctx.globalAlpha = savedAlphaF;
|
|
505
|
+
ctx.globalAlpha *= 0.3;
|
|
506
|
+
ctx.stroke();
|
|
507
|
+
ctx.globalAlpha /= 0.3;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
case "hand-drawn": {
|
|
512
|
+
// Wobbly hand-drawn edge treatment — fill normally, then redraw
|
|
513
|
+
// the outline with perturbed control points for a sketchy feel
|
|
514
|
+
const savedAlphaHD = ctx.globalAlpha;
|
|
515
|
+
ctx.globalAlpha = savedAlphaHD * 0.85;
|
|
516
|
+
ctx.fill();
|
|
517
|
+
ctx.globalAlpha = savedAlphaHD;
|
|
518
|
+
|
|
519
|
+
// Draw 2-3 slightly offset wobbly strokes for a sketchy look
|
|
520
|
+
const wobblePasses = 2 + (rng ? Math.floor(rng() * 2) : 0);
|
|
521
|
+
ctx.lineWidth = strokeWidth * 0.8;
|
|
522
|
+
for (let wp = 0; wp < wobblePasses; wp++) {
|
|
523
|
+
ctx.globalAlpha = savedAlphaHD * (0.4 - wp * 0.1);
|
|
524
|
+
ctx.save();
|
|
525
|
+
// Slight random offset per pass
|
|
526
|
+
const wobbleX = rng ? (rng() - 0.5) * size * 0.02 : 0;
|
|
527
|
+
const wobbleY = rng ? (rng() - 0.5) * size * 0.02 : 0;
|
|
528
|
+
ctx.translate(wobbleX, wobbleY);
|
|
529
|
+
// Slightly different scale per pass for edge variation
|
|
530
|
+
const wobbleScale = 1 + (rng ? (rng() - 0.5) * 0.03 : 0);
|
|
531
|
+
ctx.scale(wobbleScale, wobbleScale);
|
|
532
|
+
ctx.stroke();
|
|
533
|
+
ctx.restore();
|
|
534
|
+
}
|
|
535
|
+
ctx.globalAlpha = savedAlphaHD;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
|
|
277
539
|
case "fill-and-stroke":
|
|
278
540
|
default:
|
|
279
541
|
ctx.fill();
|
|
@@ -358,3 +620,88 @@ export function enhanceShapeGeneration(
|
|
|
358
620
|
|
|
359
621
|
ctx.restore();
|
|
360
622
|
}
|
|
623
|
+
|
|
624
|
+
// ── Shape mirroring effect ──────────────────────────────────────────
|
|
625
|
+
// Draws a shape and its mirror (reflected across an axis) for visual
|
|
626
|
+
// symmetry. Works especially well with basic shapes like triangles,
|
|
627
|
+
// crescents, and penrose tiles.
|
|
628
|
+
|
|
629
|
+
export type MirrorAxis = "horizontal" | "vertical" | "diagonal" | "radial-4";
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Draw a shape with a mirrored reflection.
|
|
633
|
+
* The mirror is drawn at reduced opacity with optional offset.
|
|
634
|
+
*/
|
|
635
|
+
export function drawMirroredShape(
|
|
636
|
+
ctx: CanvasRenderingContext2D,
|
|
637
|
+
shape: string,
|
|
638
|
+
x: number,
|
|
639
|
+
y: number,
|
|
640
|
+
config: EnhanceShapeConfig & { mirrorAxis?: MirrorAxis; mirrorGap?: number },
|
|
641
|
+
): void {
|
|
642
|
+
const { mirrorAxis = "horizontal", mirrorGap = 0 } = config;
|
|
643
|
+
|
|
644
|
+
// Draw the primary shape
|
|
645
|
+
enhanceShapeGeneration(ctx, shape, x, y, config);
|
|
646
|
+
|
|
647
|
+
// Draw the mirrored copy
|
|
648
|
+
ctx.save();
|
|
649
|
+
const savedAlpha = ctx.globalAlpha;
|
|
650
|
+
ctx.globalAlpha = savedAlpha * 0.7; // mirror is slightly softer
|
|
651
|
+
|
|
652
|
+
switch (mirrorAxis) {
|
|
653
|
+
case "horizontal":
|
|
654
|
+
// Reflect across vertical axis at shape position
|
|
655
|
+
enhanceShapeGeneration(ctx, shape, x, y + mirrorGap, {
|
|
656
|
+
...config,
|
|
657
|
+
rotation: -(config.rotation || 0),
|
|
658
|
+
size: config.size * 0.95,
|
|
659
|
+
});
|
|
660
|
+
break;
|
|
661
|
+
case "vertical":
|
|
662
|
+
enhanceShapeGeneration(ctx, shape, x + mirrorGap, y, {
|
|
663
|
+
...config,
|
|
664
|
+
rotation: 180 - (config.rotation || 0),
|
|
665
|
+
size: config.size * 0.95,
|
|
666
|
+
});
|
|
667
|
+
break;
|
|
668
|
+
case "diagonal":
|
|
669
|
+
// Reflect across 45° axis
|
|
670
|
+
enhanceShapeGeneration(ctx, shape, x + mirrorGap * 0.7, y + mirrorGap * 0.7, {
|
|
671
|
+
...config,
|
|
672
|
+
rotation: 90 - (config.rotation || 0),
|
|
673
|
+
size: config.size * 0.9,
|
|
674
|
+
});
|
|
675
|
+
break;
|
|
676
|
+
case "radial-4":
|
|
677
|
+
// Four-way radial mirror
|
|
678
|
+
for (let i = 1; i < 4; i++) {
|
|
679
|
+
const angle = (i / 4) * Math.PI * 2;
|
|
680
|
+
const mx = x + Math.cos(angle) * mirrorGap;
|
|
681
|
+
const my = y + Math.sin(angle) * mirrorGap;
|
|
682
|
+
ctx.globalAlpha = savedAlpha * (0.7 - i * 0.1);
|
|
683
|
+
enhanceShapeGeneration(ctx, shape, mx, my, {
|
|
684
|
+
...config,
|
|
685
|
+
rotation: (config.rotation || 0) + i * 90,
|
|
686
|
+
size: config.size * (0.95 - i * 0.05),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
ctx.globalAlpha = savedAlpha;
|
|
693
|
+
ctx.restore();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Pick a mirror axis deterministically.
|
|
698
|
+
* Returns null ~60% of the time (no mirroring).
|
|
699
|
+
*/
|
|
700
|
+
export function pickMirrorAxis(rng: () => number): MirrorAxis | null {
|
|
701
|
+
const roll = rng();
|
|
702
|
+
if (roll < 0.60) return null;
|
|
703
|
+
if (roll < 0.75) return "horizontal";
|
|
704
|
+
if (roll < 0.87) return "vertical";
|
|
705
|
+
if (roll < 0.95) return "diagonal";
|
|
706
|
+
return "radial-4";
|
|
707
|
+
}
|