@prosopo/puzzle-assets 0.1.3 → 0.1.4

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/src/decoys.ts ADDED
@@ -0,0 +1,191 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { createNotchShape } from "./notch.js";
16
+ import { coverageFromDistance } from "./notch.js";
17
+ import type { Prng } from "./prng.js";
18
+ import type { NotchPlacement, RgbaImage } from "./types.js";
19
+
20
+ /**
21
+ * Decoys are rendered as shallow indented jigsaw silhouettes: a darker
22
+ * edge with a barely-lifted interior. Edge shading direction (darker at
23
+ * the rim) mirrors the real cutout's inner shadow, so at a glance decoys
24
+ * read as similar shapes and it's not immediately obvious which one is
25
+ * the real target. Edge/body amplitudes come from PuzzleRenderSettings
26
+ * so operators can tune the confusion level per-client from the portal.
27
+ *
28
+ * No drop shadow — a shadow ring picked up WebP artefacts and read as a
29
+ * visible halo around each decoy.
30
+ */
31
+
32
+ /**
33
+ * Minimum centre-to-centre distance from the real notch, in notch-sizes.
34
+ * Zero: decoys may sit right up to (and over) the target region. The cut
35
+ * itself runs after decoy paint, so the darkened hole still wins visually.
36
+ */
37
+ const NOTCH_CLEARANCE = 0;
38
+
39
+ /** Placement retries per decoy before giving up on that slot. */
40
+ const PLACEMENT_ATTEMPTS = 8;
41
+
42
+ const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
43
+
44
+ const distanceSquared = (
45
+ cx: number,
46
+ cy: number,
47
+ other: { cx: number; cy: number },
48
+ ): number => {
49
+ const dx = cx - other.cx;
50
+ const dy = cy - other.cy;
51
+ return dx * dx + dy * dy;
52
+ };
53
+
54
+ /**
55
+ * Depth cue on decoys, mirroring `HOLE_INNER_SHADOW` in `compose.ts`.
56
+ * Smaller so decoys read as shallower cuts than the real hole.
57
+ */
58
+ const DECOY_INNER_SHADOW = 0.35;
59
+
60
+ /**
61
+ * Composite a jigsaw-shaped decoration onto `background`, centred on
62
+ * `(cx, cy)`. Mutates `background`. Runs the same signed-distance sampling
63
+ * as the real cut, so the silhouette reads as the same family of shapes.
64
+ */
65
+ export const paintDecoyPiece = (
66
+ background: RgbaImage,
67
+ prng: Prng,
68
+ size: number,
69
+ cx: number,
70
+ cy: number,
71
+ edgeDarkness: number,
72
+ bodyBrightness: number,
73
+ holeDarken: number,
74
+ ): void => {
75
+ const shape = createNotchShape(prng, size);
76
+ const half = size / 2;
77
+ const left = Math.round(cx - half);
78
+ const top = Math.round(cy - half);
79
+
80
+ // Body only. No drop shadow — the shadow ring read as a halo around each
81
+ // decoy and picked up WebP artefacts on the background.
82
+ for (let ly = 0; ly < size; ly++) {
83
+ for (let lx = 0; lx < size; lx++) {
84
+ const x = left + lx;
85
+ const y = top + ly;
86
+ if (x < 0 || y < 0 || x >= background.width || y >= background.height) {
87
+ continue;
88
+ }
89
+ const d = shape.distance(lx, ly);
90
+ const coverage = coverageFromDistance(d);
91
+ if (coverage <= 0) continue;
92
+
93
+ const bi = (y * background.width + x) * 4;
94
+ const r = background.data[bi] ?? 0;
95
+ const g = background.data[bi + 1] ?? 0;
96
+ const b = background.data[bi + 2] ?? 0;
97
+
98
+ // Multiplicative darken with an inner shadow, matching the real
99
+ // cut's structure. Without this, decoys were just faint edge
100
+ // tints and the real hole stood out as the only deep region on
101
+ // the frame — trivially findable by eye or by luminance.
102
+ const edge = d > -3 ? DECOY_INNER_SHADOW * (1 + d / 3) : 0;
103
+ const factor = holeDarken * (1 - edge);
104
+ const dr = r * factor;
105
+ const dg = g * factor;
106
+ const db = b * factor;
107
+
108
+ // Existing additive tweak stays on top, so operators keep the
109
+ // same rim / body knobs — they now fine-tune the multiplicative
110
+ // base rather than being the sole depth cue.
111
+ const edgeDark = d > -3 ? edgeDarkness * (1 + d / 3) : 0;
112
+ const delta = bodyBrightness - edgeDark;
113
+
114
+ const mixR = r * (1 - coverage) + dr * coverage;
115
+ const mixG = g * (1 - coverage) + dg * coverage;
116
+ const mixB = b * (1 - coverage) + db * coverage;
117
+
118
+ background.data[bi] = clamp255(Math.round(mixR + delta * coverage));
119
+ background.data[bi + 1] = clamp255(Math.round(mixG + delta * coverage));
120
+ background.data[bi + 2] = clamp255(Math.round(mixB + delta * coverage));
121
+ }
122
+ }
123
+ };
124
+
125
+ /**
126
+ * Fractional jitter around the configured decoy count. ±30% means a
127
+ * configured 5 draws anywhere in [4, 6]; a configured 100 draws in [70,
128
+ * 130]. Randomising the count prevents a solver from keying on "exactly N
129
+ * jigsaw regions on the board" as a stable feature.
130
+ */
131
+ const COUNT_JITTER = 0.3;
132
+
133
+ /**
134
+ * Paint decoys onto `background`. The actual number placed varies per-
135
+ * challenge within ±COUNT_JITTER of `targetCount`, so a solver can't
136
+ * hard-code the expected count. Decoys avoid only the real notch
137
+ * placement (they may freely overlap each other). If a slot cannot be
138
+ * placed after `PLACEMENT_ATTEMPTS`, it is silently dropped — decoys are
139
+ * cosmetic, so an occasional miss is fine.
140
+ */
141
+ export const paintDecoys = (
142
+ background: RgbaImage,
143
+ prng: Prng,
144
+ targetCount: number,
145
+ notchSize: number,
146
+ notch: NotchPlacement,
147
+ edgeDarkness: number,
148
+ bodyBrightness: number,
149
+ holeDarken: number,
150
+ ): void => {
151
+ if (targetCount <= 0) return;
152
+
153
+ // Draw an actual count near the target. Round to int, clamp to >=0.
154
+ const jitter = 1 + prng.range(-COUNT_JITTER, COUNT_JITTER);
155
+ const count = Math.max(0, Math.round(targetCount * jitter));
156
+ if (count === 0) return;
157
+
158
+ const notchClearanceSq = (notchSize * NOTCH_CLEARANCE) ** 2;
159
+ // Centre may sit anywhere on the frame; a decoy that overhangs the edge
160
+ // is clipped by `paintDecoyPiece`, matching the real piece's overhang
161
+ // behaviour. Without this, large pieces would render zero decoys because
162
+ // `notchSize/2` exceeds the frame dimensions.
163
+ const minX = 0;
164
+ const maxX = background.width;
165
+ const minY = 0;
166
+ const maxY = background.height;
167
+
168
+ for (let i = 0; i < count; i++) {
169
+ for (let attempt = 0; attempt < PLACEMENT_ATTEMPTS; attempt++) {
170
+ const cx = prng.range(minX, maxX);
171
+ const cy = prng.range(minY, maxY);
172
+ if (
173
+ distanceSquared(cx, cy, { cx: notch.targetX, cy: notch.targetY }) <
174
+ notchClearanceSq
175
+ ) {
176
+ continue;
177
+ }
178
+ paintDecoyPiece(
179
+ background,
180
+ prng,
181
+ notchSize,
182
+ cx,
183
+ cy,
184
+ edgeDarkness,
185
+ bodyBrightness,
186
+ holeDarken,
187
+ );
188
+ break;
189
+ }
190
+ }
191
+ };
package/src/encode.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import sharp from "sharp";
16
+ import type { RgbaImage } from "./types.js";
17
+
18
+ /**
19
+ * Background quality is deliberately high. WebP's chroma subsampling and
20
+ * ringing around the cut are exactly the kind of artefact an attacker could
21
+ * key on, *and* at q<95 the ringing becomes visually obvious around the
22
+ * decoy piece silhouettes we paint on top of the background — the mix of
23
+ * bright edge-rim and dark drop-shadow at the decoy edge is precisely the
24
+ * high-contrast local signal WebP butchers hardest. At 300x200 the extra few
25
+ * kilobytes of a higher quality setting are trivial.
26
+ */
27
+ const BACKGROUND_QUALITY = 95;
28
+
29
+ export const encodeBackground = (image: RgbaImage): Promise<Buffer> =>
30
+ sharp(image.data, {
31
+ raw: { width: image.width, height: image.height, channels: 4 },
32
+ })
33
+ .webp({ quality: BACKGROUND_QUALITY, alphaQuality: 100, effort: 4 })
34
+ .toBuffer();
35
+
36
+ /**
37
+ * Lossless. The piece is a 44x44 sprite on transparency, and lossy WebP's
38
+ * ringing around the alpha edge is the dominant visual artefact — a subtle
39
+ * ripple that reads as wavy edges even at q=92. Lossless costs ~4 KB per
40
+ * piece instead of ~2 KB, which is nothing over the wire, and eliminates
41
+ * the ringing entirely.
42
+ */
43
+ export const encodePiece = (image: RgbaImage): Promise<Buffer> =>
44
+ sharp(image.data, {
45
+ raw: { width: image.width, height: image.height, channels: 4 },
46
+ })
47
+ .webp({ lossless: true, alphaQuality: 100, effort: 4 })
48
+ .toBuffer();
49
+
50
+ /** PNG, for the preview script only — lossless so artefacts are visible. */
51
+ export const encodePng = (image: RgbaImage): Promise<Buffer> =>
52
+ sharp(image.data, {
53
+ raw: { width: image.width, height: image.height, channels: 4 },
54
+ })
55
+ .png()
56
+ .toBuffer();
57
+
58
+ export const toDataUri = (webp: Buffer): string =>
59
+ `data:image/webp;base64,${webp.toString("base64")}`;
package/src/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { generateBackground } from "./background.js";
16
+ import { cutNotch } from "./compose.js";
17
+ import { paintDecoys } from "./decoys.js";
18
+ import { encodeBackground, encodePiece } from "./encode.js";
19
+ import { createNotchShape } from "./notch.js";
20
+ import { createPrng, createSeed } from "./prng.js";
21
+ import type {
22
+ NotchPlacement,
23
+ PuzzleGeometry,
24
+ PuzzleRenderSettings,
25
+ RenderedPuzzle,
26
+ RgbaImage,
27
+ } from "./types.js";
28
+
29
+ export { createPrng, createSeed, SEED_BYTES } from "./prng.js";
30
+ export { generateBackground } from "./background.js";
31
+ export { paintDecoys, paintDecoyPiece } from "./decoys.js";
32
+ export {
33
+ encodeBackground,
34
+ encodePiece,
35
+ encodePng,
36
+ toDataUri,
37
+ } from "./encode.js";
38
+ export type {
39
+ NotchPlacement,
40
+ PuzzleGeometry,
41
+ PuzzleRenderSettings,
42
+ RenderedPuzzle,
43
+ RgbaImage,
44
+ } from "./types.js";
45
+
46
+ /**
47
+ * Matches the widget's container. Kept here so the generator and the provider
48
+ * agree on geometry without the provider having to restate it. Geometry is
49
+ * widget-coupled and not exposed to operators.
50
+ */
51
+ export const DEFAULT_GEOMETRY: PuzzleGeometry = {
52
+ width: 300,
53
+ height: 200,
54
+ notchSize: 44,
55
+ };
56
+
57
+ /**
58
+ * Defaults for the per-render tunables. Operators can override any of these
59
+ * per-client (via `ClientSettingsSchema.puzzle`) or per-traffic-category
60
+ * (via `TrafficCategoryPolicySchema.puzzle`); the provider resolves an
61
+ * effective value and passes it to `renderPuzzle`.
62
+ */
63
+ export const DEFAULT_RENDER_SETTINGS: PuzzleRenderSettings = {
64
+ decoyCount: 5,
65
+ decoyEdgeDarkness: 20,
66
+ decoyBodyBrightness: 4,
67
+ holeDarken: 0.55,
68
+ decoyHoleDarken: 0.7,
69
+ };
70
+
71
+ /**
72
+ * Generate a fresh background. Each one is single-use by contract: serving the
73
+ * same background twice with two different notch positions would let an
74
+ * attacker diff the two composites and read both targets straight off. The
75
+ * caller's buffer is responsible for enforcing consume-once.
76
+ */
77
+ export const createBackground = (
78
+ geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
79
+ ): RgbaImage =>
80
+ generateBackground(createPrng(createSeed()), geometry.width, geometry.height);
81
+
82
+ /**
83
+ * Cut a notch at `placement` and encode both halves.
84
+ *
85
+ * The background is consumed (mutated) by this call.
86
+ */
87
+ export const renderPuzzle = async (
88
+ background: RgbaImage,
89
+ placement: NotchPlacement,
90
+ geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
91
+ settings: PuzzleRenderSettings = DEFAULT_RENDER_SETTINGS,
92
+ ): Promise<RenderedPuzzle> => {
93
+ const prng = createPrng(createSeed());
94
+ const pieceSize = geometry.pieceSize ?? geometry.notchSize;
95
+ // Decoys go on first so the real cut sits on top of any overlap and
96
+ // always reads as the deepest, darkest region on the board. Decoys use
97
+ // the same size as the real piece so a solver can't key on scale to
98
+ // disambiguate the target.
99
+ paintDecoys(
100
+ background,
101
+ prng,
102
+ settings.decoyCount,
103
+ pieceSize,
104
+ placement,
105
+ settings.decoyEdgeDarkness,
106
+ settings.decoyBodyBrightness,
107
+ settings.decoyHoleDarken,
108
+ );
109
+ const shape = createNotchShape(prng, pieceSize);
110
+ const { background: cut, piece } = cutNotch(
111
+ prng,
112
+ background,
113
+ shape,
114
+ pieceSize,
115
+ placement,
116
+ settings.holeDarken,
117
+ );
118
+
119
+ const [backgroundWebp, pieceWebp] = await Promise.all([
120
+ encodeBackground(cut),
121
+ encodePiece(piece),
122
+ ]);
123
+
124
+ return {
125
+ background: backgroundWebp,
126
+ piece: pieceWebp,
127
+ pieceSize,
128
+ };
129
+ };
130
+
131
+ /** Convenience: generate, cut and encode in one call. */
132
+ export const createPuzzle = async (
133
+ placement: NotchPlacement,
134
+ geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
135
+ settings: PuzzleRenderSettings = DEFAULT_RENDER_SETTINGS,
136
+ ): Promise<RenderedPuzzle> =>
137
+ renderPuzzle(createBackground(geometry), placement, geometry, settings);
package/src/notch.ts ADDED
@@ -0,0 +1,223 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { Prng } from "./prng.js";
16
+
17
+ /**
18
+ * The notch silhouette, as a signed distance field sampled per pixel.
19
+ *
20
+ * Distances (rather than a boolean mask) buy anti-aliased edges for free, which
21
+ * is most of what makes the cut look like a deliberate shape instead of a
22
+ * staircase. The shape varies per challenge — a rounded square with a knob on a
23
+ * random side, and sometimes a bite out of another — so the silhouette itself
24
+ * is not a fixed template an attacker can match against.
25
+ */
26
+ export interface NotchShape {
27
+ /** Signed distance in px: negative inside the shape, positive outside. */
28
+ distance(lx: number, ly: number): number;
29
+ }
30
+
31
+ type Side = 0 | 1 | 2 | 3;
32
+
33
+ const sideOffset = (
34
+ side: Side,
35
+ half: number,
36
+ distance: number,
37
+ ): [number, number] => {
38
+ switch (side) {
39
+ case 0:
40
+ return [0, -half - distance];
41
+ case 1:
42
+ return [half + distance, 0];
43
+ case 2:
44
+ return [0, half + distance];
45
+ default:
46
+ return [-half - distance, 0];
47
+ }
48
+ };
49
+
50
+ const roundedBoxDistance = (
51
+ px: number,
52
+ py: number,
53
+ half: number,
54
+ radius: number,
55
+ ): number => {
56
+ const qx = Math.abs(px) - half + radius;
57
+ const qy = Math.abs(py) - half + radius;
58
+ const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
59
+ const inside = Math.min(Math.max(qx, qy), 0);
60
+ return outside + inside - radius;
61
+ };
62
+
63
+ const createJigsawShape = (prng: Prng, size: number): NotchShape => {
64
+ // The body is inset so the knob can protrude without leaving the bounding
65
+ // box — the caller places the box, not the body.
66
+ const knobRadius = size * prng.range(0.15, 0.19);
67
+ const bodyHalf = size / 2 - knobRadius * 0.9;
68
+ const cornerRadius = bodyHalf * prng.range(0.28, 0.42);
69
+
70
+ const knobSide = prng.int(0, 3) as Side;
71
+ // Sits mostly outside the body edge, so it reads as a tab.
72
+ const [knobX, knobY] = sideOffset(knobSide, bodyHalf, -knobRadius * 0.45);
73
+
74
+ const hasBite = prng.next() < 0.55;
75
+ const biteSide = ((knobSide + prng.int(1, 3)) % 4) as Side;
76
+ const biteRadius = size * prng.range(0.13, 0.17);
77
+ const [biteX, biteY] = sideOffset(biteSide, bodyHalf, -biteRadius * 0.55);
78
+
79
+ return {
80
+ distance(lx: number, ly: number): number {
81
+ // Local coordinates, origin at the centre of the bounding box.
82
+ const px = lx - size / 2;
83
+ const py = ly - size / 2;
84
+
85
+ const body = roundedBoxDistance(px, py, bodyHalf, cornerRadius);
86
+ const knob = Math.hypot(px - knobX, py - knobY) - knobRadius;
87
+ // Union of body and knob.
88
+ let d = Math.min(body, knob);
89
+
90
+ if (hasBite) {
91
+ const bite = Math.hypot(px - biteX, py - biteY) - biteRadius;
92
+ // Subtract the bite from the union.
93
+ d = Math.max(d, -bite);
94
+ }
95
+
96
+ return d;
97
+ },
98
+ };
99
+ };
100
+
101
+ const createCircleShape = (prng: Prng, size: number): NotchShape => {
102
+ const radius = (size / 2) * prng.range(0.85, 0.98);
103
+ const cx = size / 2;
104
+ const cy = size / 2;
105
+ return {
106
+ distance(lx: number, ly: number): number {
107
+ return Math.hypot(lx - cx, ly - cy) - radius;
108
+ },
109
+ };
110
+ };
111
+
112
+ const createRoundedRectShape = (prng: Prng, size: number): NotchShape => {
113
+ // One axis fills the box; the other is squashed by a random aspect ratio.
114
+ // Randomising which axis gets squashed avoids a landscape-only bias.
115
+ const aspect = prng.range(0.5, 1.0);
116
+ const halfMax = (size / 2) * prng.range(0.82, 0.98);
117
+ const [halfW, halfH] =
118
+ prng.next() < 0.5
119
+ ? [halfMax, halfMax * aspect]
120
+ : [halfMax * aspect, halfMax];
121
+ const cornerRadius = Math.min(halfW, halfH) * prng.range(0.08, 0.5);
122
+ const cx = size / 2;
123
+ const cy = size / 2;
124
+ return {
125
+ distance(lx: number, ly: number): number {
126
+ const px = lx - cx;
127
+ const py = ly - cy;
128
+ const qx = Math.abs(px) - halfW + cornerRadius;
129
+ const qy = Math.abs(py) - halfH + cornerRadius;
130
+ const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
131
+ const inside = Math.min(Math.max(qx, qy), 0);
132
+ return outside + inside - cornerRadius;
133
+ },
134
+ };
135
+ };
136
+
137
+ const createPolygonShape = (prng: Prng, size: number): NotchShape => {
138
+ // Random regular polygon (triangle through octagon), rotated arbitrarily.
139
+ // SDF is max-of-halfplanes: exact at the boundary and inside, slightly
140
+ // approximate for far-outside points — fine for the 1-pixel anti-aliased
141
+ // coverage band. Edge normals are precomputed so the per-pixel loop is a
142
+ // tight sum-and-max over a handful of values.
143
+ const n = prng.int(3, 8);
144
+ const radius = (size / 2) * prng.range(0.85, 0.98);
145
+ const inradius = radius * Math.cos(Math.PI / n);
146
+ const rotation = prng.next() * Math.PI * 2;
147
+ const normals: [number, number][] = [];
148
+ for (let i = 0; i < n; i++) {
149
+ // Start at the top and go around; edge i's outward normal points at
150
+ // angle (2πi/n − π/2) from the +x axis, rotated by `rotation`.
151
+ const angle = (2 * Math.PI * i) / n - Math.PI / 2 + rotation;
152
+ normals.push([Math.cos(angle), Math.sin(angle)]);
153
+ }
154
+ const cx = size / 2;
155
+ const cy = size / 2;
156
+ return {
157
+ distance(lx: number, ly: number): number {
158
+ const px = lx - cx;
159
+ const py = ly - cy;
160
+ let maxD = Number.NEGATIVE_INFINITY;
161
+ for (const [nx, ny] of normals) {
162
+ const d = px * nx + py * ny - inradius;
163
+ if (d > maxD) maxD = d;
164
+ }
165
+ return maxD;
166
+ },
167
+ };
168
+ };
169
+
170
+ const createFlowerShape = (prng: Prng, size: number): NotchShape => {
171
+ // Sinusoidal boundary: r(θ) = rAvg + rAmp·cos(nθ + φ). Reads as a soft
172
+ // flower / gear silhouette. Kept smooth (no sharp inner points) so the
173
+ // coverage sampler doesn't misread narrow petals.
174
+ const petals = prng.int(4, 8);
175
+ const rAvg = (size / 2) * prng.range(0.72, 0.88);
176
+ const rAmp = rAvg * prng.range(0.1, 0.22);
177
+ const phase = prng.next() * Math.PI * 2;
178
+ const cx = size / 2;
179
+ const cy = size / 2;
180
+ return {
181
+ distance(lx: number, ly: number): number {
182
+ const px = lx - cx;
183
+ const py = ly - cy;
184
+ const len = Math.hypot(px, py);
185
+ const angle = Math.atan2(py, px);
186
+ const r = rAvg + rAmp * Math.cos(petals * angle + phase);
187
+ return len - r;
188
+ },
189
+ };
190
+ };
191
+
192
+ /**
193
+ * Pick a random shape family per call. The real piece and its hole are cut
194
+ * from the same call so they match; decoys each call independently so they
195
+ * form a mixed field of silhouettes on the frame.
196
+ */
197
+ export const createNotchShape = (prng: Prng, size: number): NotchShape => {
198
+ const family = prng.int(0, 4);
199
+ switch (family) {
200
+ case 0:
201
+ return createJigsawShape(prng, size);
202
+ case 1:
203
+ return createCircleShape(prng, size);
204
+ case 2:
205
+ return createRoundedRectShape(prng, size);
206
+ case 3:
207
+ return createPolygonShape(prng, size);
208
+ default:
209
+ return createFlowerShape(prng, size);
210
+ }
211
+ };
212
+
213
+ /**
214
+ * Anti-aliased coverage in [0, 1] for a signed distance, using a one-pixel
215
+ * transition band centred on the boundary.
216
+ */
217
+ export const coverageFromDistance = (distance: number): number => {
218
+ const t = 0.5 - distance;
219
+ if (t <= 0) return 0;
220
+ if (t >= 1) return 1;
221
+ // smoothstep, so the edge ramp is not linear and reads softer.
222
+ return t * t * (3 - 2 * t);
223
+ };