import { describe, expect, it } from "vitest"; import { DEFAULT_GEOMETRY, createGlassSlices, createShapedGlass, sliceRects } from "./lens.js"; // A plain hairline, without the darkened edge or glints; those have their own tests. const slices = createGlassSlices(27, 1, { bezel: 16, edge: 0, glint: 0 }); const centre = 27; function refraction(x: number, y: number) { const index = (y * slices.size + x) * 4; return { r: slices.refraction[index], g: slices.refraction[index + 1] }; } function alpha(layer: keyof typeof slices.masks, x: number, y: number) { return slices.masks[layer][(y * slices.size + x) * 4 + 3] ?? 0; } describe("createGlassSlices", () => { it("renders two corners and one pixel of straight edge", () => { expect(slices.size).toBe(55); expect(createGlassSlices(27, 2).size).toBe(110); }); it("leaves the flat centre undistorted so labels stay sharp", () => { expect(refraction(centre, centre)).toEqual({ r: 128, g: 128 }); }); it("bends the rim toward the centre", () => { expect(refraction(centre, 1).g).toBeGreaterThan(128); expect(refraction(centre, 53).g).toBeLessThan(128); expect(refraction(1, centre).r).toBeGreaterThan(128); expect(refraction(53, centre).r).toBeLessThan(128); }); it("never folds content back on itself across the rim", () => { // Default lens. Each row inward must sample the backdrop further in than the last, // or neighbouring pixels swap order and content shows up as mirrored echoes. const lens = createGlassSlices(27, 1); let previous = -Infinity; for (let row = 0; row <= 27; row++) { const green = lens.refraction[(row * lens.size + 27) * 4 + 1] ?? 128; const sampled = row + 0.5 + ((green - 127.5) / 255) * lens.scale; expect(sampled).toBeGreaterThan(previous); previous = sampled; } }); it("bends the rounded ends far more than the straight edges, still without folding", () => { const lens = createGlassSlices(27, 1); const shiftAt = (x: number, y: number, channel: 0 | 1) => (((lens.refraction[(y * lens.size + x) * 4 + channel] ?? 128) - 127.5) / 255) * lens.scale; // Six pixels in: across the top edge versus into the side. expect(shiftAt(6, 27, 0)).toBeGreaterThan(shiftAt(27, 6, 1) * 2); let previous = -Infinity; for (let column = 0; column <= 27; column++) { const sampled = column + 0.5 + shiftAt(column, 27, 0); expect(sampled).toBeGreaterThan(previous); previous = sampled; } }); it("keeps pixels outside the rounded corner neutral and unmasked", () => { expect(refraction(0, 0)).toEqual({ r: 128, g: 128 }); expect(alpha("highlight", 0, 0)).toBe(0); }); it("keeps every edge feature about a point wide", () => { expect(alpha("highlight", centre, 0)).toBeGreaterThan(200); expect(alpha("highlight", centre, 3)).toBe(0); expect(alpha("shade", 0, centre)).toBeGreaterThan(200); expect(alpha("shade", 3, centre)).toBe(0); }); it("lights the top and bottom rims and darkens only the sides", () => { expect(alpha("highlight", centre, 0)).toBeGreaterThan(alpha("highlight", 0, centre) * 4); expect(alpha("shade", centre, 0)).toBe(0); }); it("glows just inside the top rim more than the bottom, fading by its reach", () => { expect(alpha("glow", centre, 2)).toBeGreaterThan(alpha("glow", centre, 52)); expect(alpha("glow", centre, DEFAULT_GEOMETRY.glowReach + 1)).toBe(0); }); it("draws the darkened edge as a contour all the way round, and none without one", () => { const lens = createGlassSlices(27, 1); const edge = (x: number, y: number) => lens.masks.edge[(y * lens.size + x) * 4 + 3] ?? 0; expect(edge(centre, 0)).toBeGreaterThan(150); expect(edge(0, centre)).toBeGreaterThan(150); expect(edge(centre, 2)).toBe(0); expect(slices.masks.edge.every(value => value === 0)).toBe(true); }); it("gathers the hairline into a glint at the top-left, fainter at the bottom-right", () => { const lens = createGlassSlices(27, 1, { edge: 0 }); const highlight = (x: number, y: number) => lens.masks.highlight[(y * lens.size + x) * 4 + 3] ?? 0; // Points on the rim at 45° in each corner. const topLeft = highlight(8, 8); const bottomRight = highlight(46, 46); const topRight = highlight(46, 8); expect(topLeft).toBeGreaterThan(bottomRight); expect(bottomRight).toBeGreaterThan(topRight); }); it("bends a gentle lens less steeply at the rim", () => { const steepest = (maxFall: number) => { const lens = createGlassSlices(27, 1, { maxFall }); let fall = 0; for (let row = 0; row < 27; row++) { const at = (y: number) => lens.refraction[(y * lens.size + 27) * 4 + 1] ?? 128; fall = Math.max(fall, ((at(row) - at(row + 1)) / 255) * lens.scale); } return fall; }; expect(steepest(0.3)).toBeLessThan(steepest(0.8)); expect(steepest(0.3)).toBeLessThanOrEqual(0.3 + 0.1); }); it("refracts only where the rim bends, so the body is the page's own pixels", () => { const lens = createGlassSlices(27, 1); const refract = (x: number, y: number) => lens.masks.refract[(y * lens.size + x) * 4 + 3] ?? 0; expect(refract(centre, 2)).toBeGreaterThan(200); expect(refract(2, centre)).toBeGreaterThan(200); expect(refract(centre, centre)).toBe(0); expect(refract(0, 0)).toBe(0); }); it("keeps the top and bottom rims of small glass short of its middle", () => { const green = (geometry: { rimShare?: number }) => { const lens = createGlassSlices(20, 1, geometry); return lens.refraction[(12 * lens.size + 20) * 4 + 1] ?? 128; }; // 12.5px in: past 0.6 of the 20px radius, inside a full 17px bezel. expect(green({})).toBe(128); expect(green({ rimShare: 1 })).toBeGreaterThan(128); }); }); /** A rounded square path, its corners drawn as cubic quarter circles. */ function roundedSquare(side: number, radius: number) { const k = radius * (1 - 0.5523); const far = side - radius; return [ `M0 ${radius}C0 ${k} ${k} 0 ${radius} 0H${far}`, `C${side - k} 0 ${side} ${k} ${side} ${radius}V${far}`, `C${side} ${side - k} ${side - k} ${side} ${far} ${side}H${radius}`, `C${k} ${side} 0 ${side - k} 0 ${far}Z`, ].join(""); } // A folder's front flap: a tab rising on the left, the rest a step lower. const FLAP = "M0 25C0 11.1929 11.1929 0 25 0H136.084C143.044 0 149.689 2.90139 154.42 8.00608L178.08 33.5343C182.811 38.639 189.456 41.5404 196.416 41.5404H296C309.807 41.5404 321 52.7333 321 66.5404V216C321 229.807 309.807 241 296 241H25C11.1929 241 0 229.807 0 216V25Z"; describe("createShapedGlass", () => { it("draws a rounded square as the slices do", () => { const shaped = createShapedGlass(roundedSquare(55, 27), 55, 55, 27, 1, { bezel: 16, edge: 0.75, glint: 0.4 }); const reference = createGlassSlices(27, 1, { bezel: 16, edge: 0.75, glint: 0.4 }); expect(shaped.width).toBe(reference.size); expect(shaped.scale).toBeCloseTo(reference.scale, 5); const differences = (a: Uint8ClampedArray, b: Uint8ClampedArray) => a.map((value, index) => Math.abs(value - (b[index] ?? 0))); const mean = (values: Uint8ClampedArray) => values.reduce((sum, value) => sum + value, 0) / values.length; // The outline is sampled, so a few pixels on the steepest part of the rim land a level or two apart. expect(Math.max(...differences(shaped.refraction, reference.refraction))).toBeLessThanOrEqual(10); expect(mean(differences(shaped.refraction, reference.refraction))).toBeLessThan(0.5); for (const layer of ["highlight", "shade", "glow", "edge", "refract"] as const) { expect(mean(differences(shaped.masks[layer], reference.masks[layer]))).toBeLessThan(0.5); } }); it("follows a tab and the step beside it", () => { const flap = createShapedGlass(FLAP, 321, 241, 25, 1); const at = (x: number, y: number) => { const index = (y * flap.width + x) * 4; return { g: flap.refraction[index + 1], rim: flap.masks.highlight[index + 3] ?? 0 }; }; // The tab's top rim and the lower top rim beside it both bend down toward the middle. expect(at(80, 1).g).toBeGreaterThan(128); expect(at(250, 43).g).toBeGreaterThan(128); // Above the step, beside the tab, is outside the glass. expect(at(250, 20)).toEqual({ g: 128, rim: 0 }); // The middle is flat. expect(at(160, 140).g).toBe(128); }); }); describe("sliceRects", () => { it("covers the square without overlap", () => { const covered = sliceRects({ radius: 2, pixelRatio: 1 }).reduce((sum, [, , w, h]) => sum + w * h, 0); // A 5px square: everything but the one-pixel centre. expect(covered).toBe(24); }); });