@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.
Files changed (46) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +23 -0
  2. package/.turbo/turbo-build$colon$tsc.log +11 -0
  3. package/.turbo/turbo-build.log +25 -0
  4. package/CHANGELOG.md +82 -0
  5. package/LICENSE +201 -0
  6. package/dist/browser.d.ts +3 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -4
  9. package/dist/browser.js.map +1 -1
  10. package/dist/cjs/compose.cjs +117 -1
  11. package/dist/cjs/contrast.cjs +142 -0
  12. package/dist/cjs/index.cjs +24 -6
  13. package/dist/cjs/notch.cjs +115 -93
  14. package/dist/cjs/palette.cjs +36 -3
  15. package/dist/compose.d.ts +4 -0
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +117 -2
  18. package/dist/compose.js.map +1 -1
  19. package/dist/contrast.d.ts +9 -0
  20. package/dist/contrast.d.ts.map +1 -0
  21. package/dist/contrast.js +137 -0
  22. package/dist/contrast.js.map +1 -0
  23. package/dist/index.d.ts +5 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -7
  26. package/dist/index.js.map +1 -1
  27. package/dist/notch.d.ts.map +1 -1
  28. package/dist/notch.js +115 -93
  29. package/dist/notch.js.map +1 -1
  30. package/dist/palette.d.ts +1 -0
  31. package/dist/palette.d.ts.map +1 -1
  32. package/dist/palette.js +36 -4
  33. package/dist/palette.js.map +1 -1
  34. package/dist/prng.d.ts.map +1 -1
  35. package/dist/prng.js.map +1 -1
  36. package/dist/tests/pieceReadability.unit.test.d.ts +2 -0
  37. package/dist/tests/pieceReadability.unit.test.d.ts.map +1 -0
  38. package/dist/tests/pieceReadability.unit.test.js +320 -0
  39. package/dist/tests/pieceReadability.unit.test.js.map +1 -0
  40. package/dist/tests/renderReplay.unit.test.d.ts +2 -0
  41. package/dist/tests/renderReplay.unit.test.d.ts.map +1 -0
  42. package/dist/tests/renderReplay.unit.test.js +77 -0
  43. package/dist/tests/renderReplay.unit.test.js.map +1 -0
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. 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;
@@ -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(require_prng.createSeed()), geometry.width, geometry.height);
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(require_prng.createSeed());
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
- /** Convenience: generate, cut and encode in one call. */
58
- var createPuzzle = async (placement, geometry = DEFAULT_GEOMETRY, settings = DEFAULT_RENDER_SETTINGS) => renderPuzzle(createBackground(geometry), placement, geometry, settings);
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;
@@ -1,10 +1,23 @@
1
1
  //#region src/notch.ts
2
- var sideOffset = (side, half, distance) => {
2
+ /** Unit outward normal and unit along-edge direction for a side. */
3
+ var sideAxes = (side) => {
3
4
  switch (side) {
4
- case 0: return [0, -half - distance];
5
- case 1: return [half + distance, 0];
6
- case 2: return [0, half + distance];
7
- default: return [-half - distance, 0];
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
- var createJigsawShape = (prng, size) => {
16
- const knobRadius = size * prng.range(.15, .19);
17
- const bodyHalf = size / 2 - knobRadius * .9;
18
- const cornerRadius = bodyHalf * prng.range(.28, .42);
19
- const knobSide = prng.int(0, 3);
20
- const [knobX, knobY] = sideOffset(knobSide, bodyHalf, -knobRadius * .45);
21
- const hasBite = prng.next() < .55;
22
- const biteSide = (knobSide + prng.int(1, 3)) % 4;
23
- const biteRadius = size * prng.range(.13, .17);
24
- const [biteX, biteY] = sideOffset(biteSide, bodyHalf, -biteRadius * .55);
25
- return { distance(lx, ly) {
26
- const px = lx - size / 2;
27
- const py = ly - size / 2;
28
- const body = roundedBoxDistance(px, py, bodyHalf, cornerRadius);
29
- const knob = Math.hypot(px - knobX, py - knobY) - knobRadius;
30
- let d = Math.min(body, knob);
31
- if (hasBite) {
32
- const bite = Math.hypot(px - biteX, py - biteY) - biteRadius;
33
- d = Math.max(d, -bite);
34
- }
35
- return d;
36
- } };
37
- };
38
- var createCircleShape = (prng, size) => {
39
- const radius = size / 2 * prng.range(.85, .98);
40
- const cx = size / 2;
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 cx = size / 2;
71
- const cy = size / 2;
72
- return { distance(lx, ly) {
73
- const px = lx - cx;
74
- const py = ly - cy;
75
- let maxD = Number.NEGATIVE_INFINITY;
76
- for (const [nx, ny] of normals) {
77
- const d = px * nx + py * ny - inradius;
78
- if (d > maxD) maxD = d;
79
- }
80
- return maxD;
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
- * Pick a random shape family per call. The real piece and its hole are cut
98
- * from the same call so they match; decoys each call independently so they
99
- * form a mixed field of silhouettes on the frame.
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
- switch (prng.int(0, 4)) {
103
- case 0: return createJigsawShape(prng, size);
104
- case 1: return createCircleShape(prng, size);
105
- case 2: return createRoundedRectShape(prng, size);
106
- case 3: return createPolygonShape(prng, size);
107
- default: return createFlowerShape(prng, size);
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
@@ -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
- var drawPalette = (prng, count) => {
79
- const spec = prng.pick(PALETTES);
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 = spec.lightness[pole] + prng.range(-.04, .04) * (pole === 0 ? 1 : -1);
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;
@@ -1 +1 @@
1
- {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../src/compose.ts"],"names":[],"mappings":"AAcA,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;AAiE5D,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,SA4GF,CAAC"}
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 };