/* The lens: a refraction map and four rim masks for a rounded box, computed from a physical profile instead of drawn by hand. Everything here is pure, so it runs in tests and workers as well as the page. The profile is rendered once as a rounded square of side 2r + 1, cut into nine slices: fixed corners and stretched edges keep it exact at any size, so the glass never has to switch off while its box resizes. */ const PROFILE_STEPS = 128; // The bottom-right glint, relative to the top-left one: the light's return off the far curve. const GLINT_RETURN = 0.6; /** Slice order shared by masks and the refraction filter. */ export const SLICES = ["tl", "tr", "bl", "br", "t", "b", "l", "r"] as const; export type GlassGeometry = { /** Width of the refracting rim along the top and bottom, in CSS pixels. The centre is flat. */ bezel: number; /** Glass depth under the top and bottom rims, in CSS pixels. Deeper glass bends further. */ thickness: number; /** Width of the refracting rim at the sides and rounded ends, in CSS pixels. */ sideBezel: number; /** Glass depth under the sides and rounded ends, in CSS pixels. */ sideThickness: number; refractiveIndex: number; /** Rim hairline width, in CSS pixels. */ hairline: number; /** Share of the hairline's strength that reaches the sides (0–1). */ hairlineSides: number; /** Width of the darkened sides, in CSS pixels. */ shadeWidth: number; /** How far the glow reaches in from the top and bottom rims, in CSS pixels. */ glowReach: number; /** Bottom glow strength relative to the top (0–1). */ glowBottom: number; /** * Steepest the rim's shift may fall per pixel inward (0–1). Past 1 the lens folds * content into mirrored echoes. Where it falls, the rim magnifies what is beneath * by 1 / (1 - maxFall), so lower values bend more gently and keep text crossing * the rim from smearing. */ maxFall: number; /** * Width of iOS 27's darkened edge, in CSS pixels: a contour where the glass meets * the air, with the hairline moved just inside it. 0 leaves it out. */ edge: number; /** * Share of the hairline's light that gathers into glints where the light catches * the curve, at the top-left and, fainter, the bottom-right (0–1). 0 keeps an even * band along the top and bottom. */ glint: number; }; /** Proportions measured from Apple's iOS 27 press images, then tuned by eye. */ export const DEFAULT_GEOMETRY: GlassGeometry = { bezel: 17, thickness: 24, sideBezel: 27, sideThickness: 44, refractiveIndex: 1.5, hairline: 1, hairlineSides: 0.15, shadeWidth: 1, glowReach: 8, glowBottom: 0.5, // The margin below 1 keeps neighbouring samples apart after 8-bit quantisation of the map. maxFall: 0.8, edge: 0.75, glint: 0.4, }; /** * The rim for a lens about a control high, like a segmented control's thumb: the * rounded ends bend over their whole radius, and gently, since at a capsule's * magnification labels crossing the rim smear into streaks. Pair with the `lens` * variant. */ export const LENS_GEOMETRY: GlassGeometry = { ...DEFAULT_GEOMETRY, bezel: 9, thickness: 8, sideBezel: 18, sideThickness: 16, glowReach: 5, maxFall: 0.3, glint: 0, }; /** Masked layers over the glass body: rim hairline, darkened sides, inner glow, outer contour. */ export type GlassMask = "highlight" | "shade" | "glow" | "edge"; export const MASKS: readonly GlassMask[] = ["highlight", "shade", "glow", "edge"]; function smoothstep(from: number, to: number, value: number) { const t = Math.min(1, Math.max(0, (value - from) / (to - from))); return t * t * (3 - 2 * t); } /** Slope of the convex squircle y = (1 - (1 - x)^4)^(1/4), flat once x reaches 1. */ function squircleSlope(x: number) { const clamped = Math.min(Math.max(x, 1e-3), 1); return (1 - clamped) ** 3 * (1 - (1 - clamped) ** 4) ** -0.75; } /** Lateral shift across one rim cross-section, from the outer edge inward. */ function refractionProfile(bezel: number, thickness: number, refractiveIndex: number, maxFall: number) { const profile = new Float32Array(PROFILE_STEPS + 1); for (let step = 0; step <= PROFILE_STEPS; step++) { const x = Math.max(step / PROFILE_STEPS, 1e-3); const height = (1 - (1 - x) ** 4) ** 0.25; // A straight-down view ray meets the tilted surface, refracts (Snell), and // travels through the remaining depth to the backdrop. const incidence = Math.atan((thickness / bezel) * squircleSlope(x)); const refracted = Math.asin(Math.sin(incidence) / refractiveIndex); profile[step] = thickness * height * Math.tan(incidence - refracted); } // Keep the lens one-to-one. Where the shift falls faster than the rim moves inward, // neighbouring pixels sample in reverse order and content folds into mirrored // echoes. Capping the fall compresses content toward the rim instead. const fallPerStep = (maxFall * bezel) / PROFILE_STEPS; for (let step = PROFILE_STEPS - 1; step >= 0; step--) { profile[step] = Math.min(profile[step] ?? 0, (profile[step + 1] ?? 0) + fallPerStep); } let max = 0; for (const shift of profile) max = Math.max(max, shift); return { profile, max }; } export type GlassSlices = { /** Corner radius the slices were built for, in whole CSS pixels. */ radius: number; /** Square side in device pixels: two corners plus one pixel of straight edge. */ size: number; pixelRatio: number; /** feDisplacementMap input: red and green carry X and Y around 128. */ refraction: Uint8ClampedArray; /** Alpha masks for the layers over the body. */ masks: Record; /** feDisplacementMap scale that restores the map to pixels. */ scale: number; }; /** * Renders a rounded square of side 2r + 1. Its corners are the glass's corners and * its centre row and column are its straight edges, so any size can be assembled * from eight slices. */ export function createGlassSlices( cornerRadius: number, pixelRatio = 1, geometry: Partial = {}, ): GlassSlices { const { bezel, thickness, sideBezel, sideThickness, refractiveIndex, hairline, hairlineSides, shadeWidth, glowReach, glowBottom, maxFall, edge, glint, } = { ...DEFAULT_GEOMETRY, ...geometry }; const radius = Math.max(1, Math.floor(cornerRadius)); const side = radius * 2 + 1; const size = side * pixelRatio; // Like real glass, the rounded ends bend light far more than the long straight edges, // which keep content crossing them straight. Each has its own lens profile, blended // around the curve. const rim = Math.min(bezel, radius); const sideRim = Math.min(sideBezel, radius); const edges = refractionProfile(rim, thickness, refractiveIndex, maxFall); const ends = refractionProfile(sideRim, sideThickness, refractiveIndex, maxFall); const max = Math.max(edges.max, ends.max); const shiftAt = (lens: typeof edges, width: number, inset: number) => inset < width ? (lens.profile[Math.round((inset / width) * PROFILE_STEPS)] ?? 0) : 0; const refraction = new Uint8ClampedArray(size * size * 4); const masks = { highlight: new Uint8ClampedArray(size * size * 4), shade: new Uint8ClampedArray(size * size * 4), glow: new Uint8ClampedArray(size * size * 4), edge: new Uint8ClampedArray(size * size * 4), }; const half = side / 2; const straight = half - radius; // With a darkened edge, the hairline starts just inside it. const lit = edge * 0.6; for (let row = 0; row < size; row++) { for (let column = 0; column < size; column++) { const x = (column + 0.5) / pixelRatio - half; const y = (row + 0.5) / pixelRatio - half; // Signed distance to the rounded square; `inset` is depth inside its edge. const qx = Math.abs(x) - straight; const qy = Math.abs(y) - straight; const cornerX = Math.max(qx, 0); const cornerY = Math.max(qy, 0); const corner = Math.hypot(cornerX, cornerY); const inset = radius - corner - Math.min(Math.max(qx, qy), 0); const nx = (corner > 0 ? cornerX / corner : qx > qy ? 1 : 0) * (Math.sign(x) || 1); const ny = (corner > 0 ? cornerY / corner : qx > qy ? 0 : 1) * (Math.sign(y) || 1); const index = (row * size + column) * 4; let dx = 0; let dy = 0; if (inset > 0 && max > 0) { const sideShare = nx * nx; const shift = shiftAt(edges, rim, inset) * (1 - sideShare) + shiftAt(ends, sideRim, inset) * sideShare; // Convex glass bends rays toward the centre, so the rim shows backdrop from further in. dx = (-nx * shift) / max; dy = (-ny * shift) / max; } refraction[index] = 128 + dx * 127; refraction[index + 1] = 128 + dy * 127; refraction[index + 2] = 128; refraction[index + 3] = 255; if (inset <= 0) continue; // Proportions measured from Apple's iOS 27 press images: every edge feature is // about a point wide, and only the glow reaches further in. const cover = Math.min(1, inset * pixelRatio + 0.5); const vertical = ny * ny; // A one-point hairline where the rim meets the air, lit from above and below. // Glints gather part of it where a light from the top-left catches the curve. const bands = hairlineSides + (1 - hairlineSides) * Math.abs(ny) ** 1.5; const facing = (-nx - ny) * Math.SQRT1_2; const glints = Math.max(facing, 0) ** 3 + GLINT_RETURN * Math.max(-facing, 0) ** 3; const reach = edge > 0 ? smoothstep(lit * 0.5, lit * 1.5, inset) : 1; masks.highlight[index + 3] = 255 * cover * reach * (1 - smoothstep(lit + 0.6 * hairline, lit + 1.6 * hairline, inset)) * Math.max(bands * (1 - glint), Math.min(1, glints * glint * 2.5)); // iOS 27's darkened edge: a contour where the glass meets the air, all the way round. if (edge > 0) masks.edge[index + 3] = 255 * cover * (1 - smoothstep(0.5 * edge, 1.1 * edge, inset)); // The darkened edge, on the sides only and about a point and a half wide. masks.shade[index + 3] = 255 * cover * (1 - smoothstep(0.8 * shadeWidth, 2 * shadeWidth, inset)) * nx * nx; // Light gathered just inside the top and bottom rims, fading over about five points. masks.glow[index + 3] = 255 * cover * smoothstep(0.8, 1.6, inset) * (1 - smoothstep(1.6, Math.max(glowReach, 1.7), inset)) * vertical * (ny < 0 ? 1 : glowBottom); } } // feDisplacementMap moves by scale * (channel - 0.5); a full swing is ±127/255. return { radius, size, pixelRatio, refraction, masks, scale: (max * 255) / 127 }; } /** Device-pixel rectangles of the eight slices, in SLICES order. */ export function sliceRects(slices: Pick) { const corner = slices.radius * slices.pixelRatio; const edge = slices.pixelRatio; const far = corner + edge; return [ [0, 0, corner, corner], [far, 0, corner, corner], [0, far, corner, corner], [far, far, corner, corner], [corner, 0, edge, corner], [corner, far, edge, corner], [0, corner, corner, edge], [far, corner, corner, edge], ] as const; }