@prosopo/puzzle-assets 0.1.4 → 0.2.1
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 +13 -15
- package/.turbo/turbo-build$colon$tsc.log +5 -8
- package/.turbo/turbo-build.log +15 -20
- package/CHANGELOG.md +86 -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 +6 -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
- package/src/background.ts +0 -189
- package/src/browser.ts +0 -100
- package/src/compose.ts +0 -210
- package/src/decoys.ts +0 -191
- package/src/encode.ts +0 -59
- package/src/index.ts +0 -137
- package/src/notch.ts +0 -223
- package/src/palette.ts +0 -110
- package/src/prng.ts +0 -95
- package/src/tests/puzzleAssets.unit.test.ts +0 -438
- package/src/types.ts +0 -93
- package/tsconfig.cjs.json +0 -14
- package/tsconfig.json +0 -19
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
package/src/decoys.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
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
|
-
};
|