@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.
Files changed (61) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +13 -15
  2. package/.turbo/turbo-build$colon$tsc.log +5 -8
  3. package/.turbo/turbo-build.log +15 -20
  4. package/CHANGELOG.md +86 -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 +6 -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
  47. package/src/background.ts +0 -189
  48. package/src/browser.ts +0 -100
  49. package/src/compose.ts +0 -210
  50. package/src/decoys.ts +0 -191
  51. package/src/encode.ts +0 -59
  52. package/src/index.ts +0 -137
  53. package/src/notch.ts +0 -223
  54. package/src/palette.ts +0 -110
  55. package/src/prng.ts +0 -95
  56. package/src/tests/puzzleAssets.unit.test.ts +0 -438
  57. package/src/types.ts +0 -93
  58. package/tsconfig.cjs.json +0 -14
  59. package/tsconfig.json +0 -19
  60. package/tsconfig.tsbuildinfo +0 -1
  61. package/tsconfig.types.json +0 -9
package/package.json CHANGED
@@ -1,61 +1,60 @@
1
1
  {
2
- "name": "@prosopo/puzzle-assets",
3
- "version": "0.1.4",
4
- "description": "Procedural imagery for the slider puzzle captcha",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
7
- "type": "module",
8
- "engines": {
9
- "node": "^24",
10
- "npm": "^11"
11
- },
12
- "exports": {
13
- ".": {
14
- "types": "./dist/index.d.ts",
15
- "import": "./dist/index.js",
16
- "require": "./dist/cjs/index.cjs"
17
- },
18
- "./browser": {
19
- "types": "./dist/browser.d.ts",
20
- "import": "./dist/browser.js",
21
- "require": "./dist/cjs/browser.cjs"
22
- }
23
- },
24
- "scripts": {
25
- "clean": "del-cli --verbose dist tsconfig.tsbuildinfo",
26
- "build": "npm run build:cross-env -- --mode ${NODE_ENV:-development}",
27
- "build:cross-env": "vite build --config vite.esm.config.ts",
28
- "build:tsc": "tsc --build --verbose",
29
- "build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
30
- "preview": "tsx scripts/preview.ts",
31
- "test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts",
32
- "typecheck": "tsc --project tsconfig.types.json"
33
- },
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/prosopo/captcha.git",
37
- "directory": "packages/puzzle-assets"
38
- },
39
- "author": "Prosopo Limited",
40
- "license": "Apache-2.0",
41
- "bugs": {
42
- "url": "https://github.com/prosopo/captcha/issues"
43
- },
44
- "homepage": "https://github.com/prosopo/captcha#readme",
45
- "dependencies": {
46
- "sharp": "0.35.4"
47
- },
48
- "devDependencies": {
49
- "@prosopo/config": "3.3.14",
50
- "@types/node": "22.10.2",
51
- "@vitest/coverage-v8": "4.1.11",
52
- "del-cli": "6.0.0",
53
- "dotenv": "16.4.5",
54
- "tslib": "2.7.0",
55
- "tsx": "4.20.3",
56
- "typescript": "5.6.2",
57
- "vite": "8.1.5",
58
- "vitest": "4.1.11"
59
- },
60
- "sideEffects": false
61
- }
2
+ "name": "@prosopo/puzzle-assets",
3
+ "version": "0.2.1",
4
+ "description": "Procedural imagery for the slider puzzle captcha",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": "^24"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/cjs/index.cjs"
16
+ },
17
+ "./browser": {
18
+ "types": "./dist/browser.d.ts",
19
+ "import": "./dist/browser.js",
20
+ "require": "./dist/cjs/browser.cjs"
21
+ }
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/prosopo/captcha.git",
26
+ "directory": "packages/puzzle-assets"
27
+ },
28
+ "author": "Prosopo Limited",
29
+ "license": "Apache-2.0",
30
+ "bugs": {
31
+ "url": "https://github.com/prosopo/captcha/issues"
32
+ },
33
+ "homepage": "https://github.com/prosopo/captcha#readme",
34
+ "dependencies": {
35
+ "sharp": "0.35.4"
36
+ },
37
+ "devDependencies": {
38
+ "@prosopo/config": "3.4.3",
39
+ "@types/node": "22.10.2",
40
+ "@vitest/coverage-v8": "4.1.11",
41
+ "del-cli": "6.0.0",
42
+ "dotenv": "16.4.5",
43
+ "tslib": "2.7.0",
44
+ "tsx": "4.20.3",
45
+ "typescript": "5.6.2",
46
+ "vite": "8.1.5",
47
+ "vitest": "4.1.11"
48
+ },
49
+ "sideEffects": false,
50
+ "scripts": {
51
+ "clean": "del-cli --verbose dist tsconfig.tsbuildinfo",
52
+ "build": "pnpm run build:cross-env --mode ${NODE_ENV:-development}",
53
+ "build:cross-env": "vite build --config vite.esm.config.ts",
54
+ "build:tsc": "tsc --build --verbose",
55
+ "build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
56
+ "preview": "tsx scripts/preview.ts",
57
+ "test": "NODE_ENV=${NODE_ENV:-test}; pnpm exec vitest run --config ./vite.test.config.ts",
58
+ "typecheck": "tsc --project tsconfig.types.json"
59
+ }
60
+ }
package/src/background.ts DELETED
@@ -1,189 +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 Rgb, drawPalette } from "./palette.js";
16
- import type { Prng } from "./prng.js";
17
- import type { RgbaImage } from "./types.js";
18
-
19
- interface MeshPoint {
20
- x: number;
21
- y: number;
22
- colour: Rgb;
23
- /** Falloff radius. Small values give tight colour fields, large ones wash. */
24
- sigma: number;
25
- }
26
-
27
- interface Blob {
28
- x: number;
29
- y: number;
30
- sigma: number;
31
- colour: Rgb;
32
- alpha: number;
33
- }
34
-
35
- /**
36
- * Grain amplitude, in 0-255 units, applied per channel.
37
- *
38
- * This is the one knob serving both goals at once. Visually it reads as film
39
- * grain and stops the mesh gradient banding on 8-bit displays. Structurally it
40
- * raises the noise floor an edge detector has to clear to find the notch
41
- * boundary — a perfectly smooth gradient would hand the target over to a Canny
42
- * filter. Too high and the image looks like TV static, so it stays subtle.
43
- */
44
- const GRAIN_AMPLITUDE = 5;
45
-
46
- const VIGNETTE_STRENGTH = 0.16;
47
-
48
- const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
49
-
50
- /**
51
- * Smooth multi-colour blend: every pixel is a Gaussian-weighted mix of the
52
- * control points, giving the "mesh gradient" look — broad soft colour fields
53
- * with no hard edges anywhere.
54
- *
55
- * The falloff has to be local. Inverse-distance weighting (1/d^2) looks right
56
- * on paper but at 300x200 every control point is a comparable distance from
57
- * every pixel, so the weights come out near-equal and the whole frame collapses
58
- * to the palette's mean — a flat grey-ish wash. A Gaussian with sigma around a
59
- * third of the frame keeps each point dominant near itself and still blends
60
- * smoothly in between.
61
- */
62
- const meshColourAt = (
63
- points: readonly MeshPoint[],
64
- x: number,
65
- y: number,
66
- ): Rgb => {
67
- let wSum = 0;
68
- let r = 0;
69
- let g = 0;
70
- let b = 0;
71
- for (const point of points) {
72
- const dx = x - point.x;
73
- const dy = y - point.y;
74
- const w = Math.exp(-(dx * dx + dy * dy) / (2 * point.sigma * point.sigma));
75
- wSum += w;
76
- r += point.colour.r * w;
77
- g += point.colour.g * w;
78
- b += point.colour.b * w;
79
- }
80
- // Every point can be far away near a corner; fall back to the nearest.
81
- if (wSum < 1e-6) {
82
- let best = points[0];
83
- if (!best) throw new Error("puzzle-assets: no mesh points");
84
- let bestD = Number.POSITIVE_INFINITY;
85
- for (const point of points) {
86
- const d = (x - point.x) ** 2 + (y - point.y) ** 2;
87
- if (d < bestD) {
88
- bestD = d;
89
- best = point;
90
- }
91
- }
92
- return best.colour;
93
- }
94
- return { r: r / wSum, g: g / wSum, b: b / wSum };
95
- };
96
-
97
- /**
98
- * Synthesise a background. Deterministic in `prng`, so the same seed always
99
- * yields the same picture — see the seed-secrecy note in prng.ts for why that
100
- * determinism is safe only while the seed stays on the provider.
101
- */
102
- export const generateBackground = (
103
- prng: Prng,
104
- width: number,
105
- height: number,
106
- ): RgbaImage => {
107
- const pointCount = prng.int(4, 6);
108
- // Drawn to length: drawPalette spreads hues evenly across the band, so
109
- // taking a prefix of a longer draw would cover only part of it and leave
110
- // the mesh monotone.
111
- const palette = drawPalette(prng, pointCount);
112
- const blobPalette = drawPalette(prng, 3);
113
-
114
- const minDimension = Math.min(width, height);
115
- const points: MeshPoint[] = [];
116
- for (let i = 0; i < pointCount; i++) {
117
- const colour = palette[i];
118
- if (!colour) throw new Error("puzzle-assets: palette underflow");
119
- points.push({
120
- // Allow control points slightly outside the frame so the gradient
121
- // keeps moving at the edges instead of pooling into flat corners.
122
- x: prng.range(-0.15, 1.15) * width,
123
- y: prng.range(-0.15, 1.15) * height,
124
- colour,
125
- sigma: prng.range(0.26, 0.48) * minDimension,
126
- });
127
- }
128
-
129
- const blobs: Blob[] = [];
130
- const blobCount = prng.int(2, 4);
131
- for (let i = 0; i < blobCount; i++) {
132
- const colour = blobPalette[i % blobPalette.length];
133
- if (!colour) throw new Error("puzzle-assets: palette underflow");
134
- blobs.push({
135
- x: prng.range(0.1, 0.9) * width,
136
- y: prng.range(0.1, 0.9) * height,
137
- sigma: prng.range(0.18, 0.38) * minDimension,
138
- colour,
139
- alpha: prng.range(0.16, 0.32),
140
- });
141
- }
142
-
143
- const data = Buffer.alloc(width * height * 4);
144
- const cx = width / 2;
145
- const cy = height / 2;
146
- const maxRadius = Math.sqrt(cx * cx + cy * cy);
147
-
148
- for (let y = 0; y < height; y++) {
149
- for (let x = 0; x < width; x++) {
150
- const base = meshColourAt(points, x, y);
151
- let r = base.r;
152
- let g = base.g;
153
- let b = base.b;
154
-
155
- // Soft radial depth. Gaussian falloff means no visible boundary.
156
- for (const blob of blobs) {
157
- const dx = x - blob.x;
158
- const dy = y - blob.y;
159
- const a =
160
- blob.alpha *
161
- Math.exp(-(dx * dx + dy * dy) / (2 * blob.sigma * blob.sigma));
162
- r += (blob.colour.r - r) * a;
163
- g += (blob.colour.g - g) * a;
164
- b += (blob.colour.b - b) * a;
165
- }
166
-
167
- // Vignette: darkens the corners a little, which reads as depth and
168
- // keeps attention in the middle where the puzzle happens.
169
- const dxc = x - cx;
170
- const dyc = y - cy;
171
- const vignette =
172
- 1 -
173
- VIGNETTE_STRENGTH * ((dxc * dxc + dyc * dyc) / (maxRadius * maxRadius));
174
- r *= vignette;
175
- g *= vignette;
176
- b *= vignette;
177
-
178
- const grain = (prng.next() - 0.5) * 2 * GRAIN_AMPLITUDE;
179
-
180
- const i = (y * width + x) * 4;
181
- data[i] = clamp255(Math.round(r + grain));
182
- data[i + 1] = clamp255(Math.round(g + grain));
183
- data[i + 2] = clamp255(Math.round(b + grain));
184
- data[i + 3] = 255;
185
- }
186
- }
187
-
188
- return { data, width, height };
189
- };
package/src/browser.ts DELETED
@@ -1,100 +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
- // Browser-safe surface: same drawing pipeline as the provider, minus the
16
- // sharp-based WebP encoder. Consumers paint the returned RGBA buffers onto
17
- // a <canvas> directly.
18
-
19
- import { generateBackground } from "./background.js";
20
- import { cutNotch } from "./compose.js";
21
- import { paintDecoys } from "./decoys.js";
22
- import { createNotchShape } from "./notch.js";
23
- import { createPrng, createSeed } from "./prng.js";
24
- import type {
25
- NotchPlacement,
26
- PuzzleGeometry,
27
- PuzzleRenderSettings,
28
- RgbaImage,
29
- } from "./types.js";
30
-
31
- export { createPrng, createSeed, SEED_BYTES } from "./prng.js";
32
- export { generateBackground } from "./background.js";
33
- export { paintDecoys, paintDecoyPiece } from "./decoys.js";
34
- export type {
35
- NotchPlacement,
36
- PuzzleGeometry,
37
- PuzzleRenderSettings,
38
- RenderedPuzzle,
39
- RgbaImage,
40
- } from "./types.js";
41
-
42
- export const DEFAULT_GEOMETRY: PuzzleGeometry = {
43
- width: 300,
44
- height: 200,
45
- notchSize: 44,
46
- };
47
-
48
- export const DEFAULT_RENDER_SETTINGS: PuzzleRenderSettings = {
49
- decoyCount: 5,
50
- decoyEdgeDarkness: 20,
51
- decoyBodyBrightness: 4,
52
- holeDarken: 0.55,
53
- decoyHoleDarken: 0.7,
54
- };
55
-
56
- export interface RenderedPuzzleRgba {
57
- background: RgbaImage;
58
- piece: RgbaImage;
59
- pieceSize: number;
60
- }
61
-
62
- /**
63
- * Same pipeline as `renderPuzzle` in index.ts but returns raw RGBA rather
64
- * than WebP-encoded buffers, so browser consumers can `putImageData` onto
65
- * a canvas without needing sharp.
66
- */
67
- export const renderPuzzleToRgba = (
68
- background: RgbaImage,
69
- placement: NotchPlacement,
70
- geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
71
- settings: PuzzleRenderSettings = DEFAULT_RENDER_SETTINGS,
72
- ): RenderedPuzzleRgba => {
73
- const prng = createPrng(createSeed());
74
- const pieceSize = geometry.pieceSize ?? geometry.notchSize;
75
- paintDecoys(
76
- background,
77
- prng,
78
- settings.decoyCount,
79
- pieceSize,
80
- placement,
81
- settings.decoyEdgeDarkness,
82
- settings.decoyBodyBrightness,
83
- settings.decoyHoleDarken,
84
- );
85
- const shape = createNotchShape(prng, pieceSize);
86
- const { background: cut, piece } = cutNotch(
87
- prng,
88
- background,
89
- shape,
90
- pieceSize,
91
- placement,
92
- settings.holeDarken,
93
- );
94
- return { background: cut, piece, pieceSize };
95
- };
96
-
97
- export const createBackground = (
98
- geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
99
- ): RgbaImage =>
100
- generateBackground(createPrng(createSeed()), geometry.width, geometry.height);
package/src/compose.ts DELETED
@@ -1,210 +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 NotchShape, coverageFromDistance } from "./notch.js";
16
- import type { Prng } from "./prng.js";
17
- import type { NotchPlacement, RgbaImage } from "./types.js";
18
-
19
- /**
20
- * Per-pixel noise added to the piece, in 0-255 units.
21
- *
22
- * This is not decoration. The piece is cut from the background, so a
23
- * pixel-exact copy would let an attacker slide the piece over the background
24
- * with normalised cross-correlation and land on the target in one pass —
25
- * which would make the whole server-rendering exercise worthless. The piece is
26
- * therefore brightness-shifted and re-noised so it correlates well with a
27
- * region of the background but never perfectly, and the background gets fresh
28
- * noise around the cut so the seam is not a clean template either.
29
- */
30
- const PIECE_NOISE = 7;
31
- const PIECE_BRIGHTNESS_RANGE: [number, number] = [-8, 8];
32
-
33
- /**
34
- * Disturbance ring outside the cut, in px. The ring exists so an edge detector
35
- * cannot lock onto a clean gradient discontinuity along the seam. The noise
36
- * tapers linearly to zero at the outer edge so it fades into the ambient
37
- * background grain instead of terminating in a visible border.
38
- */
39
- const SEAM_NOISE_RADIUS = 3;
40
- /**
41
- * Peak amplitude at the seam itself, in 0-255 units. Kept close to the
42
- * background grain amplitude so the ring reads as continuation of the grain
43
- * rather than as a halo around the hole.
44
- */
45
- const SEAM_NOISE = 4;
46
-
47
- /**
48
- * Depth cue on the hole. HOLE_INNER_SHADOW is a fixed within-primitive
49
- * ratio, but HOLE_DARKEN is caller-provided so operators can tune how
50
- * obvious the target is versus the decoys.
51
- */
52
- const HOLE_INNER_SHADOW = 0.45;
53
-
54
- /**
55
- * Depth cue on the piece itself. A subtle brightness rise just inside the
56
- * silhouette edge, so the piece reads as raised rather than as a flat sticker.
57
- * Kept low deliberately — a stronger rim starts to look like a drawn border
58
- * against the darkened cutout it drops into.
59
- */
60
- const PIECE_EDGE_LIGHT = 8;
61
-
62
- const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
63
-
64
- /**
65
- * Sample the background at `(x, y)`. Returns `null` when the coordinate
66
- * sits outside the frame — the piece is clipped at the boundary rather
67
- * than filled from a clamped edge pixel, so a piece whose placement
68
- * overhangs the frame simply loses those pixels.
69
- */
70
- const sampleBackground = (
71
- image: RgbaImage,
72
- x: number,
73
- y: number,
74
- ): [number, number, number] | null => {
75
- if (x < 0 || y < 0 || x >= image.width || y >= image.height) {
76
- return null;
77
- }
78
- const i = (y * image.width + x) * 4;
79
- return [image.data[i] ?? 0, image.data[i + 1] ?? 0, image.data[i + 2] ?? 0];
80
- };
81
-
82
- export interface CutResult {
83
- /** The background, mutated in place, with the hole cut into it. */
84
- background: RgbaImage;
85
- /** The draggable piece, on transparency. */
86
- piece: RgbaImage;
87
- }
88
-
89
- /**
90
- * Cut the notch out of `background` and produce the matching piece.
91
- *
92
- * `background` is mutated in place — it is a freshly generated, single-use
93
- * image, so there is nothing to preserve.
94
- */
95
- export const cutNotch = (
96
- prng: Prng,
97
- background: RgbaImage,
98
- shape: NotchShape,
99
- size: number,
100
- placement: NotchPlacement,
101
- holeDarken: number,
102
- ): CutResult => {
103
- const half = size / 2;
104
- const left = Math.round(placement.targetX - half);
105
- const top = Math.round(placement.targetY - half);
106
-
107
- const pieceData = Buffer.alloc(size * size * 4);
108
- const brightness = prng.range(
109
- PIECE_BRIGHTNESS_RANGE[0],
110
- PIECE_BRIGHTNESS_RANGE[1],
111
- );
112
-
113
- // Pass 1: build the piece from the pixels about to be removed.
114
- for (let ly = 0; ly < size; ly++) {
115
- for (let lx = 0; lx < size; lx++) {
116
- const d = shape.distance(lx, ly);
117
- const coverage = coverageFromDistance(d);
118
- const pi = (ly * size + lx) * 4;
119
-
120
- if (coverage <= 0) {
121
- pieceData[pi + 3] = 0;
122
- continue;
123
- }
124
-
125
- const sample = sampleBackground(background, left + lx, top + ly);
126
- if (sample === null) {
127
- // Piece extends past the frame — leave this pixel transparent
128
- // so the overhang doesn't smear the edge colour outward.
129
- pieceData[pi + 3] = 0;
130
- continue;
131
- }
132
- const [r, g, b] = sample;
133
-
134
- // A bright rim just inside the edge makes the piece read as a
135
- // raised object rather than a flat sticker.
136
- const edgeLight = d > -2 ? PIECE_EDGE_LIGHT * (1 + d / 2) : 0;
137
- const noise = (prng.next() - 0.5) * 2 * PIECE_NOISE;
138
- const delta = brightness + edgeLight + noise;
139
-
140
- pieceData[pi] = clamp255(Math.round(r + delta));
141
- pieceData[pi + 1] = clamp255(Math.round(g + delta));
142
- pieceData[pi + 2] = clamp255(Math.round(b + delta));
143
- pieceData[pi + 3] = Math.round(coverage * 255);
144
- }
145
- }
146
-
147
- // Pass 2: cut the hole, with an inner shadow for depth, and disturb a ring
148
- // of pixels around the seam.
149
- for (let ly = -SEAM_NOISE_RADIUS; ly < size + SEAM_NOISE_RADIUS; ly++) {
150
- for (let lx = -SEAM_NOISE_RADIUS; lx < size + SEAM_NOISE_RADIUS; lx++) {
151
- const x = left + lx;
152
- const y = top + ly;
153
- if (x < 0 || y < 0 || x >= background.width || y >= background.height) {
154
- continue;
155
- }
156
- const bi = (y * background.width + x) * 4;
157
- const d = shape.distance(lx, ly);
158
- const coverage = coverageFromDistance(d);
159
-
160
- if (coverage > 0) {
161
- const r = background.data[bi] ?? 0;
162
- const g = background.data[bi + 1] ?? 0;
163
- const b = background.data[bi + 2] ?? 0;
164
-
165
- // Darken towards the hole, deeper near the edges so the cut has
166
- // an inner shadow rather than reading as a flat grey patch.
167
- const edge = d > -3 ? HOLE_INNER_SHADOW * (1 + d / 3) : 0;
168
- const factor = holeDarken * (1 - edge);
169
- const mix = coverage;
170
-
171
- background.data[bi] = clamp255(
172
- Math.round(r * (1 - mix) + r * factor * mix),
173
- );
174
- background.data[bi + 1] = clamp255(
175
- Math.round(g * (1 - mix) + g * factor * mix),
176
- );
177
- background.data[bi + 2] = clamp255(
178
- Math.round(b * (1 - mix) + b * factor * mix),
179
- );
180
- } else if (d < SEAM_NOISE_RADIUS) {
181
- // Just outside the cut: re-noise so the seam is not a clean
182
- // gradient discontinuity for an edge detector to lock onto.
183
- // Falloff: full amplitude at the seam, zero at the outer edge
184
- // of the ring, so the disturbance blends into ambient grain
185
- // instead of terminating in a visible halo.
186
- const falloff = 1 - d / SEAM_NOISE_RADIUS;
187
- const noise = (prng.next() - 0.5) * 2 * SEAM_NOISE * falloff;
188
- background.data[bi] = clamp255(
189
- Math.round((background.data[bi] ?? 0) + noise),
190
- );
191
- background.data[bi + 1] = clamp255(
192
- Math.round((background.data[bi + 1] ?? 0) + noise),
193
- );
194
- background.data[bi + 2] = clamp255(
195
- Math.round((background.data[bi + 2] ?? 0) + noise),
196
- );
197
- }
198
- }
199
- }
200
-
201
- // Drop shadow is applied client-side via CSS `filter: drop-shadow` on the
202
- // piece element — see PuzzleCanvas.tsx. Baking one into the piece pixels
203
- // stacked with the CSS shadow read as a visible border once lossless WebP
204
- // stopped smudging the baked version away.
205
-
206
- return {
207
- background,
208
- piece: { data: pieceData, width: size, height: size },
209
- };
210
- };