git-hash-art 0.7.0 → 0.9.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 +323 -270
- package/CHANGELOG.md +18 -0
- package/bin/cli.js +17 -14
- package/bin/generateExamples.js +6 -14
- package/bin/generateVersionComparison.js +353 -0
- package/dist/browser.js +2398 -225
- package/dist/browser.js.map +1 -1
- package/dist/main.js +2398 -225
- package/dist/main.js.map +1 -1
- package/dist/module.js +2398 -225
- package/dist/module.js.map +1 -1
- package/package.json +2 -1
- package/src/lib/archetypes.ts +119 -0
- package/src/lib/canvas/colors.ts +110 -2
- package/src/lib/canvas/draw.ts +359 -9
- package/src/lib/canvas/shapes/affinity.ts +624 -0
- package/src/lib/canvas/shapes/procedural.ts +395 -32
- package/src/lib/render.ts +531 -155
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-hash-art",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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
|
@@ -219,6 +219,57 @@ const ARCHETYPES: Archetype[] = [
|
|
|
219
219
|
sizePower: 2.5,
|
|
220
220
|
invertForeground: false,
|
|
221
221
|
},
|
|
222
|
+
{
|
|
223
|
+
name: "watercolor-wash",
|
|
224
|
+
gridSize: 3,
|
|
225
|
+
layers: 3,
|
|
226
|
+
baseOpacity: 0.25,
|
|
227
|
+
opacityReduction: 0.03,
|
|
228
|
+
minShapeSize: 200,
|
|
229
|
+
maxShapeSize: 700,
|
|
230
|
+
backgroundStyle: "radial-light",
|
|
231
|
+
paletteMode: "harmonious",
|
|
232
|
+
preferredStyles: ["watercolor", "fill-only", "incomplete"],
|
|
233
|
+
flowLineMultiplier: 0.5,
|
|
234
|
+
heroShape: false,
|
|
235
|
+
glowMultiplier: 0.3,
|
|
236
|
+
sizePower: 0.6,
|
|
237
|
+
invertForeground: false,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "op-art",
|
|
241
|
+
gridSize: 8,
|
|
242
|
+
layers: 2,
|
|
243
|
+
baseOpacity: 0.95,
|
|
244
|
+
opacityReduction: 0.05,
|
|
245
|
+
minShapeSize: 20,
|
|
246
|
+
maxShapeSize: 200,
|
|
247
|
+
backgroundStyle: "solid-light",
|
|
248
|
+
paletteMode: "high-contrast",
|
|
249
|
+
preferredStyles: ["fill-and-stroke", "stroke-only", "dashed"],
|
|
250
|
+
flowLineMultiplier: 0,
|
|
251
|
+
heroShape: false,
|
|
252
|
+
glowMultiplier: 0,
|
|
253
|
+
sizePower: 0.4,
|
|
254
|
+
invertForeground: false,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "collage",
|
|
258
|
+
gridSize: 4,
|
|
259
|
+
layers: 3,
|
|
260
|
+
baseOpacity: 0.9,
|
|
261
|
+
opacityReduction: 0.08,
|
|
262
|
+
minShapeSize: 80,
|
|
263
|
+
maxShapeSize: 500,
|
|
264
|
+
backgroundStyle: "solid-light",
|
|
265
|
+
paletteMode: "duotone",
|
|
266
|
+
preferredStyles: ["fill-and-stroke", "fill-only", "double-stroke"],
|
|
267
|
+
flowLineMultiplier: 0,
|
|
268
|
+
heroShape: true,
|
|
269
|
+
glowMultiplier: 0,
|
|
270
|
+
sizePower: 0.7,
|
|
271
|
+
invertForeground: false,
|
|
272
|
+
},
|
|
222
273
|
{
|
|
223
274
|
name: "classic",
|
|
224
275
|
gridSize: 5,
|
|
@@ -236,6 +287,74 @@ const ARCHETYPES: Archetype[] = [
|
|
|
236
287
|
sizePower: 1.8,
|
|
237
288
|
invertForeground: false,
|
|
238
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
|
+
},
|
|
239
358
|
];
|
|
240
359
|
|
|
241
360
|
/**
|
package/src/lib/canvas/colors.ts
CHANGED
|
@@ -325,9 +325,87 @@ export function hexWithAlpha(hex: string, alpha: number): string {
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
|
-
*
|
|
329
|
-
*
|
|
328
|
+
* Color hierarchy — assigns dominant/secondary/accent roles to a palette.
|
|
329
|
+
* Dominant gets ~60% of usage, secondary ~25%, accent ~15%.
|
|
330
330
|
*/
|
|
331
|
+
export interface ColorHierarchy {
|
|
332
|
+
dominant: string;
|
|
333
|
+
secondary: string;
|
|
334
|
+
accent: string;
|
|
335
|
+
all: string[];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function buildColorHierarchy(colors: string[], rng: () => number): ColorHierarchy {
|
|
339
|
+
if (colors.length < 3) {
|
|
340
|
+
return {
|
|
341
|
+
dominant: colors[0] || "#888888",
|
|
342
|
+
secondary: colors[1] || colors[0] || "#888888",
|
|
343
|
+
accent: colors[colors.length - 1] || "#888888",
|
|
344
|
+
all: colors,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// Pick dominant as the color closest to the palette's average hue
|
|
348
|
+
const hsls = colors.map((c) => hexToHsl(c));
|
|
349
|
+
const avgHue = hsls.reduce((s, h) => s + h[0], 0) / hsls.length;
|
|
350
|
+
let dominantIdx = 0;
|
|
351
|
+
let minDist = 360;
|
|
352
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
353
|
+
const d = Math.min(Math.abs(hsls[i][0] - avgHue), 360 - Math.abs(hsls[i][0] - avgHue));
|
|
354
|
+
if (d < minDist) { minDist = d; dominantIdx = i; }
|
|
355
|
+
}
|
|
356
|
+
// Accent is the color most distant from dominant in hue
|
|
357
|
+
let accentIdx = 0;
|
|
358
|
+
let maxDist = 0;
|
|
359
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
360
|
+
if (i === dominantIdx) continue;
|
|
361
|
+
const d = Math.min(Math.abs(hsls[i][0] - hsls[dominantIdx][0]), 360 - Math.abs(hsls[i][0] - hsls[dominantIdx][0]));
|
|
362
|
+
if (d > maxDist) { maxDist = d; accentIdx = i; }
|
|
363
|
+
}
|
|
364
|
+
// Secondary is the remaining color with highest saturation
|
|
365
|
+
let secondaryIdx = 0;
|
|
366
|
+
let maxSat = -1;
|
|
367
|
+
for (let i = 0; i < hsls.length; i++) {
|
|
368
|
+
if (i === dominantIdx || i === accentIdx) continue;
|
|
369
|
+
if (hsls[i][1] > maxSat) { maxSat = hsls[i][1]; secondaryIdx = i; }
|
|
370
|
+
}
|
|
371
|
+
if (secondaryIdx === dominantIdx) secondaryIdx = accentIdx === 0 ? 1 : 0;
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
dominant: colors[dominantIdx],
|
|
375
|
+
secondary: colors[secondaryIdx],
|
|
376
|
+
accent: colors[accentIdx],
|
|
377
|
+
all: colors,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Pick a color from the hierarchy with weighted probability.
|
|
383
|
+
* ~60% dominant, ~25% secondary, ~15% accent.
|
|
384
|
+
*/
|
|
385
|
+
export function pickHierarchyColor(hierarchy: ColorHierarchy, rng: () => number): string {
|
|
386
|
+
const roll = rng();
|
|
387
|
+
if (roll < 0.60) return hierarchy.dominant;
|
|
388
|
+
if (roll < 0.85) return hierarchy.secondary;
|
|
389
|
+
return hierarchy.accent;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* HSL-space color jitter — preserves vibrancy better than RGB jitter.
|
|
394
|
+
* Applies small hue wobble + saturation/lightness variation.
|
|
395
|
+
*/
|
|
396
|
+
export function jitterColorHSL(
|
|
397
|
+
hex: string,
|
|
398
|
+
rng: () => number,
|
|
399
|
+
hueAmount = 8,
|
|
400
|
+
slAmount = 0.06,
|
|
401
|
+
): string {
|
|
402
|
+
const [h, s, l] = hexToHsl(hex);
|
|
403
|
+
const newH = (h + (rng() - 0.5) * hueAmount * 2 + 360) % 360;
|
|
404
|
+
const newS = Math.max(0, Math.min(1, s + (rng() - 0.5) * slAmount * 2));
|
|
405
|
+
const newL = Math.max(0, Math.min(1, l + (rng() - 0.5) * slAmount * 2));
|
|
406
|
+
return hslToHex(newH, newS, newL);
|
|
407
|
+
}
|
|
408
|
+
|
|
331
409
|
export function jitterColor(
|
|
332
410
|
hex: string,
|
|
333
411
|
rng: () => number,
|
|
@@ -402,3 +480,33 @@ export function enforceContrast(
|
|
|
402
480
|
return hslToHex(h, targetS, targetL);
|
|
403
481
|
}
|
|
404
482
|
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Apply a unified color grade to a hex color — shifts the entire image
|
|
486
|
+
* toward a cohesive tone. This is the "Instagram filter" effect.
|
|
487
|
+
*/
|
|
488
|
+
export function applyColorGrade(
|
|
489
|
+
hex: string,
|
|
490
|
+
gradeHue: number,
|
|
491
|
+
intensity: number,
|
|
492
|
+
): string {
|
|
493
|
+
const [h, s, l] = hexToHsl(hex);
|
|
494
|
+
// Blend hue toward the grade hue
|
|
495
|
+
const hueDiff = ((gradeHue - h + 540) % 360) - 180;
|
|
496
|
+
const newH = (h + hueDiff * intensity * 0.3 + 360) % 360;
|
|
497
|
+
// Slightly unify saturation
|
|
498
|
+
const newS = Math.max(0, Math.min(1, s + (0.5 - s) * intensity * 0.15));
|
|
499
|
+
return hslToHex(newH, newS, l);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Compute a deterministic color grade from the hash.
|
|
504
|
+
* Returns a hue (0-360) and intensity (0.15-0.4).
|
|
505
|
+
*/
|
|
506
|
+
export function pickColorGrade(rng: () => number): { hue: number; intensity: number } {
|
|
507
|
+
// Warm golden, cool blue, rosy, teal, amber
|
|
508
|
+
const GRADE_HUES = [40, 220, 340, 175, 30];
|
|
509
|
+
const hue = GRADE_HUES[Math.floor(rng() * GRADE_HUES.length)] + (rng() - 0.5) * 20;
|
|
510
|
+
const intensity = 0.15 + rng() * 0.25;
|
|
511
|
+
return { hue: (hue + 360) % 360, intensity };
|
|
512
|
+
}
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -35,7 +35,13 @@ 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
|
|
39
45
|
|
|
40
46
|
const RENDER_STYLES: RenderStyle[] = [
|
|
41
47
|
"fill-and-stroke",
|
|
@@ -47,6 +53,12 @@ const RENDER_STYLES: RenderStyle[] = [
|
|
|
47
53
|
"watercolor",
|
|
48
54
|
"hatched",
|
|
49
55
|
"incomplete",
|
|
56
|
+
"stipple",
|
|
57
|
+
"stencil",
|
|
58
|
+
"noise-grain",
|
|
59
|
+
"wood-grain",
|
|
60
|
+
"marble-vein",
|
|
61
|
+
"fabric-weave",
|
|
50
62
|
];
|
|
51
63
|
|
|
52
64
|
export function pickRenderStyle(rng: () => number): RenderStyle {
|
|
@@ -151,23 +163,56 @@ function applyRenderStyle(
|
|
|
151
163
|
break;
|
|
152
164
|
|
|
153
165
|
case "watercolor": {
|
|
154
|
-
//
|
|
155
|
-
const passes =
|
|
166
|
+
// Improved watercolor: edge darkening + radial bleed + layered washes
|
|
167
|
+
const passes = 4 + (rng ? Math.floor(rng() * 2) : 0);
|
|
156
168
|
const savedAlpha = ctx.globalAlpha;
|
|
157
|
-
|
|
169
|
+
|
|
170
|
+
// Pass 1: Base wash — large, soft fill at low opacity
|
|
171
|
+
ctx.globalAlpha = savedAlpha * 0.15;
|
|
172
|
+
ctx.save();
|
|
173
|
+
const baseScale = 1.08 + (rng ? rng() * 0.04 : 0);
|
|
174
|
+
ctx.scale(baseScale, baseScale);
|
|
175
|
+
ctx.fill();
|
|
176
|
+
ctx.restore();
|
|
177
|
+
|
|
178
|
+
// Pass 2: Multiple offset washes with radial displacement
|
|
179
|
+
ctx.globalAlpha = savedAlpha * (0.25 / passes * 2);
|
|
158
180
|
for (let p = 0; p < passes; p++) {
|
|
159
|
-
|
|
160
|
-
const
|
|
181
|
+
// Radial outward displacement (not uniform) for organic bleed
|
|
182
|
+
const angle = rng ? rng() * Math.PI * 2 : p * Math.PI / 2;
|
|
183
|
+
const dist = rng ? rng() * size * 0.05 : size * 0.02;
|
|
184
|
+
const jx = Math.cos(angle) * dist;
|
|
185
|
+
const jy = Math.sin(angle) * dist;
|
|
161
186
|
ctx.save();
|
|
162
187
|
ctx.translate(jx, jy);
|
|
163
188
|
ctx.fill();
|
|
164
189
|
ctx.restore();
|
|
165
190
|
}
|
|
191
|
+
|
|
192
|
+
// Pass 3: Edge darkening — draw a slightly smaller shape with lighter fill
|
|
193
|
+
// to simulate pigment pooling at boundaries
|
|
194
|
+
ctx.globalAlpha = savedAlpha * 0.35;
|
|
195
|
+
ctx.save();
|
|
196
|
+
const innerScale = 0.85 + (rng ? rng() * 0.08 : 0);
|
|
197
|
+
ctx.scale(innerScale, innerScale);
|
|
198
|
+
// Lighten the fill for the inner area
|
|
199
|
+
const origFill = ctx.fillStyle;
|
|
200
|
+
if (typeof fillColor === "string") {
|
|
201
|
+
ctx.fillStyle = fillColor.replace(/[\d.]+\)$/, (m) => {
|
|
202
|
+
const v = parseFloat(m);
|
|
203
|
+
return Math.min(1, v * 1.4).toFixed(2) + ")";
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
ctx.fill();
|
|
207
|
+
ctx.fillStyle = origFill;
|
|
208
|
+
ctx.restore();
|
|
209
|
+
|
|
166
210
|
ctx.globalAlpha = savedAlpha;
|
|
167
|
-
//
|
|
168
|
-
ctx.globalAlpha *= 0.
|
|
211
|
+
// Soft stroke on top — thinner than normal for delicacy
|
|
212
|
+
ctx.globalAlpha *= 0.25;
|
|
213
|
+
ctx.lineWidth = strokeWidth * 0.6;
|
|
169
214
|
ctx.stroke();
|
|
170
|
-
ctx.globalAlpha /= 0.
|
|
215
|
+
ctx.globalAlpha /= 0.25;
|
|
171
216
|
break;
|
|
172
217
|
}
|
|
173
218
|
|
|
@@ -241,6 +286,226 @@ function applyRenderStyle(
|
|
|
241
286
|
break;
|
|
242
287
|
}
|
|
243
288
|
|
|
289
|
+
case "stipple": {
|
|
290
|
+
// Dot-fill texture — clip to shape, then scatter dots
|
|
291
|
+
const savedAlphaS = ctx.globalAlpha;
|
|
292
|
+
ctx.globalAlpha = savedAlphaS * 0.15;
|
|
293
|
+
ctx.fill(); // ghost fill
|
|
294
|
+
ctx.globalAlpha = savedAlphaS;
|
|
295
|
+
|
|
296
|
+
ctx.save();
|
|
297
|
+
ctx.clip();
|
|
298
|
+
const dotSpacing = Math.max(2, size * 0.03);
|
|
299
|
+
const extent = size * 0.55;
|
|
300
|
+
ctx.globalAlpha = savedAlphaS * 0.7;
|
|
301
|
+
for (let dx = -extent; dx <= extent; dx += dotSpacing) {
|
|
302
|
+
for (let dy = -extent; dy <= extent; dy += dotSpacing) {
|
|
303
|
+
// Jitter each dot position for organic feel
|
|
304
|
+
const jx = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
|
|
305
|
+
const jy = rng ? (rng() - 0.5) * dotSpacing * 0.6 : 0;
|
|
306
|
+
const dotR = rng ? dotSpacing * (0.15 + rng() * 0.2) : dotSpacing * 0.2;
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.arc(dx + jx, dy + jy, dotR, 0, Math.PI * 2);
|
|
309
|
+
ctx.fill();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
ctx.restore();
|
|
313
|
+
ctx.globalAlpha = savedAlphaS;
|
|
314
|
+
// Outline
|
|
315
|
+
ctx.globalAlpha *= 0.4;
|
|
316
|
+
ctx.stroke();
|
|
317
|
+
ctx.globalAlpha /= 0.4;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case "stencil": {
|
|
322
|
+
// Negative-space cutout — fill a rectangle, then erase the shape
|
|
323
|
+
const savedAlphaSt = ctx.globalAlpha;
|
|
324
|
+
// Fill a bounding area with the stroke color
|
|
325
|
+
ctx.globalAlpha = savedAlphaSt * 0.5;
|
|
326
|
+
ctx.fillStyle = strokeColor;
|
|
327
|
+
ctx.fillRect(-size * 0.6, -size * 0.6, size * 1.2, size * 1.2);
|
|
328
|
+
// Cut out the shape using destination-out
|
|
329
|
+
ctx.globalCompositeOperation = "destination-out";
|
|
330
|
+
ctx.globalAlpha = 1;
|
|
331
|
+
ctx.fill();
|
|
332
|
+
ctx.globalCompositeOperation = "source-over";
|
|
333
|
+
ctx.globalAlpha = savedAlphaSt;
|
|
334
|
+
// Subtle outline of the cutout
|
|
335
|
+
ctx.globalAlpha *= 0.3;
|
|
336
|
+
ctx.stroke();
|
|
337
|
+
ctx.globalAlpha /= 0.3;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case "noise-grain": {
|
|
342
|
+
// Procedural noise grain texture clipped to shape boundary
|
|
343
|
+
const savedAlphaN = ctx.globalAlpha;
|
|
344
|
+
ctx.globalAlpha = savedAlphaN * 0.25;
|
|
345
|
+
ctx.fill(); // base tint
|
|
346
|
+
ctx.globalAlpha = savedAlphaN;
|
|
347
|
+
|
|
348
|
+
ctx.save();
|
|
349
|
+
ctx.clip();
|
|
350
|
+
const grainSpacing = Math.max(1.5, size * 0.015);
|
|
351
|
+
const extentN = size * 0.55;
|
|
352
|
+
ctx.globalAlpha = savedAlphaN * 0.6;
|
|
353
|
+
for (let gx = -extentN; gx <= extentN; gx += grainSpacing) {
|
|
354
|
+
for (let gy = -extentN; gy <= extentN; gy += grainSpacing) {
|
|
355
|
+
if (!rng) break;
|
|
356
|
+
const jx = (rng() - 0.5) * grainSpacing * 1.2;
|
|
357
|
+
const jy = (rng() - 0.5) * grainSpacing * 1.2;
|
|
358
|
+
const brightness = rng() > 0.5 ? 255 : 0;
|
|
359
|
+
const dotAlpha = 0.15 + rng() * 0.35;
|
|
360
|
+
ctx.globalAlpha = savedAlphaN * dotAlpha;
|
|
361
|
+
ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
|
|
362
|
+
const dotSize = grainSpacing * (0.3 + rng() * 0.5);
|
|
363
|
+
ctx.fillRect(gx + jx, gy + jy, dotSize, dotSize);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
ctx.restore();
|
|
367
|
+
ctx.fillStyle = fillColor;
|
|
368
|
+
ctx.globalAlpha = savedAlphaN;
|
|
369
|
+
ctx.globalAlpha *= 0.4;
|
|
370
|
+
ctx.stroke();
|
|
371
|
+
ctx.globalAlpha /= 0.4;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
case "wood-grain": {
|
|
376
|
+
// Parallel wavy lines simulating wood grain, clipped to shape
|
|
377
|
+
const savedAlphaW = ctx.globalAlpha;
|
|
378
|
+
ctx.globalAlpha = savedAlphaW * 0.2;
|
|
379
|
+
ctx.fill(); // base tint
|
|
380
|
+
ctx.globalAlpha = savedAlphaW;
|
|
381
|
+
|
|
382
|
+
ctx.save();
|
|
383
|
+
ctx.clip();
|
|
384
|
+
const grainLineSpacing = Math.max(2, size * 0.035);
|
|
385
|
+
const extentW = size * 0.55;
|
|
386
|
+
const waveFreq = rng ? 3 + rng() * 5 : 5;
|
|
387
|
+
const waveAmp = rng ? size * (0.01 + rng() * 0.03) : size * 0.02;
|
|
388
|
+
const grainAngle = rng ? rng() * Math.PI : Math.PI * 0.25;
|
|
389
|
+
ctx.lineWidth = Math.max(0.5, strokeWidth * 0.3);
|
|
390
|
+
ctx.globalAlpha = savedAlphaW * 0.5;
|
|
391
|
+
|
|
392
|
+
const cosG = Math.cos(grainAngle);
|
|
393
|
+
const sinG = Math.sin(grainAngle);
|
|
394
|
+
for (let d = -extentW; d <= extentW; d += grainLineSpacing) {
|
|
395
|
+
ctx.beginPath();
|
|
396
|
+
for (let t = -extentW; t <= extentW; t += 2) {
|
|
397
|
+
const wave = Math.sin((t / extentW) * waveFreq * Math.PI) * waveAmp;
|
|
398
|
+
const px = t * cosG - (d + wave) * sinG;
|
|
399
|
+
const py = t * sinG + (d + wave) * cosG;
|
|
400
|
+
if (t === -extentW) ctx.moveTo(px, py);
|
|
401
|
+
else ctx.lineTo(px, py);
|
|
402
|
+
}
|
|
403
|
+
ctx.stroke();
|
|
404
|
+
}
|
|
405
|
+
ctx.restore();
|
|
406
|
+
ctx.globalAlpha = savedAlphaW;
|
|
407
|
+
ctx.globalAlpha *= 0.35;
|
|
408
|
+
ctx.stroke();
|
|
409
|
+
ctx.globalAlpha /= 0.35;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case "marble-vein": {
|
|
414
|
+
// Branching vein lines on a soft fill, clipped to shape
|
|
415
|
+
const savedAlphaM = ctx.globalAlpha;
|
|
416
|
+
ctx.globalAlpha = savedAlphaM * 0.35;
|
|
417
|
+
ctx.fill(); // soft base
|
|
418
|
+
ctx.globalAlpha = savedAlphaM;
|
|
419
|
+
|
|
420
|
+
ctx.save();
|
|
421
|
+
ctx.clip();
|
|
422
|
+
const veinCount = rng ? 2 + Math.floor(rng() * 3) : 3;
|
|
423
|
+
const extentM = size * 0.45;
|
|
424
|
+
ctx.lineWidth = Math.max(0.5, strokeWidth * 0.5);
|
|
425
|
+
ctx.globalAlpha = savedAlphaM * 0.4;
|
|
426
|
+
|
|
427
|
+
for (let v = 0; v < veinCount; v++) {
|
|
428
|
+
const startX = rng ? (rng() - 0.5) * extentM * 2 : 0;
|
|
429
|
+
const startY = rng ? -extentM + rng() * extentM * 0.5 : -extentM;
|
|
430
|
+
let vx = startX;
|
|
431
|
+
let vy = startY;
|
|
432
|
+
const steps = 15 + (rng ? Math.floor(rng() * 15) : 10);
|
|
433
|
+
const stepLen = size * 0.04;
|
|
434
|
+
|
|
435
|
+
ctx.beginPath();
|
|
436
|
+
ctx.moveTo(vx, vy);
|
|
437
|
+
for (let s = 0; s < steps; s++) {
|
|
438
|
+
const drift = rng ? (rng() - 0.5) * stepLen * 1.5 : 0;
|
|
439
|
+
vx += drift;
|
|
440
|
+
vy += stepLen;
|
|
441
|
+
ctx.lineTo(vx, vy);
|
|
442
|
+
// Branch ~20% of the time
|
|
443
|
+
if (rng && rng() < 0.2 && s > 2 && s < steps - 3) {
|
|
444
|
+
const branchDir = rng() < 0.5 ? -1 : 1;
|
|
445
|
+
let bx = vx;
|
|
446
|
+
let by = vy;
|
|
447
|
+
const bSteps = 3 + Math.floor(rng() * 5);
|
|
448
|
+
ctx.moveTo(bx, by);
|
|
449
|
+
for (let bs = 0; bs < bSteps; bs++) {
|
|
450
|
+
bx += branchDir * stepLen * (0.5 + rng() * 0.5);
|
|
451
|
+
by += stepLen * 0.6;
|
|
452
|
+
ctx.lineTo(bx, by);
|
|
453
|
+
}
|
|
454
|
+
ctx.moveTo(vx, vy); // return to main vein
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
ctx.stroke();
|
|
458
|
+
}
|
|
459
|
+
ctx.restore();
|
|
460
|
+
ctx.globalAlpha = savedAlphaM;
|
|
461
|
+
ctx.globalAlpha *= 0.3;
|
|
462
|
+
ctx.stroke();
|
|
463
|
+
ctx.globalAlpha /= 0.3;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "fabric-weave": {
|
|
468
|
+
// Interlocking horizontal/vertical threads clipped to shape
|
|
469
|
+
const savedAlphaF = ctx.globalAlpha;
|
|
470
|
+
ctx.globalAlpha = savedAlphaF * 0.15;
|
|
471
|
+
ctx.fill(); // ghost base
|
|
472
|
+
ctx.globalAlpha = savedAlphaF;
|
|
473
|
+
|
|
474
|
+
ctx.save();
|
|
475
|
+
ctx.clip();
|
|
476
|
+
const threadSpacing = Math.max(2, size * 0.04);
|
|
477
|
+
const extentF = size * 0.55;
|
|
478
|
+
ctx.lineWidth = Math.max(0.8, threadSpacing * 0.5);
|
|
479
|
+
ctx.globalAlpha = savedAlphaF * 0.55;
|
|
480
|
+
|
|
481
|
+
// Horizontal threads
|
|
482
|
+
for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
|
|
483
|
+
ctx.beginPath();
|
|
484
|
+
ctx.moveTo(-extentF, y);
|
|
485
|
+
ctx.lineTo(extentF, y);
|
|
486
|
+
ctx.stroke();
|
|
487
|
+
}
|
|
488
|
+
// Vertical threads (offset by half spacing for weave effect)
|
|
489
|
+
ctx.globalAlpha = savedAlphaF * 0.45;
|
|
490
|
+
ctx.strokeStyle = fillColor;
|
|
491
|
+
for (let x = -extentF; x <= extentF; x += threadSpacing * 2) {
|
|
492
|
+
ctx.beginPath();
|
|
493
|
+
for (let y = -extentF; y <= extentF; y += threadSpacing * 2) {
|
|
494
|
+
// Over-under: draw segment, skip segment
|
|
495
|
+
ctx.moveTo(x, y);
|
|
496
|
+
ctx.lineTo(x, y + threadSpacing);
|
|
497
|
+
}
|
|
498
|
+
ctx.stroke();
|
|
499
|
+
}
|
|
500
|
+
ctx.strokeStyle = strokeColor;
|
|
501
|
+
ctx.restore();
|
|
502
|
+
ctx.globalAlpha = savedAlphaF;
|
|
503
|
+
ctx.globalAlpha *= 0.3;
|
|
504
|
+
ctx.stroke();
|
|
505
|
+
ctx.globalAlpha /= 0.3;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
|
|
244
509
|
case "fill-and-stroke":
|
|
245
510
|
default:
|
|
246
511
|
ctx.fill();
|
|
@@ -325,3 +590,88 @@ export function enhanceShapeGeneration(
|
|
|
325
590
|
|
|
326
591
|
ctx.restore();
|
|
327
592
|
}
|
|
593
|
+
|
|
594
|
+
// ── Shape mirroring effect ──────────────────────────────────────────
|
|
595
|
+
// Draws a shape and its mirror (reflected across an axis) for visual
|
|
596
|
+
// symmetry. Works especially well with basic shapes like triangles,
|
|
597
|
+
// crescents, and penrose tiles.
|
|
598
|
+
|
|
599
|
+
export type MirrorAxis = "horizontal" | "vertical" | "diagonal" | "radial-4";
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Draw a shape with a mirrored reflection.
|
|
603
|
+
* The mirror is drawn at reduced opacity with optional offset.
|
|
604
|
+
*/
|
|
605
|
+
export function drawMirroredShape(
|
|
606
|
+
ctx: CanvasRenderingContext2D,
|
|
607
|
+
shape: string,
|
|
608
|
+
x: number,
|
|
609
|
+
y: number,
|
|
610
|
+
config: EnhanceShapeConfig & { mirrorAxis?: MirrorAxis; mirrorGap?: number },
|
|
611
|
+
): void {
|
|
612
|
+
const { mirrorAxis = "horizontal", mirrorGap = 0 } = config;
|
|
613
|
+
|
|
614
|
+
// Draw the primary shape
|
|
615
|
+
enhanceShapeGeneration(ctx, shape, x, y, config);
|
|
616
|
+
|
|
617
|
+
// Draw the mirrored copy
|
|
618
|
+
ctx.save();
|
|
619
|
+
const savedAlpha = ctx.globalAlpha;
|
|
620
|
+
ctx.globalAlpha = savedAlpha * 0.7; // mirror is slightly softer
|
|
621
|
+
|
|
622
|
+
switch (mirrorAxis) {
|
|
623
|
+
case "horizontal":
|
|
624
|
+
// Reflect across vertical axis at shape position
|
|
625
|
+
enhanceShapeGeneration(ctx, shape, x, y + mirrorGap, {
|
|
626
|
+
...config,
|
|
627
|
+
rotation: -(config.rotation || 0),
|
|
628
|
+
size: config.size * 0.95,
|
|
629
|
+
});
|
|
630
|
+
break;
|
|
631
|
+
case "vertical":
|
|
632
|
+
enhanceShapeGeneration(ctx, shape, x + mirrorGap, y, {
|
|
633
|
+
...config,
|
|
634
|
+
rotation: 180 - (config.rotation || 0),
|
|
635
|
+
size: config.size * 0.95,
|
|
636
|
+
});
|
|
637
|
+
break;
|
|
638
|
+
case "diagonal":
|
|
639
|
+
// Reflect across 45° axis
|
|
640
|
+
enhanceShapeGeneration(ctx, shape, x + mirrorGap * 0.7, y + mirrorGap * 0.7, {
|
|
641
|
+
...config,
|
|
642
|
+
rotation: 90 - (config.rotation || 0),
|
|
643
|
+
size: config.size * 0.9,
|
|
644
|
+
});
|
|
645
|
+
break;
|
|
646
|
+
case "radial-4":
|
|
647
|
+
// Four-way radial mirror
|
|
648
|
+
for (let i = 1; i < 4; i++) {
|
|
649
|
+
const angle = (i / 4) * Math.PI * 2;
|
|
650
|
+
const mx = x + Math.cos(angle) * mirrorGap;
|
|
651
|
+
const my = y + Math.sin(angle) * mirrorGap;
|
|
652
|
+
ctx.globalAlpha = savedAlpha * (0.7 - i * 0.1);
|
|
653
|
+
enhanceShapeGeneration(ctx, shape, mx, my, {
|
|
654
|
+
...config,
|
|
655
|
+
rotation: (config.rotation || 0) + i * 90,
|
|
656
|
+
size: config.size * (0.95 - i * 0.05),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
ctx.globalAlpha = savedAlpha;
|
|
663
|
+
ctx.restore();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Pick a mirror axis deterministically.
|
|
668
|
+
* Returns null ~60% of the time (no mirroring).
|
|
669
|
+
*/
|
|
670
|
+
export function pickMirrorAxis(rng: () => number): MirrorAxis | null {
|
|
671
|
+
const roll = rng();
|
|
672
|
+
if (roll < 0.60) return null;
|
|
673
|
+
if (roll < 0.75) return "horizontal";
|
|
674
|
+
if (roll < 0.87) return "vertical";
|
|
675
|
+
if (roll < 0.95) return "diagonal";
|
|
676
|
+
return "radial-4";
|
|
677
|
+
}
|