@prosopo/puzzle-assets 0.1.3 → 0.2.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/.turbo/turbo-build$colon$cjs.log +23 -0
- package/.turbo/turbo-build$colon$tsc.log +11 -0
- package/.turbo/turbo-build.log +25 -0
- package/CHANGELOG.md +82 -0
- package/LICENSE +201 -0
- package/dist/browser.d.ts +3 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -4
- package/dist/browser.js.map +1 -1
- package/dist/cjs/compose.cjs +117 -1
- package/dist/cjs/contrast.cjs +142 -0
- package/dist/cjs/index.cjs +24 -6
- package/dist/cjs/notch.cjs +115 -93
- package/dist/cjs/palette.cjs +36 -3
- package/dist/compose.d.ts +4 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +117 -2
- package/dist/compose.js.map +1 -1
- package/dist/contrast.d.ts +9 -0
- package/dist/contrast.d.ts.map +1 -0
- package/dist/contrast.js +137 -0
- package/dist/contrast.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/notch.d.ts.map +1 -1
- package/dist/notch.js +115 -93
- package/dist/notch.js.map +1 -1
- package/dist/palette.d.ts +1 -0
- package/dist/palette.d.ts.map +1 -1
- package/dist/palette.js +36 -4
- package/dist/palette.js.map +1 -1
- package/dist/prng.d.ts.map +1 -1
- package/dist/prng.js.map +1 -1
- package/dist/tests/pieceReadability.unit.test.d.ts +2 -0
- package/dist/tests/pieceReadability.unit.test.d.ts.map +1 -0
- package/dist/tests/pieceReadability.unit.test.js +320 -0
- package/dist/tests/pieceReadability.unit.test.js.map +1 -0
- package/dist/tests/renderReplay.unit.test.d.ts +2 -0
- package/dist/tests/renderReplay.unit.test.d.ts.map +1 -0
- package/dist/tests/renderReplay.unit.test.js +77 -0
- package/dist/tests/renderReplay.unit.test.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +59 -60
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
//#region src/contrast.ts
|
|
2
|
+
var VISION_TYPES = [
|
|
3
|
+
"normal",
|
|
4
|
+
"protanopia",
|
|
5
|
+
"deuteranopia",
|
|
6
|
+
"tritanopia"
|
|
7
|
+
];
|
|
8
|
+
/** sRGB 8-bit channel to linear light in [0, 1]. */
|
|
9
|
+
var toLinear = (channel) => {
|
|
10
|
+
const c = channel / 255;
|
|
11
|
+
return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
|
|
12
|
+
};
|
|
13
|
+
var fromLinear = (value) => {
|
|
14
|
+
const c = value <= .0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - .055;
|
|
15
|
+
return Math.min(255, Math.max(0, Math.round(c * 255)));
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* WCAG relative luminance, in [0, 1].
|
|
19
|
+
*
|
|
20
|
+
* Linearised first: averaging raw sRGB bytes is the usual mistake and it
|
|
21
|
+
* overstates how light a saturated colour is, which would let this pass a pair
|
|
22
|
+
* that a dichromat sees as flat.
|
|
23
|
+
*/
|
|
24
|
+
var relativeLuminance = (colour) => .2126 * toLinear(colour.r) + .7152 * toLinear(colour.g) + .0722 * toLinear(colour.b);
|
|
25
|
+
/**
|
|
26
|
+
* WCAG contrast ratio, in [1, 21].
|
|
27
|
+
*
|
|
28
|
+
* 1 means two colours are the same brightness — indistinguishable without hue —
|
|
29
|
+
* and 21 is black against white.
|
|
30
|
+
*/
|
|
31
|
+
var contrastRatio = (a, b) => {
|
|
32
|
+
const la = relativeLuminance(a);
|
|
33
|
+
const lb = relativeLuminance(b);
|
|
34
|
+
const lighter = Math.max(la, lb);
|
|
35
|
+
const darker = Math.min(la, lb);
|
|
36
|
+
return (lighter + .05) / (darker + .05);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Brettel/Viénot-style dichromacy simulation, in linear RGB.
|
|
40
|
+
*
|
|
41
|
+
* These are the Viénot–Brettel–Mollon single-plane projections, which is the
|
|
42
|
+
* standard approximation and is more than accurate enough for the question being
|
|
43
|
+
* asked here: not "what exactly does this look like" but "has the difference
|
|
44
|
+
* between these two colours disappeared". The matrices are applied to linear
|
|
45
|
+
* light, not to sRGB bytes — applying them to gamma-encoded values is a common
|
|
46
|
+
* error that makes the result look more distinguishable than it is.
|
|
47
|
+
*/
|
|
48
|
+
var DICHROMACY_MATRICES = {
|
|
49
|
+
protanopia: [
|
|
50
|
+
.15286,
|
|
51
|
+
1.17579,
|
|
52
|
+
-.32865,
|
|
53
|
+
.11475,
|
|
54
|
+
.85426,
|
|
55
|
+
.03099,
|
|
56
|
+
-.00386,
|
|
57
|
+
-.00534,
|
|
58
|
+
1.0092
|
|
59
|
+
],
|
|
60
|
+
deuteranopia: [
|
|
61
|
+
.3677,
|
|
62
|
+
.86122,
|
|
63
|
+
-.22892,
|
|
64
|
+
.28039,
|
|
65
|
+
.67201,
|
|
66
|
+
.0476,
|
|
67
|
+
-.0118,
|
|
68
|
+
.04164,
|
|
69
|
+
.97016
|
|
70
|
+
],
|
|
71
|
+
tritanopia: [
|
|
72
|
+
1.25528,
|
|
73
|
+
-.07657,
|
|
74
|
+
-.17871,
|
|
75
|
+
-.07833,
|
|
76
|
+
.93087,
|
|
77
|
+
.14746,
|
|
78
|
+
.00491,
|
|
79
|
+
.2214,
|
|
80
|
+
.77309
|
|
81
|
+
]
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* What `colour` looks like to someone with `vision`.
|
|
85
|
+
*
|
|
86
|
+
* `normal` is the identity, so callers can loop over [`VISION_TYPES`] without a
|
|
87
|
+
* special case.
|
|
88
|
+
*/
|
|
89
|
+
var simulateVision = (colour, vision) => {
|
|
90
|
+
if (vision === "normal") return colour;
|
|
91
|
+
const m = DICHROMACY_MATRICES[vision];
|
|
92
|
+
const r = toLinear(colour.r);
|
|
93
|
+
const g = toLinear(colour.g);
|
|
94
|
+
const b = toLinear(colour.b);
|
|
95
|
+
return {
|
|
96
|
+
r: fromLinear(m[0] * r + m[1] * g + m[2] * b),
|
|
97
|
+
g: fromLinear(m[3] * r + m[4] * g + m[5] * b),
|
|
98
|
+
b: fromLinear(m[6] * r + m[7] * g + m[8] * b)
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* The lowest contrast ratio between `a` and `b` across normal vision and all
|
|
103
|
+
* three dichromacies.
|
|
104
|
+
*
|
|
105
|
+
* The minimum, not the average: a palette that reads well for most people and
|
|
106
|
+
* collapses for deuteranopes has failed, and averaging would hide exactly that.
|
|
107
|
+
*/
|
|
108
|
+
var worstCaseContrast = (a, b) => {
|
|
109
|
+
let worst = Number.POSITIVE_INFINITY;
|
|
110
|
+
for (const vision of VISION_TYPES) {
|
|
111
|
+
const ratio = contrastRatio(simulateVision(a, vision), simulateVision(b, vision));
|
|
112
|
+
if (ratio < worst) worst = ratio;
|
|
113
|
+
}
|
|
114
|
+
return worst;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The widest separation any pair in `colours` achieves in the worst case.
|
|
118
|
+
*
|
|
119
|
+
* This is the question a mesh background has to answer: not "is every pair
|
|
120
|
+
* distinguishable" — a gradient needs near-neighbours — but "does the field
|
|
121
|
+
* contain a visible light-to-dark range at all, for everyone". A palette whose
|
|
122
|
+
* best pair is flat is a flat picture, and a piece resting on it has nothing but
|
|
123
|
+
* its own edge treatment to separate it.
|
|
124
|
+
*/
|
|
125
|
+
var bestPairContrast = (colours) => {
|
|
126
|
+
let best = 1;
|
|
127
|
+
for (let i = 0; i < colours.length; i++) for (let j = i + 1; j < colours.length; j++) {
|
|
128
|
+
const a = colours[i];
|
|
129
|
+
const b = colours[j];
|
|
130
|
+
if (a === void 0 || b === void 0) continue;
|
|
131
|
+
const ratio = worstCaseContrast(a, b);
|
|
132
|
+
if (ratio > best) best = ratio;
|
|
133
|
+
}
|
|
134
|
+
return best;
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
exports.VISION_TYPES = VISION_TYPES;
|
|
138
|
+
exports.bestPairContrast = bestPairContrast;
|
|
139
|
+
exports.contrastRatio = contrastRatio;
|
|
140
|
+
exports.relativeLuminance = relativeLuminance;
|
|
141
|
+
exports.simulateVision = simulateVision;
|
|
142
|
+
exports.worstCaseContrast = worstCaseContrast;
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_contrast = require("./contrast.cjs");
|
|
3
|
+
const require_palette = require("./palette.cjs");
|
|
2
4
|
const require_background = require("./background.cjs");
|
|
3
5
|
const require_notch = require("./notch.cjs");
|
|
4
6
|
const require_compose = require("./compose.cjs");
|
|
@@ -35,14 +37,14 @@ var DEFAULT_RENDER_SETTINGS = {
|
|
|
35
37
|
* attacker diff the two composites and read both targets straight off. The
|
|
36
38
|
* caller's buffer is responsible for enforcing consume-once.
|
|
37
39
|
*/
|
|
38
|
-
var createBackground = (geometry = DEFAULT_GEOMETRY) => require_background.generateBackground(require_prng.createPrng(
|
|
40
|
+
var createBackground = (geometry = DEFAULT_GEOMETRY, seed = require_prng.createSeed()) => require_background.generateBackground(require_prng.createPrng(seed), geometry.width, geometry.height);
|
|
39
41
|
/**
|
|
40
42
|
* Cut a notch at `placement` and encode both halves.
|
|
41
43
|
*
|
|
42
44
|
* The background is consumed (mutated) by this call.
|
|
43
45
|
*/
|
|
44
|
-
var renderPuzzle = async (background, placement, geometry = DEFAULT_GEOMETRY, settings = DEFAULT_RENDER_SETTINGS) => {
|
|
45
|
-
const prng = require_prng.createPrng(
|
|
46
|
+
var renderPuzzle = async (background, placement, geometry = DEFAULT_GEOMETRY, settings = DEFAULT_RENDER_SETTINGS, seed = require_prng.createSeed()) => {
|
|
47
|
+
const prng = require_prng.createPrng(seed);
|
|
46
48
|
const pieceSize = geometry.pieceSize ?? geometry.notchSize;
|
|
47
49
|
require_decoys.paintDecoys(background, prng, settings.decoyCount, pieceSize, placement, settings.decoyEdgeDarkness, settings.decoyBodyBrightness, settings.decoyHoleDarken);
|
|
48
50
|
const shape = require_notch.createNotchShape(prng, pieceSize);
|
|
@@ -51,24 +53,40 @@ var renderPuzzle = async (background, placement, geometry = DEFAULT_GEOMETRY, se
|
|
|
51
53
|
return {
|
|
52
54
|
background: backgroundWebp,
|
|
53
55
|
piece: pieceWebp,
|
|
54
|
-
pieceSize
|
|
56
|
+
pieceSize,
|
|
57
|
+
seed
|
|
55
58
|
};
|
|
56
59
|
};
|
|
57
|
-
/**
|
|
58
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Convenience: generate, cut and encode in one call.
|
|
62
|
+
*
|
|
63
|
+
* Takes both seeds so a caller holding a persisted pair can reproduce a served
|
|
64
|
+
* challenge byte for byte — the background seed and the render seed are
|
|
65
|
+
* independent and both are required.
|
|
66
|
+
*/
|
|
67
|
+
var createPuzzle = async (placement, geometry = DEFAULT_GEOMETRY, settings = DEFAULT_RENDER_SETTINGS, backgroundSeed = require_prng.createSeed(), renderSeed = require_prng.createSeed()) => renderPuzzle(createBackground(geometry, backgroundSeed), placement, geometry, settings, renderSeed);
|
|
59
68
|
//#endregion
|
|
60
69
|
exports.DEFAULT_GEOMETRY = DEFAULT_GEOMETRY;
|
|
61
70
|
exports.DEFAULT_RENDER_SETTINGS = DEFAULT_RENDER_SETTINGS;
|
|
71
|
+
exports.MIN_PALETTE_CONTRAST = require_palette.MIN_PALETTE_CONTRAST;
|
|
62
72
|
exports.SEED_BYTES = require_prng.SEED_BYTES;
|
|
73
|
+
exports.VISION_TYPES = require_contrast.VISION_TYPES;
|
|
74
|
+
exports.bestPairContrast = require_contrast.bestPairContrast;
|
|
75
|
+
exports.contrastRatio = require_contrast.contrastRatio;
|
|
63
76
|
exports.createBackground = createBackground;
|
|
64
77
|
exports.createPrng = require_prng.createPrng;
|
|
65
78
|
exports.createPuzzle = createPuzzle;
|
|
66
79
|
exports.createSeed = require_prng.createSeed;
|
|
80
|
+
exports.drawPalette = require_palette.drawPalette;
|
|
67
81
|
exports.encodeBackground = require_encode.encodeBackground;
|
|
68
82
|
exports.encodePiece = require_encode.encodePiece;
|
|
69
83
|
exports.encodePng = require_encode.encodePng;
|
|
70
84
|
exports.generateBackground = require_background.generateBackground;
|
|
85
|
+
exports.hslToRgb = require_palette.hslToRgb;
|
|
71
86
|
exports.paintDecoyPiece = require_decoys.paintDecoyPiece;
|
|
72
87
|
exports.paintDecoys = require_decoys.paintDecoys;
|
|
88
|
+
exports.relativeLuminance = require_contrast.relativeLuminance;
|
|
73
89
|
exports.renderPuzzle = renderPuzzle;
|
|
90
|
+
exports.simulateVision = require_contrast.simulateVision;
|
|
74
91
|
exports.toDataUri = require_encode.toDataUri;
|
|
92
|
+
exports.worstCaseContrast = require_contrast.worstCaseContrast;
|
package/dist/cjs/notch.cjs
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
//#region src/notch.ts
|
|
2
|
-
|
|
2
|
+
/** Unit outward normal and unit along-edge direction for a side. */
|
|
3
|
+
var sideAxes = (side) => {
|
|
3
4
|
switch (side) {
|
|
4
|
-
case 0: return
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
case 0: return {
|
|
6
|
+
normal: [0, -1],
|
|
7
|
+
along: [1, 0]
|
|
8
|
+
};
|
|
9
|
+
case 1: return {
|
|
10
|
+
normal: [1, 0],
|
|
11
|
+
along: [0, 1]
|
|
12
|
+
};
|
|
13
|
+
case 2: return {
|
|
14
|
+
normal: [0, 1],
|
|
15
|
+
along: [-1, 0]
|
|
16
|
+
};
|
|
17
|
+
default: return {
|
|
18
|
+
normal: [-1, 0],
|
|
19
|
+
along: [0, -1]
|
|
20
|
+
};
|
|
8
21
|
}
|
|
9
22
|
};
|
|
10
23
|
var roundedBoxDistance = (px, py, half, radius) => {
|
|
@@ -12,100 +25,109 @@ var roundedBoxDistance = (px, py, half, radius) => {
|
|
|
12
25
|
const qy = Math.abs(py) - half + radius;
|
|
13
26
|
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - radius;
|
|
14
27
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const cy = size / 2;
|
|
42
|
-
return { distance(lx, ly) {
|
|
43
|
-
return Math.hypot(lx - cx, ly - cy) - radius;
|
|
44
|
-
} };
|
|
45
|
-
};
|
|
46
|
-
var createRoundedRectShape = (prng, size) => {
|
|
47
|
-
const aspect = prng.range(.5, 1);
|
|
48
|
-
const halfMax = size / 2 * prng.range(.82, .98);
|
|
49
|
-
const [halfW, halfH] = prng.next() < .5 ? [halfMax, halfMax * aspect] : [halfMax * aspect, halfMax];
|
|
50
|
-
const cornerRadius = Math.min(halfW, halfH) * prng.range(.08, .5);
|
|
51
|
-
const cx = size / 2;
|
|
52
|
-
const cy = size / 2;
|
|
53
|
-
return { distance(lx, ly) {
|
|
54
|
-
const px = lx - cx;
|
|
55
|
-
const py = ly - cy;
|
|
56
|
-
const qx = Math.abs(px) - halfW + cornerRadius;
|
|
57
|
-
const qy = Math.abs(py) - halfH + cornerRadius;
|
|
58
|
-
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - cornerRadius;
|
|
59
|
-
} };
|
|
60
|
-
};
|
|
61
|
-
var createPolygonShape = (prng, size) => {
|
|
62
|
-
const n = prng.int(3, 8);
|
|
63
|
-
const inradius = size / 2 * prng.range(.85, .98) * Math.cos(Math.PI / n);
|
|
64
|
-
const rotation = prng.next() * Math.PI * 2;
|
|
65
|
-
const normals = [];
|
|
66
|
-
for (let i = 0; i < n; i++) {
|
|
67
|
-
const angle = 2 * Math.PI * i / n - Math.PI / 2 + rotation;
|
|
68
|
-
normals.push([Math.cos(angle), Math.sin(angle)]);
|
|
28
|
+
/**
|
|
29
|
+
* How many of the four edges carry a knob, and which.
|
|
30
|
+
*
|
|
31
|
+
* At least two, so the outline cannot come out as a plain rounded square and
|
|
32
|
+
* fail to read as a jigsaw piece at all. At most three: four knobs on a small
|
|
33
|
+
* square starts to read as a cross or a flower, which is the problem being
|
|
34
|
+
* fixed rather than a jigsaw piece.
|
|
35
|
+
*
|
|
36
|
+
* At least one of them is a tab. A piece with only blanks is a square with bites
|
|
37
|
+
* taken out of it — recognisable as damaged, not as interlocking.
|
|
38
|
+
*/
|
|
39
|
+
var chooseEdges = (prng) => {
|
|
40
|
+
const count = prng.int(2, 3);
|
|
41
|
+
const sides = [
|
|
42
|
+
0,
|
|
43
|
+
1,
|
|
44
|
+
2,
|
|
45
|
+
3
|
|
46
|
+
];
|
|
47
|
+
for (let i = sides.length - 1; i > 0; i--) {
|
|
48
|
+
const j = prng.int(0, i);
|
|
49
|
+
const a = sides[i];
|
|
50
|
+
const b = sides[j];
|
|
51
|
+
if (a === void 0 || b === void 0) continue;
|
|
52
|
+
sides[i] = b;
|
|
53
|
+
sides[j] = a;
|
|
69
54
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
var createFlowerShape = (prng, size) => {
|
|
84
|
-
const petals = prng.int(4, 8);
|
|
85
|
-
const rAvg = size / 2 * prng.range(.72, .88);
|
|
86
|
-
const rAmp = rAvg * prng.range(.1, .22);
|
|
87
|
-
const phase = prng.next() * Math.PI * 2;
|
|
88
|
-
const cx = size / 2;
|
|
89
|
-
const cy = size / 2;
|
|
90
|
-
return { distance(lx, ly) {
|
|
91
|
-
const px = lx - cx;
|
|
92
|
-
const py = ly - cy;
|
|
93
|
-
return Math.hypot(px, py) - (rAvg + rAmp * Math.cos(petals * Math.atan2(py, px) + phase));
|
|
94
|
-
} };
|
|
55
|
+
const edges = [
|
|
56
|
+
"flat",
|
|
57
|
+
"flat",
|
|
58
|
+
"flat",
|
|
59
|
+
"flat"
|
|
60
|
+
];
|
|
61
|
+
const chosen = sides.slice(0, count);
|
|
62
|
+
for (const side of chosen) edges[side] = prng.next() < .55 ? "tab" : "blank";
|
|
63
|
+
if (!chosen.some((side) => edges[side] === "tab")) {
|
|
64
|
+
const first = chosen[0];
|
|
65
|
+
if (first !== void 0) edges[first] = "tab";
|
|
66
|
+
}
|
|
67
|
+
return edges;
|
|
95
68
|
};
|
|
96
69
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
70
|
+
* Margin left between the silhouette and the edge of its bounding box, in px.
|
|
71
|
+
*
|
|
72
|
+
* One pixel for the anti-aliasing band plus a little slack. The box is the
|
|
73
|
+
* piece's own image and the caller's placement maths assumes the shape is inside
|
|
74
|
+
* it: a tab reaching the border comes out clipped flat, which looks like a
|
|
75
|
+
* manufacturing defect rather than a jigsaw tab.
|
|
76
|
+
*/
|
|
77
|
+
var BOX_MARGIN = 1.5;
|
|
78
|
+
/**
|
|
79
|
+
* Build one jigsaw piece.
|
|
80
|
+
*
|
|
81
|
+
* The body is a rounded square; each chosen edge gets a tab or a blank, and the
|
|
82
|
+
* whole silhouette is scaled down at the end if a tab would otherwise reach past
|
|
83
|
+
* the bounding box the caller places.
|
|
100
84
|
*/
|
|
101
85
|
var createNotchShape = (prng, size) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
86
|
+
const knobRadius = size * prng.range(.13, .17);
|
|
87
|
+
const nominalHalf = size / 2 - knobRadius * 1.05;
|
|
88
|
+
const edges = chooseEdges(prng);
|
|
89
|
+
const knobs = [];
|
|
90
|
+
let cornerRadius = nominalHalf * prng.range(.14, .3);
|
|
91
|
+
let bodyHalf = nominalHalf;
|
|
92
|
+
for (let side = 0; side < 4; side = side + 1) {
|
|
93
|
+
const kind = edges[side];
|
|
94
|
+
if (kind === void 0 || kind === "flat") continue;
|
|
95
|
+
const { normal, along } = sideAxes(side);
|
|
96
|
+
const radius = knobRadius * prng.range(.88, 1.12);
|
|
97
|
+
const offset = kind === "tab" ? radius * .52 : -radius * .42;
|
|
98
|
+
const slideLimit = Math.max(0, bodyHalf - radius - cornerRadius * .6);
|
|
99
|
+
const slide = prng.range(-slideLimit, slideLimit) * .6;
|
|
100
|
+
knobs.push({
|
|
101
|
+
kind,
|
|
102
|
+
cx: normal[0] * (bodyHalf + offset) + along[0] * slide,
|
|
103
|
+
cy: normal[1] * (bodyHalf + offset) + along[1] * slide,
|
|
104
|
+
radius
|
|
105
|
+
});
|
|
108
106
|
}
|
|
107
|
+
let maxExtent = bodyHalf;
|
|
108
|
+
for (const knob of knobs) {
|
|
109
|
+
if (knob.kind !== "tab") continue;
|
|
110
|
+
maxExtent = Math.max(maxExtent, Math.abs(knob.cx) + knob.radius, Math.abs(knob.cy) + knob.radius);
|
|
111
|
+
}
|
|
112
|
+
const limit = size / 2 - BOX_MARGIN;
|
|
113
|
+
if (maxExtent > limit) {
|
|
114
|
+
const scale = limit / maxExtent;
|
|
115
|
+
bodyHalf *= scale;
|
|
116
|
+
cornerRadius *= scale;
|
|
117
|
+
for (const knob of knobs) {
|
|
118
|
+
knob.cx *= scale;
|
|
119
|
+
knob.cy *= scale;
|
|
120
|
+
knob.radius *= scale;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { distance(lx, ly) {
|
|
124
|
+
const px = lx - size / 2;
|
|
125
|
+
const py = ly - size / 2;
|
|
126
|
+
let d = roundedBoxDistance(px, py, bodyHalf, cornerRadius);
|
|
127
|
+
for (const knob of knobs) if (knob.kind === "tab") d = Math.min(d, Math.hypot(px - knob.cx, py - knob.cy) - knob.radius);
|
|
128
|
+
for (const knob of knobs) if (knob.kind === "blank") d = Math.max(d, -(Math.hypot(px - knob.cx, py - knob.cy) - knob.radius));
|
|
129
|
+
return d;
|
|
130
|
+
} };
|
|
109
131
|
};
|
|
110
132
|
/**
|
|
111
133
|
* Anti-aliased coverage in [0, 1] for a signed distance, using a one-pixel
|
package/dist/cjs/palette.cjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const require_contrast = require("./contrast.cjs");
|
|
1
2
|
//#region src/palette.ts
|
|
2
3
|
var PALETTES = [
|
|
3
4
|
{
|
|
@@ -75,20 +76,52 @@ var hslToRgb = (h, s, l) => {
|
|
|
75
76
|
* colour and the mesh reads as one flat field with a slight gradient, which is
|
|
76
77
|
* the difference between "designed" and "beige".
|
|
77
78
|
*/
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The worst-case contrast ratio the palette's widest pair must reach, across
|
|
81
|
+
* normal vision and all three dichromacies.
|
|
82
|
+
*
|
|
83
|
+
* This is a floor on the *range* a background spans, not on every pair — a mesh
|
|
84
|
+
* gradient needs near-neighbours, and demanding contrast between them would
|
|
85
|
+
* produce stripes. What it rules out is a field that is uniformly one brightness,
|
|
86
|
+
* where hue is the only thing separating anything and a dichromat sees a flat
|
|
87
|
+
* rectangle.
|
|
88
|
+
*
|
|
89
|
+
* 1.6 is chosen to be achievable rather than aspirational: the palettes are
|
|
90
|
+
* analogous-hue families on purpose, and pushing this towards WCAG's 3:1 would
|
|
91
|
+
* reject most of them and force the generator into the widened-lightness
|
|
92
|
+
* fallback on nearly every draw, which would make every background look the
|
|
93
|
+
* same. Separation of the piece from what it rests on is guaranteed by its
|
|
94
|
+
* luminance-stepped rim in `compose.ts`, not by this; this stops the background
|
|
95
|
+
* itself from having no structure at all.
|
|
96
|
+
*/
|
|
97
|
+
var MIN_PALETTE_CONTRAST = 1.6;
|
|
98
|
+
/** How many times to redraw before widening the lightness band instead. */
|
|
99
|
+
var MAX_PALETTE_ATTEMPTS = 6;
|
|
100
|
+
var drawOnce = (prng, count, spec, lightnessBoost) => {
|
|
80
101
|
const baseHue = spec.hue + prng.range(-20, 20);
|
|
81
102
|
const direction = prng.next() < .5 ? -1 : 1;
|
|
103
|
+
const low = Math.max(.08, spec.lightness[0] - lightnessBoost);
|
|
104
|
+
const high = Math.min(.92, spec.lightness[1] + lightnessBoost);
|
|
82
105
|
const colours = [];
|
|
83
106
|
for (let i = 0; i < count; i++) {
|
|
84
107
|
const hue = baseHue + direction * ((count === 1 ? .5 : i / (count - 1)) - .5) * 2 * spec.spread + prng.range(-6, 6);
|
|
85
108
|
const saturation = prng.range(spec.saturation[0], spec.saturation[1]);
|
|
86
109
|
const pole = i % 2 === 0 ? 0 : 1;
|
|
87
|
-
const lightness =
|
|
110
|
+
const lightness = (pole === 0 ? low : high) + prng.range(-.04, .04) * (pole === 0 ? 1 : -1);
|
|
88
111
|
colours.push(hslToRgb(hue, saturation, lightness));
|
|
89
112
|
}
|
|
90
113
|
return colours;
|
|
91
114
|
};
|
|
115
|
+
var drawPalette = (prng, count) => {
|
|
116
|
+
const spec = prng.pick(PALETTES);
|
|
117
|
+
let last = drawOnce(prng, count, spec, 0);
|
|
118
|
+
for (let attempt = 1; attempt <= MAX_PALETTE_ATTEMPTS; attempt++) {
|
|
119
|
+
if (require_contrast.bestPairContrast(last) >= 1.6) return last;
|
|
120
|
+
last = drawOnce(prng, count, spec, attempt * .07);
|
|
121
|
+
}
|
|
122
|
+
return last;
|
|
123
|
+
};
|
|
92
124
|
//#endregion
|
|
125
|
+
exports.MIN_PALETTE_CONTRAST = MIN_PALETTE_CONTRAST;
|
|
93
126
|
exports.drawPalette = drawPalette;
|
|
94
127
|
exports.hslToRgb = hslToRgb;
|
package/dist/compose.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type NotchShape } from "./notch.js";
|
|
2
2
|
import type { Prng } from "./prng.js";
|
|
3
3
|
import type { NotchPlacement, RgbaImage } from "./types.js";
|
|
4
|
+
export declare const pieceRimGeometry: (size: number) => {
|
|
5
|
+
plateau: number;
|
|
6
|
+
width: number;
|
|
7
|
+
};
|
|
4
8
|
export interface CutResult {
|
|
5
9
|
background: RgbaImage;
|
|
6
10
|
piece: RgbaImage;
|
package/dist/compose.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,KAAK,UAAU,EAAwB,MAAM,YAAY,CAAC;AACnE,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsE5D,eAAO,MAAM,gBAAgB,SACtB,MAAM,KACV;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAalC,CAAC;AAmIF,MAAM,WAAW,SAAS;IAEzB,UAAU,EAAE,SAAS,CAAC;IAEtB,KAAK,EAAE,SAAS,CAAC;CACjB;AAQD,eAAO,MAAM,QAAQ,SACd,IAAI,cACE,SAAS,SACd,UAAU,QACX,MAAM,aACD,cAAc,cACb,MAAM,KAChB,SAyHF,CAAC"}
|
package/dist/compose.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relativeLuminance } from "./contrast.js";
|
|
1
2
|
import { coverageFromDistance } from "./notch.js";
|
|
2
3
|
//#region src/compose.ts
|
|
3
4
|
/**
|
|
@@ -39,6 +40,74 @@ var HOLE_INNER_SHADOW = .45;
|
|
|
39
40
|
* against the darkened cutout it drops into.
|
|
40
41
|
*/
|
|
41
42
|
var PIECE_EDGE_LIGHT = 8;
|
|
43
|
+
/**
|
|
44
|
+
* The piece's outline, measured inwards from the silhouette, as a fraction of
|
|
45
|
+
* the piece's size.
|
|
46
|
+
*
|
|
47
|
+
* Full strength out to `PLATEAU`, then tapering to nothing by `WIDTH`. A rim that
|
|
48
|
+
* tapers from the very first pixel has its strongest values exactly where
|
|
49
|
+
* anti-aliasing makes the pixels partly transparent, so the part of it that is
|
|
50
|
+
* fully opaque is only ever half strength — the cue measures far weaker than the
|
|
51
|
+
* number configuring it suggests. The plateau puts the full step on pixels that
|
|
52
|
+
* are actually opaque, and the taper then stops it ending in a hard line.
|
|
53
|
+
*
|
|
54
|
+
* PROPORTIONAL, NOT A FIXED PIXEL COUNT. A 4px band is a tidy outline on a
|
|
55
|
+
* 120px piece and most of the surface area of a 44px one — the default
|
|
56
|
+
* `notchSize` — which made the whole piece read as a pale blob rather than as a
|
|
57
|
+
* piece with a lit edge. Caught by rendering it and looking; no measurement of
|
|
58
|
+
* rim-versus-core contrast can see it, because by that measure a piece that is
|
|
59
|
+
* entirely rim scores perfectly.
|
|
60
|
+
*/
|
|
61
|
+
var PIECE_RIM_PLATEAU_RATIO = .03;
|
|
62
|
+
var PIECE_RIM_WIDTH_RATIO = .065;
|
|
63
|
+
/** Floors, so the rim does not vanish on a very small piece. */
|
|
64
|
+
var PIECE_RIM_MIN_PLATEAU = 1;
|
|
65
|
+
var PIECE_RIM_MIN_WIDTH = 2.2;
|
|
66
|
+
/** Rim geometry for a piece of `size` px. Exported so tests measure the band the renderer actually drew. */
|
|
67
|
+
var pieceRimGeometry = (size) => {
|
|
68
|
+
const plateau = Math.max(PIECE_RIM_MIN_PLATEAU, size * PIECE_RIM_PLATEAU_RATIO);
|
|
69
|
+
return {
|
|
70
|
+
plateau,
|
|
71
|
+
width: Math.max(PIECE_RIM_MIN_WIDTH, size * PIECE_RIM_WIDTH_RATIO, plateau + 1)
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The WCAG contrast ratio the rim aims to reach against the piece's own body.
|
|
76
|
+
*
|
|
77
|
+
* This is the separation cue, and it is deliberately not a hue. The piece is cut
|
|
78
|
+
* from the same mesh the background is drawn from, so on an unlucky palette it
|
|
79
|
+
* was the same colour as whatever it was resting on and the only thing dividing
|
|
80
|
+
* them was a CSS drop-shadow. A luminance step survives what hue does not:
|
|
81
|
+
* greyscale, a monochrome display, every dichromacy, and a phone screen in
|
|
82
|
+
* sunlight.
|
|
83
|
+
*
|
|
84
|
+
* Expressed as a ratio rather than as a fixed byte offset because a fixed offset
|
|
85
|
+
* is not a fixed cue: 40 units out of 255 is obvious on a mid grey and almost
|
|
86
|
+
* invisible near white, where the same step is a much smaller fraction of the
|
|
87
|
+
* luminance. The required offset is solved for per piece from the luminance it
|
|
88
|
+
* actually has.
|
|
89
|
+
*
|
|
90
|
+
* 1.5 is comfortably visible without reading as a drawn border. The tests assert
|
|
91
|
+
* a lower bound than this, because the taper and the per-pixel noise cost a
|
|
92
|
+
* little of it.
|
|
93
|
+
*/
|
|
94
|
+
var PIECE_RIM_TARGET_CONTRAST = 1.5;
|
|
95
|
+
/**
|
|
96
|
+
* Cap on the rim offset, in 0-255 units. Past roughly this the rim stops reading
|
|
97
|
+
* as a lit or shaded edge and starts reading as a stroke drawn around a sticker,
|
|
98
|
+
* which is the look this is trying to avoid.
|
|
99
|
+
*/
|
|
100
|
+
var PIECE_RIM_MAX_STEP = 72;
|
|
101
|
+
/**
|
|
102
|
+
* Above this relative luminance the piece is treated as light, and gets a dark
|
|
103
|
+
* rim rather than a light one.
|
|
104
|
+
*
|
|
105
|
+
* Picking the direction from the piece's own mean rather than fixing it is the
|
|
106
|
+
* whole point: a fixed light rim vanishes on a pale background and a fixed dark
|
|
107
|
+
* one vanishes on a dark background, which is how a cue that is present in the
|
|
108
|
+
* code ends up absent on screen.
|
|
109
|
+
*/
|
|
110
|
+
var PIECE_RIM_DIRECTION_THRESHOLD = .4;
|
|
42
111
|
var clamp255 = (v) => v < 0 ? 0 : v > 255 ? 255 : v;
|
|
43
112
|
/**
|
|
44
113
|
* Sample the background at `(x, y)`. Returns `null` when the coordinate
|
|
@@ -56,6 +125,49 @@ var sampleBackground = (image, x, y) => {
|
|
|
56
125
|
];
|
|
57
126
|
};
|
|
58
127
|
/**
|
|
128
|
+
* Rim strength in [0, 1] for a signed distance: 1 on the plateau, tapering to 0
|
|
129
|
+
* at the full width inside the silhouette, 0 beyond it.
|
|
130
|
+
*/
|
|
131
|
+
var rimProfile = (d, plateau, width) => {
|
|
132
|
+
if (d <= -width) return 0;
|
|
133
|
+
if (d >= -plateau) return 1;
|
|
134
|
+
return (d + width) / (width - plateau);
|
|
135
|
+
};
|
|
136
|
+
/** Approximate sRGB byte for a relative luminance. */
|
|
137
|
+
var byteForLuminance = (luminance) => 255 * Math.max(0, Math.min(1, luminance)) ** (1 / 2.2);
|
|
138
|
+
/**
|
|
139
|
+
* The signed offset, in 0-255 units, to apply at the piece's rim.
|
|
140
|
+
*
|
|
141
|
+
* Averages the luminance of the pixels the piece will be cut from, picks the
|
|
142
|
+
* direction that steps *away* from it, then solves for the offset that reaches
|
|
143
|
+
* [`PIECE_RIM_TARGET_CONTRAST`] against it. A dark piece gets a light rim and a
|
|
144
|
+
* light piece a dark one, so the cue is present whatever the palette produced —
|
|
145
|
+
* which neither a fixed direction nor a fixed magnitude can promise.
|
|
146
|
+
*/
|
|
147
|
+
var pieceRimDelta = (background, shape, size, left, top) => {
|
|
148
|
+
let total = 0;
|
|
149
|
+
let counted = 0;
|
|
150
|
+
for (let ly = 0; ly < size; ly += 4) for (let lx = 0; lx < size; lx += 4) {
|
|
151
|
+
if (coverageFromDistance(shape.distance(lx, ly)) <= 0) continue;
|
|
152
|
+
const sample = sampleBackground(background, left + lx, top + ly);
|
|
153
|
+
if (sample === null) continue;
|
|
154
|
+
total += relativeLuminance({
|
|
155
|
+
r: sample[0],
|
|
156
|
+
g: sample[1],
|
|
157
|
+
b: sample[2]
|
|
158
|
+
});
|
|
159
|
+
counted++;
|
|
160
|
+
}
|
|
161
|
+
if (counted === 0) return PIECE_RIM_MAX_STEP;
|
|
162
|
+
const pieceLuminance = total / counted;
|
|
163
|
+
const lighten = pieceLuminance <= PIECE_RIM_DIRECTION_THRESHOLD;
|
|
164
|
+
const delta = byteForLuminance(lighten ? PIECE_RIM_TARGET_CONTRAST * (pieceLuminance + .05) - .05 : (pieceLuminance + .05) / PIECE_RIM_TARGET_CONTRAST - .05) - byteForLuminance(pieceLuminance);
|
|
165
|
+
const capped = Math.max(-PIECE_RIM_MAX_STEP, Math.min(PIECE_RIM_MAX_STEP, delta));
|
|
166
|
+
const floor = 24;
|
|
167
|
+
if (Math.abs(capped) < floor) return lighten ? floor : -24;
|
|
168
|
+
return capped;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
59
171
|
* Cut the notch out of `background` and produce the matching piece.
|
|
60
172
|
*
|
|
61
173
|
* `background` is mutated in place — it is a freshly generated, single-use
|
|
@@ -67,6 +179,8 @@ var cutNotch = (prng, background, shape, size, placement, holeDarken) => {
|
|
|
67
179
|
const top = Math.round(placement.targetY - half);
|
|
68
180
|
const pieceData = Buffer.alloc(size * size * 4);
|
|
69
181
|
const brightness = prng.range(PIECE_BRIGHTNESS_RANGE[0], PIECE_BRIGHTNESS_RANGE[1]);
|
|
182
|
+
const rimStep = pieceRimDelta(background, shape, size, left, top);
|
|
183
|
+
const rim = pieceRimGeometry(size);
|
|
70
184
|
for (let ly = 0; ly < size; ly++) for (let lx = 0; lx < size; lx++) {
|
|
71
185
|
const d = shape.distance(lx, ly);
|
|
72
186
|
const coverage = coverageFromDistance(d);
|
|
@@ -82,8 +196,9 @@ var cutNotch = (prng, background, shape, size, placement, holeDarken) => {
|
|
|
82
196
|
}
|
|
83
197
|
const [r, g, b] = sample;
|
|
84
198
|
const edgeLight = d > -2 ? PIECE_EDGE_LIGHT * (1 + d / 2) : 0;
|
|
199
|
+
const rimDelta = rimStep * rimProfile(d, rim.plateau, rim.width);
|
|
85
200
|
const noise = (prng.next() - .5) * 2 * PIECE_NOISE;
|
|
86
|
-
const delta = brightness + edgeLight + noise;
|
|
201
|
+
const delta = brightness + edgeLight + rimDelta + noise;
|
|
87
202
|
pieceData[pi] = clamp255(Math.round(r + delta));
|
|
88
203
|
pieceData[pi + 1] = clamp255(Math.round(g + delta));
|
|
89
204
|
pieceData[pi + 2] = clamp255(Math.round(b + delta));
|
|
@@ -123,4 +238,4 @@ var cutNotch = (prng, background, shape, size, placement, holeDarken) => {
|
|
|
123
238
|
};
|
|
124
239
|
};
|
|
125
240
|
//#endregion
|
|
126
|
-
export { cutNotch };
|
|
241
|
+
export { cutNotch, pieceRimGeometry };
|