git-hash-art 0.13.0 → 0.14.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/CHANGELOG.md +8 -0
- package/dist/browser.js +83 -70
- package/dist/browser.js.map +1 -1
- package/dist/main.js +83 -70
- package/dist/main.js.map +1 -1
- package/dist/module.js +83 -70
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/custom-shapes.test.ts +182 -0
- package/src/browser.ts +1 -1
- package/src/index.ts +1 -1
- package/src/lib/canvas/draw.ts +8 -4
- package/src/lib/canvas/shapes/affinity.ts +33 -0
- package/src/lib/render.ts +38 -67
- package/src/types.ts +50 -0
package/package.json
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
3
|
+
import * as render from "../lib/render";
|
|
4
|
+
import * as affinity from "../lib/canvas/shapes/affinity";
|
|
5
|
+
import type { CustomDrawFunction } from "../types";
|
|
6
|
+
|
|
7
|
+
describe("Custom shapes", () => {
|
|
8
|
+
let ctx: CanvasRenderingContext2D;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
const canvas = createCanvas(256, 256);
|
|
12
|
+
ctx = canvas.getContext("2d") as unknown as CanvasRenderingContext2D;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should render with custom shapes without crashing", () => {
|
|
16
|
+
const drawSpy = vi.fn<CustomDrawFunction>((ctx, size, rng) => {
|
|
17
|
+
const r = size / 2;
|
|
18
|
+
ctx.beginPath();
|
|
19
|
+
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
|
20
|
+
ctx.closePath();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(() => {
|
|
24
|
+
render.renderHashArt(ctx, "abcdef1234567890", {
|
|
25
|
+
width: 256,
|
|
26
|
+
height: 256,
|
|
27
|
+
customShapes: {
|
|
28
|
+
myCircle: { draw: drawSpy },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should pass deterministic RNG to custom draw function", () => {
|
|
35
|
+
const rngValues: number[] = [];
|
|
36
|
+
const makeDraw = (collector: number[]): CustomDrawFunction => (ctx, size, rng) => {
|
|
37
|
+
collector.push(rng());
|
|
38
|
+
ctx.beginPath();
|
|
39
|
+
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
|
|
40
|
+
ctx.closePath();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
render.renderHashArt(ctx, "deadbeefdeadbeef", {
|
|
44
|
+
width: 256,
|
|
45
|
+
height: 256,
|
|
46
|
+
customShapes: {
|
|
47
|
+
onlyShape: {
|
|
48
|
+
draw: makeDraw(rngValues),
|
|
49
|
+
profile: { tier: 1, heroCandidate: true, affinities: ["onlyShape"] },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (rngValues.length > 0) {
|
|
55
|
+
const rngValues2: number[] = [];
|
|
56
|
+
const canvas2 = createCanvas(256, 256);
|
|
57
|
+
const ctx2 = canvas2.getContext("2d") as unknown as CanvasRenderingContext2D;
|
|
58
|
+
|
|
59
|
+
render.renderHashArt(ctx2, "deadbeefdeadbeef", {
|
|
60
|
+
width: 256,
|
|
61
|
+
height: 256,
|
|
62
|
+
customShapes: {
|
|
63
|
+
onlyShape: {
|
|
64
|
+
draw: makeDraw(rngValues2),
|
|
65
|
+
profile: { tier: 1, heroCandidate: true, affinities: ["onlyShape"] },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(rngValues).toEqual(rngValues2);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should not crash with empty customShapes", () => {
|
|
75
|
+
expect(() => {
|
|
76
|
+
render.renderHashArt(ctx, "1111111111111111", {
|
|
77
|
+
width: 256,
|
|
78
|
+
height: 256,
|
|
79
|
+
customShapes: {},
|
|
80
|
+
});
|
|
81
|
+
}).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should not crash with undefined customShapes", () => {
|
|
85
|
+
expect(() => {
|
|
86
|
+
render.renderHashArt(ctx, "2222222222222222", {
|
|
87
|
+
width: 256,
|
|
88
|
+
height: 256,
|
|
89
|
+
});
|
|
90
|
+
}).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should clean up custom profiles after render", () => {
|
|
94
|
+
render.renderHashArt(ctx, "3333333333333333", {
|
|
95
|
+
width: 256,
|
|
96
|
+
height: 256,
|
|
97
|
+
customShapes: {
|
|
98
|
+
tempShape: {
|
|
99
|
+
draw: (ctx, size) => {
|
|
100
|
+
ctx.beginPath();
|
|
101
|
+
ctx.rect(-size / 2, -size / 2, size, size);
|
|
102
|
+
ctx.closePath();
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(affinity.SHAPE_PROFILES["tempShape"]).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should apply custom profile overrides", () => {
|
|
112
|
+
affinity.registerCustomProfile("testShape", {
|
|
113
|
+
tier: 1,
|
|
114
|
+
minSizeFraction: 0.1,
|
|
115
|
+
maxSizeFraction: 0.5,
|
|
116
|
+
affinities: ["circle", "triangle"],
|
|
117
|
+
heroCandidate: true,
|
|
118
|
+
bestStyles: ["stroke-only"],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const profile = affinity.SHAPE_PROFILES["testShape"];
|
|
122
|
+
expect(profile).toBeDefined();
|
|
123
|
+
expect(profile.tier).toBe(1);
|
|
124
|
+
expect(profile.minSizeFraction).toBe(0.1);
|
|
125
|
+
expect(profile.maxSizeFraction).toBe(0.5);
|
|
126
|
+
expect(profile.heroCandidate).toBe(true);
|
|
127
|
+
expect(profile.bestStyles).toEqual(["stroke-only"]);
|
|
128
|
+
expect(profile.category).toBe("procedural");
|
|
129
|
+
|
|
130
|
+
affinity.unregisterCustomProfile("testShape");
|
|
131
|
+
expect(affinity.SHAPE_PROFILES["testShape"]).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should use sensible defaults for missing profile fields", () => {
|
|
135
|
+
affinity.registerCustomProfile("defaultsShape", {});
|
|
136
|
+
|
|
137
|
+
const profile = affinity.SHAPE_PROFILES["defaultsShape"];
|
|
138
|
+
expect(profile.tier).toBe(2);
|
|
139
|
+
expect(profile.minSizeFraction).toBe(0.05);
|
|
140
|
+
expect(profile.maxSizeFraction).toBe(1.0);
|
|
141
|
+
expect(profile.affinities).toEqual(["circle", "square"]);
|
|
142
|
+
expect(profile.heroCandidate).toBe(false);
|
|
143
|
+
expect(profile.bestStyles).toEqual(["fill-and-stroke", "watercolor"]);
|
|
144
|
+
|
|
145
|
+
affinity.unregisterCustomProfile("defaultsShape");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should render with multiple custom shapes", () => {
|
|
149
|
+
const diamond: CustomDrawFunction = (ctx, size, rng) => {
|
|
150
|
+
const half = size / 2;
|
|
151
|
+
ctx.beginPath();
|
|
152
|
+
ctx.moveTo(0, -half);
|
|
153
|
+
ctx.lineTo(half * rng(), 0);
|
|
154
|
+
ctx.lineTo(0, half);
|
|
155
|
+
ctx.lineTo(-half * rng(), 0);
|
|
156
|
+
ctx.closePath();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const cross: CustomDrawFunction = (ctx, size, rng) => {
|
|
160
|
+
const arm = size * 0.15;
|
|
161
|
+
const half = size / 2 * (0.8 + rng() * 0.2);
|
|
162
|
+
ctx.beginPath();
|
|
163
|
+
ctx.rect(-arm, -half, arm * 2, size);
|
|
164
|
+
ctx.rect(-half, -arm, size, arm * 2);
|
|
165
|
+
ctx.closePath();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
expect(() => {
|
|
169
|
+
render.renderHashArt(ctx, "cafebabe12345678", {
|
|
170
|
+
width: 256,
|
|
171
|
+
height: 256,
|
|
172
|
+
customShapes: {
|
|
173
|
+
customDiamond: { draw: diamond },
|
|
174
|
+
customCross: {
|
|
175
|
+
draw: cross,
|
|
176
|
+
profile: { tier: 1, heroCandidate: true },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}).not.toThrow();
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/browser.ts
CHANGED
|
@@ -96,5 +96,5 @@ function generateDataURL(
|
|
|
96
96
|
|
|
97
97
|
export { renderToCanvas, generateImageBlob, generateDataURL, renderHashArt };
|
|
98
98
|
export { PRESETS } from "./lib/constants";
|
|
99
|
-
export type { GenerationConfig } from "./types";
|
|
99
|
+
export type { GenerationConfig, CustomShapeDefinition, CustomDrawFunction } from "./types";
|
|
100
100
|
export { DEFAULT_CONFIG } from "./types";
|
package/src/index.ts
CHANGED
|
@@ -66,5 +66,5 @@ function saveImageToFile(
|
|
|
66
66
|
|
|
67
67
|
export { generateImageFromHash, saveImageToFile, renderHashArt };
|
|
68
68
|
export { PRESETS } from "./lib/constants";
|
|
69
|
-
export type { GenerationConfig } from "./types";
|
|
69
|
+
export type { GenerationConfig, CustomShapeDefinition, CustomDrawFunction } from "./types";
|
|
70
70
|
export { DEFAULT_CONFIG } from "./types";
|
package/src/lib/canvas/draw.ts
CHANGED
|
@@ -134,6 +134,8 @@ interface EnhanceShapeConfig extends DrawShapeConfig {
|
|
|
134
134
|
lightAngle?: number;
|
|
135
135
|
/** Scale factor for resolution-independent sizing. */
|
|
136
136
|
scaleFactor?: number;
|
|
137
|
+
/** Optional combined shapes registry (includes custom shapes). Falls back to built-in shapes. */
|
|
138
|
+
activeShapes?: Record<string, (ctx: CanvasRenderingContext2D, size: number, config?: any) => void>;
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
export function drawShape(
|
|
@@ -141,9 +143,9 @@ export function drawShape(
|
|
|
141
143
|
shape: string,
|
|
142
144
|
x: number,
|
|
143
145
|
y: number,
|
|
144
|
-
config: DrawShapeConfig,
|
|
146
|
+
config: DrawShapeConfig & { activeShapes?: Record<string, (ctx: CanvasRenderingContext2D, size: number, config?: any) => void> },
|
|
145
147
|
) {
|
|
146
|
-
const { fillColor, strokeColor, strokeWidth, size, rotation } = config;
|
|
148
|
+
const { fillColor, strokeColor, strokeWidth, size, rotation, activeShapes } = config;
|
|
147
149
|
ctx.save();
|
|
148
150
|
ctx.translate(x, y);
|
|
149
151
|
ctx.rotate((rotation * Math.PI) / 180);
|
|
@@ -151,7 +153,8 @@ export function drawShape(
|
|
|
151
153
|
ctx.strokeStyle = strokeColor;
|
|
152
154
|
ctx.lineWidth = strokeWidth;
|
|
153
155
|
|
|
154
|
-
const
|
|
156
|
+
const registry = activeShapes ?? shapes;
|
|
157
|
+
const drawFunction = registry[shape];
|
|
155
158
|
if (drawFunction) {
|
|
156
159
|
drawFunction(ctx, size);
|
|
157
160
|
ctx.fill();
|
|
@@ -746,7 +749,8 @@ export function enhanceShapeGeneration(
|
|
|
746
749
|
ctx.strokeStyle = strokeColor;
|
|
747
750
|
ctx.lineWidth = strokeWidth;
|
|
748
751
|
|
|
749
|
-
const
|
|
752
|
+
const registry = config.activeShapes ?? shapes;
|
|
753
|
+
const drawFunction = registry[shape];
|
|
750
754
|
if (drawFunction) {
|
|
751
755
|
drawFunction(ctx, size, { rng });
|
|
752
756
|
applyRenderStyle(ctx, renderStyle, fillColor, strokeColor, strokeWidth, size, rng);
|
|
@@ -420,6 +420,39 @@ export const SHAPE_PROFILES: Record<string, ShapeProfile> = {
|
|
|
420
420
|
},
|
|
421
421
|
};
|
|
422
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Register a custom shape profile into SHAPE_PROFILES.
|
|
425
|
+
* Merges user-provided partial profile with sensible defaults.
|
|
426
|
+
*/
|
|
427
|
+
export function registerCustomProfile(
|
|
428
|
+
name: string,
|
|
429
|
+
partial?: {
|
|
430
|
+
tier?: 1 | 2 | 3;
|
|
431
|
+
minSizeFraction?: number;
|
|
432
|
+
maxSizeFraction?: number;
|
|
433
|
+
affinities?: string[];
|
|
434
|
+
heroCandidate?: boolean;
|
|
435
|
+
bestStyles?: string[];
|
|
436
|
+
},
|
|
437
|
+
): void {
|
|
438
|
+
SHAPE_PROFILES[name] = {
|
|
439
|
+
tier: partial?.tier ?? 2,
|
|
440
|
+
minSizeFraction: partial?.minSizeFraction ?? 0.05,
|
|
441
|
+
maxSizeFraction: partial?.maxSizeFraction ?? 1.0,
|
|
442
|
+
affinities: partial?.affinities ?? ["circle", "square"],
|
|
443
|
+
category: "procedural",
|
|
444
|
+
heroCandidate: partial?.heroCandidate ?? false,
|
|
445
|
+
bestStyles: partial?.bestStyles ?? ["fill-and-stroke", "watercolor"],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Remove a custom shape profile from SHAPE_PROFILES.
|
|
451
|
+
*/
|
|
452
|
+
export function unregisterCustomProfile(name: string): void {
|
|
453
|
+
delete SHAPE_PROFILES[name];
|
|
454
|
+
}
|
|
455
|
+
|
|
423
456
|
// ── Shape palette: curated sets of shapes that work well together ────
|
|
424
457
|
|
|
425
458
|
export interface ShapePalette {
|
package/src/lib/render.ts
CHANGED
|
@@ -520,7 +520,32 @@ export function renderHashArt(
|
|
|
520
520
|
const colorHierarchy = buildColorHierarchy(colors, rng);
|
|
521
521
|
|
|
522
522
|
// ── 0c. Shape palette — curated shapes that work well together ──
|
|
523
|
-
|
|
523
|
+
// Merge custom shapes into a combined registry
|
|
524
|
+
const customShapeNames: string[] = [];
|
|
525
|
+
type DrawFunction = (ctx: CanvasRenderingContext2D, size: number, config?: any) => void;
|
|
526
|
+
let activeShapes: Record<string, DrawFunction> | undefined;
|
|
527
|
+
if (finalConfig.customShapes && Object.keys(finalConfig.customShapes).length > 0) {
|
|
528
|
+
activeShapes = { ...shapes };
|
|
529
|
+
for (const [name, def] of Object.entries(finalConfig.customShapes)) {
|
|
530
|
+
// Wrap CustomDrawFunction (ctx, size, rng) into DrawFunction (ctx, size, config?)
|
|
531
|
+
const customDraw = def.draw;
|
|
532
|
+
activeShapes[name] = (ctx, size, config?) => {
|
|
533
|
+
customDraw(ctx, size, config?.rng ?? Math.random);
|
|
534
|
+
};
|
|
535
|
+
// Register profile for affinity system (inlined to avoid ESM interop issues)
|
|
536
|
+
SHAPE_PROFILES[name] = {
|
|
537
|
+
tier: def.profile?.tier ?? 2,
|
|
538
|
+
minSizeFraction: def.profile?.minSizeFraction ?? 0.05,
|
|
539
|
+
maxSizeFraction: def.profile?.maxSizeFraction ?? 1.0,
|
|
540
|
+
affinities: def.profile?.affinities ?? ["circle", "square"],
|
|
541
|
+
category: "procedural",
|
|
542
|
+
heroCandidate: def.profile?.heroCandidate ?? false,
|
|
543
|
+
bestStyles: def.profile?.bestStyles ?? ["fill-and-stroke", "watercolor"],
|
|
544
|
+
};
|
|
545
|
+
customShapeNames.push(name);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const shapeNames = Object.keys(activeShapes ?? shapes);
|
|
524
549
|
const shapePalette = buildShapePalette(rng, shapeNames, archetype.name);
|
|
525
550
|
|
|
526
551
|
// ── 0d. Color grading — unified tone for the whole image ───────
|
|
@@ -873,6 +898,7 @@ export function renderHashArt(
|
|
|
873
898
|
rng,
|
|
874
899
|
lightAngle,
|
|
875
900
|
scaleFactor,
|
|
901
|
+
activeShapes,
|
|
876
902
|
});
|
|
877
903
|
|
|
878
904
|
heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
|
|
@@ -1118,6 +1144,7 @@ export function renderHashArt(
|
|
|
1118
1144
|
rng,
|
|
1119
1145
|
lightAngle,
|
|
1120
1146
|
scaleFactor,
|
|
1147
|
+
activeShapes,
|
|
1121
1148
|
};
|
|
1122
1149
|
|
|
1123
1150
|
if (shouldMirror) {
|
|
@@ -1152,6 +1179,7 @@ export function renderHashArt(
|
|
|
1152
1179
|
proportionType: "GOLDEN_RATIO",
|
|
1153
1180
|
renderStyle: "fill-only",
|
|
1154
1181
|
rng,
|
|
1182
|
+
activeShapes,
|
|
1155
1183
|
});
|
|
1156
1184
|
}
|
|
1157
1185
|
extrasSpent += glazePasses;
|
|
@@ -1186,6 +1214,7 @@ export function renderHashArt(
|
|
|
1186
1214
|
proportionType: "GOLDEN_RATIO",
|
|
1187
1215
|
renderStyle: finalRenderStyle,
|
|
1188
1216
|
rng,
|
|
1217
|
+
activeShapes,
|
|
1189
1218
|
});
|
|
1190
1219
|
shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
|
|
1191
1220
|
spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
|
|
@@ -1237,6 +1266,7 @@ export function renderHashArt(
|
|
|
1237
1266
|
proportionType: "GOLDEN_RATIO",
|
|
1238
1267
|
renderStyle: innerStyle,
|
|
1239
1268
|
rng,
|
|
1269
|
+
activeShapes,
|
|
1240
1270
|
},
|
|
1241
1271
|
);
|
|
1242
1272
|
extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
|
|
@@ -1297,6 +1327,7 @@ export function renderHashArt(
|
|
|
1297
1327
|
proportionType: "GOLDEN_RATIO",
|
|
1298
1328
|
renderStyle: memberStyle,
|
|
1299
1329
|
rng,
|
|
1330
|
+
activeShapes,
|
|
1300
1331
|
});
|
|
1301
1332
|
shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
1302
1333
|
spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
|
|
@@ -1348,6 +1379,7 @@ export function renderHashArt(
|
|
|
1348
1379
|
proportionType: "GOLDEN_RATIO",
|
|
1349
1380
|
renderStyle: finalRenderStyle,
|
|
1350
1381
|
rng,
|
|
1382
|
+
activeShapes,
|
|
1351
1383
|
});
|
|
1352
1384
|
shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
1353
1385
|
spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
|
|
@@ -1368,72 +1400,7 @@ export function renderHashArt(
|
|
|
1368
1400
|
if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; }
|
|
1369
1401
|
_mark("5_shape_layers");
|
|
1370
1402
|
|
|
1371
|
-
// ── 5g.
|
|
1372
|
-
// ~18% of images get 1-3 portal windows that paint over foreground
|
|
1373
|
-
// with a tinted background wash, creating a "peek through" effect.
|
|
1374
|
-
if (rng() < 0.18 && shapePositions.length > 3) {
|
|
1375
|
-
const portalCount = 1 + Math.floor(rng() * 2);
|
|
1376
|
-
for (let p = 0; p < portalCount; p++) {
|
|
1377
|
-
// Pick a position biased toward placed shapes
|
|
1378
|
-
const sourceShape = shapePositions[Math.floor(rng() * shapePositions.length)];
|
|
1379
|
-
const portalX = sourceShape.x + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1380
|
-
const portalY = sourceShape.y + (rng() - 0.5) * sourceShape.size * 0.5;
|
|
1381
|
-
const portalSize = adjustedMaxSize * (0.15 + rng() * 0.25);
|
|
1382
|
-
|
|
1383
|
-
// Pick a portal shape from the palette
|
|
1384
|
-
const portalShape = pickShapeFromPalette(shapePalette, rng, portalSize / adjustedMaxSize);
|
|
1385
|
-
const portalRotation = rng() * 360;
|
|
1386
|
-
const portalAlpha = 0.6 + rng() * 0.35;
|
|
1387
|
-
|
|
1388
|
-
ctx.save();
|
|
1389
|
-
ctx.translate(portalX, portalY);
|
|
1390
|
-
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1391
|
-
|
|
1392
|
-
// Step 1: Clip to the portal shape and fill with background wash
|
|
1393
|
-
ctx.beginPath();
|
|
1394
|
-
shapes[portalShape]?.(ctx, portalSize);
|
|
1395
|
-
ctx.clip();
|
|
1396
|
-
|
|
1397
|
-
// Fill the clipped region with a radial gradient from background colors
|
|
1398
|
-
const portalColor = jitterColorHSL(bgStart, rng, 15, 0.1);
|
|
1399
|
-
const portalGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, portalSize);
|
|
1400
|
-
portalGrad.addColorStop(0, portalColor);
|
|
1401
|
-
portalGrad.addColorStop(1, bgEnd);
|
|
1402
|
-
ctx.globalAlpha = portalAlpha;
|
|
1403
|
-
ctx.fillStyle = portalGrad;
|
|
1404
|
-
ctx.fillRect(-portalSize, -portalSize, portalSize * 2, portalSize * 2);
|
|
1405
|
-
|
|
1406
|
-
// Optional: subtle inner texture — a few tiny dots inside the portal
|
|
1407
|
-
if (rng() < 0.5) {
|
|
1408
|
-
const dotCount = 3 + Math.floor(rng() * 5);
|
|
1409
|
-
ctx.globalAlpha = portalAlpha * 0.3;
|
|
1410
|
-
ctx.fillStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.2);
|
|
1411
|
-
for (let d = 0; d < dotCount; d++) {
|
|
1412
|
-
const dx = (rng() - 0.5) * portalSize * 1.4;
|
|
1413
|
-
const dy = (rng() - 0.5) * portalSize * 1.4;
|
|
1414
|
-
const dr = (1 + rng() * 3) * scaleFactor;
|
|
1415
|
-
ctx.beginPath();
|
|
1416
|
-
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
|
|
1417
|
-
ctx.fill();
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
ctx.restore();
|
|
1422
|
-
|
|
1423
|
-
// Step 2: Draw a border ring around the portal (outside the clip)
|
|
1424
|
-
ctx.save();
|
|
1425
|
-
ctx.translate(portalX, portalY);
|
|
1426
|
-
ctx.rotate((portalRotation * Math.PI) / 180);
|
|
1427
|
-
ctx.globalAlpha = 0.15 + rng() * 0.2;
|
|
1428
|
-
ctx.strokeStyle = hexWithAlpha(pickHierarchyColor(colorHierarchy, rng), 0.5);
|
|
1429
|
-
ctx.lineWidth = (1.5 + rng() * 2.5) * scaleFactor;
|
|
1430
|
-
ctx.beginPath();
|
|
1431
|
-
shapes[portalShape]?.(ctx, portalSize * 1.06);
|
|
1432
|
-
ctx.stroke();
|
|
1433
|
-
ctx.restore();
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1403
|
+
// ── 5g. (Portal/cutout feature removed — replaced by custom shapes API) ──
|
|
1437
1404
|
|
|
1438
1405
|
_mark("5g_portals");
|
|
1439
1406
|
|
|
@@ -2044,4 +2011,8 @@ export function renderHashArt(
|
|
|
2044
2011
|
ctx.globalAlpha = 1;
|
|
2045
2012
|
_mark("11_signature");
|
|
2046
2013
|
|
|
2014
|
+
// Clean up custom shape profiles to avoid leaking into subsequent renders
|
|
2015
|
+
for (const name of customShapeNames) {
|
|
2016
|
+
delete SHAPE_PROFILES[name];
|
|
2017
|
+
}
|
|
2047
2018
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draw function signature for custom shapes.
|
|
3
|
+
* The function should build a canvas path (moveTo/lineTo/arc/etc.)
|
|
4
|
+
* centered at the origin. The pipeline handles translate, rotate,
|
|
5
|
+
* fill, and stroke — your function just defines the geometry.
|
|
6
|
+
*
|
|
7
|
+
* @param ctx - Canvas 2D rendering context (already translated to shape center)
|
|
8
|
+
* @param size - Bounding size in pixels
|
|
9
|
+
* @param rng - Deterministic RNG seeded from the git hash — use this instead of Math.random()
|
|
10
|
+
*/
|
|
11
|
+
export type CustomDrawFunction = (
|
|
12
|
+
ctx: CanvasRenderingContext2D,
|
|
13
|
+
size: number,
|
|
14
|
+
rng: () => number,
|
|
15
|
+
) => void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Definition for a user-provided custom shape.
|
|
19
|
+
*/
|
|
20
|
+
export interface CustomShapeDefinition {
|
|
21
|
+
/** The draw function that builds the shape path */
|
|
22
|
+
draw: CustomDrawFunction;
|
|
23
|
+
/**
|
|
24
|
+
* Optional shape profile for the affinity system.
|
|
25
|
+
* Controls how the shape is selected and composed with others.
|
|
26
|
+
* Sensible defaults are applied for any omitted fields.
|
|
27
|
+
*/
|
|
28
|
+
profile?: {
|
|
29
|
+
/** Visual quality tier: 1 = always good, 2 = usually good, 3 = situational (default: 2) */
|
|
30
|
+
tier?: 1 | 2 | 3;
|
|
31
|
+
/** Minimum size as fraction of maxShapeSize (default: 0.05) */
|
|
32
|
+
minSizeFraction?: number;
|
|
33
|
+
/** Maximum size as fraction of maxShapeSize (default: 1.0) */
|
|
34
|
+
maxSizeFraction?: number;
|
|
35
|
+
/** Names of shapes this composes well with (default: ["circle", "square"]) */
|
|
36
|
+
affinities?: string[];
|
|
37
|
+
/** Whether this shape works as a hero/focal element (default: false) */
|
|
38
|
+
heroCandidate?: boolean;
|
|
39
|
+
/** Best render styles (default: ["fill-and-stroke", "watercolor"]) */
|
|
40
|
+
bestStyles?: string[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
1
44
|
/**
|
|
2
45
|
* Configuration options for image generation.
|
|
3
46
|
*/
|
|
@@ -20,6 +63,13 @@ export interface GenerationConfig {
|
|
|
20
63
|
opacityReduction: number;
|
|
21
64
|
/** Base shapes per layer — defaults to gridSize² × 1.5 when 0 */
|
|
22
65
|
shapesPerLayer: number;
|
|
66
|
+
/**
|
|
67
|
+
* Custom shapes to include in the generation.
|
|
68
|
+
* Keys are shape names, values are CustomShapeDefinition objects.
|
|
69
|
+
* Custom shapes are merged with built-in shapes and participate
|
|
70
|
+
* in palette selection, affinity matching, and all render styles.
|
|
71
|
+
*/
|
|
72
|
+
customShapes?: Record<string, CustomShapeDefinition>;
|
|
23
73
|
/** Internal: collect per-phase timing data when set (not part of public API) */
|
|
24
74
|
_debugTiming?: { phases: Record<string, number>; shapeCount: number; extraCount: number };
|
|
25
75
|
}
|