Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| 6 | slices: fixed corners and stretched edges keep it exact at any size, so the glass |
| 7 | never has to switch off while its box resizes. Glass of any other outline is |
| 8 | rendered whole, by the same shader, from an SVG path (createShapedGlass). */ |
| 9 |
| 10 | const PROFILE_STEPS = 128; |
| 11 | // The bottom-right glint, relative to the top-left one: the light's return off the far curve. |
| 12 | const GLINT_RETURN = 0.6; |
| 13 |
| 14 | /** Slice order shared by masks and the refraction filter. */ |
| 15 | export const SLICES = ["tl", "tr", "bl", "br", "t", "b", "l", "r"] as const; |
| 16 |
| 17 | export type GlassGeometry = { |
| 18 | /** Width of the refracting rim along the top and bottom, in CSS pixels. The centre is flat. */ |
| 19 | bezel: number; |
| 20 | /** Glass depth under the top and bottom rims, in CSS pixels. Deeper glass bends further. */ |
| 21 | thickness: number; |
| 22 | /** Width of the refracting rim at the sides and rounded ends, in CSS pixels. */ |
| 23 | sideBezel: number; |
| 24 | /** Glass depth under the sides and rounded ends, in CSS pixels. */ |
| 25 | sideThickness: number; |
| 26 | refractiveIndex: number; |
| 27 | /** Rim hairline width, in CSS pixels. */ |
| 28 | hairline: number; |
| 29 | /** Share of the hairline's strength that reaches the sides (0–1). */ |
| 30 | hairlineSides: number; |
| 31 | /** Width of the darkened sides, in CSS pixels. */ |
| 32 | shadeWidth: number; |
| 33 | /** How far the glow reaches in from the top and bottom rims, in CSS pixels. */ |
| 34 | glowReach: number; |
| 35 | /** Bottom glow strength relative to the top (0–1). */ |
| 36 | glowBottom: number; |
| 37 | /** |
| 38 | * Steepest the rim's shift may fall per pixel inward (0–1). Past 1 the lens folds |
| 39 | * content into mirrored echoes. Where it falls, the rim magnifies what is beneath |
| 40 | * by 1 / (1 - maxFall), so lower values bend more gently and keep text crossing |
| 41 | * the rim from smearing. |
| 42 | */ |
| 43 | maxFall: number; |
| 44 | /** |
| 45 | * Most of the corner radius the top and bottom rims may take (0–1). On small |
| 46 | * glass a full-width bezel reaches the middle, so the whole body is rim and |
| 47 | * magnifies what is beneath; iOS keeps the bend to a band at the edge and the |
| 48 | * middle shows the page as it is. |
| 49 | */ |
| 50 | rimShare: number; |
| 51 | /** |
| 52 | * Width of iOS 27's darkened edge, in CSS pixels: a contour where the glass meets |
| 53 | * the air, with the hairline moved just inside it. 0 leaves it out. |
| 54 | */ |
| 55 | edge: number; |
| 56 | /** |
| 57 | * Share of the hairline's light that gathers into glints where the light catches |
| 58 | * the curve, at the top-left and, fainter, the bottom-right (0–1). 0 keeps an even |
| 59 | * band along the top and bottom. |
| 60 | */ |
| 61 | glint: number; |
| 62 | }; |
| 63 |
| 64 | /** |
| 65 | * Proportions measured from Apple's iOS 27 press images, then tuned by eye. Sharp |
| 66 | * as the page around it, as iOS's glass is: the rims stop short of the middle of |
| 67 | * small glass and magnify what is beneath at most 1.8 times. The glow inside the |
| 68 | * bottom rim is nearly the top's, as the light iOS 27's glass gathers along its |
| 69 | * lower edge is. |
| 70 | */ |
| 71 | export const DEFAULT_GEOMETRY: GlassGeometry = { |
| 72 | bezel: 17, |
| 73 | thickness: 24, |
| 74 | sideBezel: 27, |
| 75 | sideThickness: 44, |
| 76 | refractiveIndex: 1.5, |
| 77 | hairline: 1, |
| 78 | hairlineSides: 0.15, |
| 79 | shadeWidth: 1, |
| 80 | glowReach: 8, |
| 81 | glowBottom: 0.8, |
| 82 | // On a 40px capsule a full rim reached the middle, magnifying up to five times, |
| 83 | // and Chromium's nearest-pixel displacement showed every page pixel it stretched |
| 84 | // as a block. |
| 85 | maxFall: 0.45, |
| 86 | rimShare: 0.6, |
| 87 | edge: 0.75, |
| 88 | glint: 0.4, |
| 89 | }; |
| 90 |
| 91 | /** |
| 92 | * The rim for a lens about a control high, like a segmented control's thumb: the |
| 93 | * rounded ends bend over their whole radius, and gently, so a label crossing the |
| 94 | * rim bends a little rather than breaking into a doubled, stair-stepped piece. |
| 95 | * Pair with the `lens` variant. |
| 96 | */ |
| 97 | export const LENS_GEOMETRY: GlassGeometry = { |
| 98 | ...DEFAULT_GEOMETRY, |
| 99 | bezel: 9, |
| 100 | thickness: 5, |
| 101 | sideBezel: 18, |
| 102 | sideThickness: 9, |
| 103 | glowReach: 5, |
| 104 | glowBottom: 0.5, |
| 105 | maxFall: 0.22, |
| 106 | glint: 0, |
| 107 | }; |
| 108 |
| 109 | /** |
| 110 | * Masked layers over the glass body: rim hairline, darkened sides, inner glow, outer |
| 111 | * contour, and the refraction, which bends only where the rim does. The rest of the |
| 112 | * body is the page as it is, never resampled by the displacement, which takes the |
| 113 | * nearest pixel and on some GPUs drew a flat body visibly coarser than the page. |
| 114 | */ |
| 115 | export type GlassMask = "highlight" | "shade" | "glow" | "edge" | "refract"; |
| 116 | export const MASKS: readonly GlassMask[] = ["highlight", "shade", "glow", "edge", "refract"]; |
| 117 |
| 118 | function smoothstep(from: number, to: number, value: number) { |
| 119 | const t = Math.min(1, Math.max(0, (value - from) / (to - from))); |
| 120 | return t * t * (3 - 2 * t); |
| 121 | } |
| 122 |
| 123 | /** Slope of the convex squircle y = (1 - (1 - x)^4)^(1/4), flat once x reaches 1. */ |
| 124 | function squircleSlope(x: number) { |
| 125 | const clamped = Math.min(Math.max(x, 1e-3), 1); |
| 126 | return (1 - clamped) ** 3 * (1 - (1 - clamped) ** 4) ** -0.75; |
| 127 | } |
| 128 |
| 129 | /** Lateral shift across one rim cross-section, from the outer edge inward. */ |
| 130 | function refractionProfile(bezel: number, thickness: number, refractiveIndex: number, maxFall: number) { |
| 131 | const profile = new Float32Array(PROFILE_STEPS + 1); |
| 132 | for (let step = 0; step <= PROFILE_STEPS; step++) { |
| 133 | const x = Math.max(step / PROFILE_STEPS, 1e-3); |
| 134 | const height = (1 - (1 - x) ** 4) ** 0.25; |
| 135 | // A straight-down view ray meets the tilted surface, refracts (Snell), and |
| 136 | // travels through the remaining depth to the backdrop. |
| 137 | const incidence = Math.atan((thickness / bezel) * squircleSlope(x)); |
| 138 | const refracted = Math.asin(Math.sin(incidence) / refractiveIndex); |
| 139 | profile[step] = thickness * height * Math.tan(incidence - refracted); |
| 140 | } |
| 141 | // Keep the lens one-to-one. Where the shift falls faster than the rim moves inward, |
| 142 | // neighbouring pixels sample in reverse order and content folds into mirrored |
| 143 | // echoes. Capping the fall compresses content toward the rim instead. |
| 144 | const fallPerStep = (maxFall * bezel) / PROFILE_STEPS; |
| 145 | for (let step = PROFILE_STEPS - 1; step >= 0; step--) { |
| 146 | profile[step] = Math.min(profile[step] ?? 0, (profile[step + 1] ?? 0) + fallPerStep); |
| 147 | } |
| 148 | let max = 0; |
| 149 | for (const shift of profile) max = Math.max(max, shift); |
| 150 | return { profile, max }; |
| 151 | } |
| 152 |
| 153 | type GlassPixels = { |
| 154 | /** feDisplacementMap input: red and green carry X and Y around 128. */ |
| 155 | refraction: Uint8ClampedArray; |
| 156 | /** Alpha masks for the layers over the body. */ |
| 157 | masks: Record<GlassMask, Uint8ClampedArray>; |
| 158 | }; |
| 159 |
| 160 | function glassPixels(count: number): GlassPixels { |
| 161 | return { |
| 162 | refraction: new Uint8ClampedArray(count * 4), |
| 163 | masks: { |
| 164 | highlight: new Uint8ClampedArray(count * 4), |
| 165 | shade: new Uint8ClampedArray(count * 4), |
| 166 | glow: new Uint8ClampedArray(count * 4), |
| 167 | edge: new Uint8ClampedArray(count * 4), |
| 168 | refract: new Uint8ClampedArray(count * 4), |
| 169 | }, |
| 170 | }; |
| 171 | } |
| 172 |
| 173 | /** |
| 174 | * Shades glass whose corners have this radius one pixel at a time, from how deep |
| 175 | * the pixel is inside the edge (negative outside it) and the edge's outward normal |
| 176 | * nearest it. |
| 177 | */ |
| 178 | function glassShader(cornerRadius: number, pixelRatio: number, geometry: Partial<GlassGeometry>, { refraction, masks }: GlassPixels) { |
| 179 | const { |
| 180 | bezel, |
| 181 | thickness, |
| 182 | sideBezel, |
| 183 | sideThickness, |
| 184 | refractiveIndex, |
| 185 | hairline, |
| 186 | hairlineSides, |
| 187 | shadeWidth, |
| 188 | glowReach, |
| 189 | glowBottom, |
| 190 | maxFall, |
| 191 | rimShare, |
| 192 | edge, |
| 193 | glint, |
| 194 | } = { ...DEFAULT_GEOMETRY, ...geometry }; |
| 195 | const radius = Math.max(1, Math.floor(cornerRadius)); |
| 196 | // Like real glass, the rounded ends bend light far more than the long straight edges, |
| 197 | // which keep content crossing them straight. Each has its own lens profile, blended |
| 198 | // around the curve. |
| 199 | const rim = Math.min(bezel, radius * rimShare); |
| 200 | const sideRim = Math.min(sideBezel, radius); |
| 201 | const edges = refractionProfile(rim, thickness, refractiveIndex, maxFall); |
| 202 | const ends = refractionProfile(sideRim, sideThickness, refractiveIndex, maxFall); |
| 203 | const max = Math.max(edges.max, ends.max); |
| 204 | const shiftAt = (lens: typeof edges, width: number, inset: number) => |
| 205 | inset < width ? (lens.profile[Math.round((inset / width) * PROFILE_STEPS)] ?? 0) : 0; |
| 206 | // With a darkened edge, the hairline starts just inside it. |
| 207 | const lit = edge * 0.6; |
| 208 |
| 209 | return { |
| 210 | radius, |
| 211 | // feDisplacementMap moves by scale * (channel - 0.5); a full swing is ±127/255. |
| 212 | scale: (max * 255) / 127, |
| 213 | /** Shades the pixel at byte `index` of every map. */ |
| 214 | shade(index: number, inset: number, nx: number, ny: number) { |
| 215 | let dx = 0; |
| 216 | let dy = 0; |
| 217 | if (inset > 0 && max > 0) { |
| 218 | const sideShare = nx * nx; |
| 219 | const shift = shiftAt(edges, rim, inset) * (1 - sideShare) + shiftAt(ends, sideRim, inset) * sideShare; |
| 220 | // Convex glass bends rays toward the centre, so the rim shows backdrop from further in. |
| 221 | dx = (-nx * shift) / max; |
| 222 | dy = (-ny * shift) / max; |
| 223 | } |
| 224 | refraction[index] = 128 + dx * 127; |
| 225 | refraction[index + 1] = 128 + dy * 127; |
| 226 | refraction[index + 2] = 128; |
| 227 | refraction[index + 3] = 255; |
| 228 | // The refraction shows only where it bends. |
| 229 | masks.refract[index + 3] = 255 * Math.min(1, Math.hypot(dx, dy) * 40); |
| 230 |
| 231 | if (inset <= 0) return; |
| 232 | // Proportions measured from Apple's iOS 27 press images: every edge feature is |
| 233 | // about a point wide, and only the glow reaches further in. |
| 234 | const cover = Math.min(1, inset * pixelRatio + 0.5); |
| 235 | const vertical = ny * ny; |
| 236 | // A one-point hairline where the rim meets the air, lit from above and below. |
| 237 | // Glints gather part of it where a light from the top-left catches the curve. |
| 238 | const bands = hairlineSides + (1 - hairlineSides) * Math.abs(ny) ** 1.5; |
| 239 | const facing = (-nx - ny) * Math.SQRT1_2; |
| 240 | const glints = Math.max(facing, 0) ** 3 + GLINT_RETURN * Math.max(-facing, 0) ** 3; |
| 241 | const reach = edge > 0 ? smoothstep(lit * 0.5, lit * 1.5, inset) : 1; |
| 242 | masks.highlight[index + 3] = 255 * cover * reach |
| 243 | * (1 - smoothstep(lit + 0.6 * hairline, lit + 1.6 * hairline, inset)) |
| 244 | * Math.max(bands * (1 - glint), Math.min(1, glints * glint * 2.5)); |
| 245 | // iOS 27's darkened edge: a contour where the glass meets the air, all the way round. |
| 246 | if (edge > 0) masks.edge[index + 3] = 255 * cover * (1 - smoothstep(0.5 * edge, 1.1 * edge, inset)); |
| 247 | // The darkened edge, on the sides only and about a point and a half wide. |
| 248 | masks.shade[index + 3] = 255 * cover * (1 - smoothstep(0.8 * shadeWidth, 2 * shadeWidth, inset)) * nx * nx; |
| 249 | // Light gathered just inside the top and bottom rims, fading over about five points. |
| 250 | masks.glow[index + 3] = 255 * cover * smoothstep(0.8, 1.6, inset) * (1 - smoothstep(1.6, Math.max(glowReach, 1.7), inset)) |
| 251 | * vertical * (ny < 0 ? 1 : glowBottom); |
| 252 | }, |
| 253 | }; |
| 254 | } |
| 255 |
| 256 | export type GlassSlices = GlassPixels & { |
| 257 | /** Corner radius the slices were built for, in whole CSS pixels. */ |
| 258 | radius: number; |
| 259 | /** Square side in device pixels: two corners plus one pixel of straight edge. */ |
| 260 | size: number; |
| 261 | pixelRatio: number; |
| 262 | /** feDisplacementMap scale that restores the map to pixels. */ |
| 263 | scale: number; |
| 264 | }; |
| 265 |
| 266 | /** |
| 267 | * Renders a rounded square of side 2r + 1. Its corners are the glass's corners and |
| 268 | * its centre row and column are its straight edges, so any size can be assembled |
| 269 | * from eight slices. |
| 270 | */ |
| 271 | export function createGlassSlices( |
| 272 | cornerRadius: number, |
| 273 | pixelRatio = 1, |
| 274 | geometry: Partial<GlassGeometry> = {}, |
| 275 | ): GlassSlices { |
| 276 | const radius = Math.max(1, Math.floor(cornerRadius)); |
| 277 | const side = radius * 2 + 1; |
| 278 | const size = side * pixelRatio; |
| 279 | const pixels = glassPixels(size * size); |
| 280 | const glass = glassShader(radius, pixelRatio, geometry, pixels); |
| 281 | const half = side / 2; |
| 282 | const straight = half - radius; |
| 283 |
| 284 | for (let row = 0; row < size; row++) { |
| 285 | for (let column = 0; column < size; column++) { |
| 286 | const x = (column + 0.5) / pixelRatio - half; |
| 287 | const y = (row + 0.5) / pixelRatio - half; |
| 288 | // Signed distance to the rounded square; `inset` is depth inside its edge. |
| 289 | const qx = Math.abs(x) - straight; |
| 290 | const qy = Math.abs(y) - straight; |
| 291 | const cornerX = Math.max(qx, 0); |
| 292 | const cornerY = Math.max(qy, 0); |
| 293 | const corner = Math.hypot(cornerX, cornerY); |
| 294 | const inset = radius - corner - Math.min(Math.max(qx, qy), 0); |
| 295 | const nx = (corner > 0 ? cornerX / corner : qx > qy ? 1 : 0) * (Math.sign(x) || 1); |
| 296 | const ny = (corner > 0 ? cornerY / corner : qx > qy ? 0 : 1) * (Math.sign(y) || 1); |
| 297 | glass.shade((row * size + column) * 4, inset, nx, ny); |
| 298 | } |
| 299 | } |
| 300 | return { radius, size, pixelRatio, ...pixels, scale: glass.scale }; |
| 301 | } |
| 302 |
| 303 | export type ShapedGlass = GlassPixels & { |
| 304 | /** Map size in device pixels. */ |
| 305 | width: number; |
| 306 | height: number; |
| 307 | pixelRatio: number; |
| 308 | /** feDisplacementMap scale that restores the map to pixels. */ |
| 309 | scale: number; |
| 310 | }; |
| 311 |
| 312 | /** |
| 313 | * Glass for an outline no rounded rectangle makes, rendered whole at one size: an |
| 314 | * SVG path of absolute M, L, H, V, C and Z commands, in CSS pixels. Each pixel is |
| 315 | * shaded as the slices' are, from its distance to the nearest point of the outline |
| 316 | * and the outline's normal there. The rims stay within `cornerRadius`, as a rounded |
| 317 | * rectangle's stay within its corners. |
| 318 | */ |
| 319 | export function createShapedGlass( |
| 320 | path: string, |
| 321 | width: number, |
| 322 | height: number, |
| 323 | cornerRadius: number, |
| 324 | pixelRatio = 1, |
| 325 | geometry: Partial<GlassGeometry> = {}, |
| 326 | ): ShapedGlass { |
| 327 | const columns = Math.ceil(width * pixelRatio); |
| 328 | const rows = Math.ceil(height * pixelRatio); |
| 329 | const pixels = glassPixels(columns * rows); |
| 330 | const glass = glassShader(cornerRadius, pixelRatio, geometry, pixels); |
| 331 | // Points a quarter of a device pixel apart, so the nearest is as near as the true outline. |
| 332 | const outline = outlinePoints(path, 0.25 / pixelRatio); |
| 333 | const nearest = nearestOutlinePoints(outline, columns, rows, pixelRatio); |
| 334 |
| 335 | for (let row = 0; row < rows; row++) { |
| 336 | for (let column = 0; column < columns; column++) { |
| 337 | const pixel = row * columns + column; |
| 338 | const point = nearest[pixel] ?? 0; |
| 339 | const nx = outline.nx[point] ?? 0; |
| 340 | const ny = outline.ny[point] ?? 0; |
| 341 | const dx = (column + 0.5) / pixelRatio - (outline.x[point] ?? 0); |
| 342 | const dy = (row + 0.5) / pixelRatio - (outline.y[point] ?? 0); |
| 343 | // Inside, the pixel lies behind the outline's outward normal. |
| 344 | const distance = Math.hypot(dx, dy); |
| 345 | glass.shade(pixel * 4, dx * nx + dy * ny < 0 ? distance : -distance, nx, ny); |
| 346 | } |
| 347 | } |
| 348 | return { width: columns, height: rows, pixelRatio, ...pixels, scale: glass.scale }; |
| 349 | } |
| 350 |
| 351 | /** The path's outline as evenly spaced points with outward normals, in CSS pixels. */ |
| 352 | function outlinePoints(path: string, spacing: number) { |
| 353 | const polygon: [number, number][] = []; |
| 354 | const tokens = path.match(/[MLHVCZ]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []; |
| 355 | let command = ""; |
| 356 | let x = 0; |
| 357 | let y = 0; |
| 358 | for (let index = 0; index < tokens.length; ) { |
| 359 | const token = tokens[index] ?? ""; |
| 360 | if (/^[a-z]$/i.test(token)) { |
| 361 | command = token.toUpperCase(); |
| 362 | index++; |
| 363 | if (command === "Z") continue; |
| 364 | } |
| 365 | const take = () => Number(tokens[index++]); |
| 366 | if (command === "M" || command === "L") { |
| 367 | x = take(); |
| 368 | y = take(); |
| 369 | } else if (command === "H") { |
| 370 | x = take(); |
| 371 | } else if (command === "V") { |
| 372 | y = take(); |
| 373 | } else if (command === "C") { |
| 374 | const [x1, y1, x2, y2, x3, y3] = [take(), take(), take(), take(), take(), take()]; |
| 375 | const [x0, y0] = [x, y]; |
| 376 | // Fine enough that a 25px corner's chords stray well under a device pixel. |
| 377 | for (let step = 1; step < 32; step++) { |
| 378 | const t = step / 32; |
| 379 | const u = 1 - t; |
| 380 | polygon.push([ |
| 381 | u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3, |
| 382 | u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3, |
| 383 | ]); |
| 384 | } |
| 385 | x = x3; |
| 386 | y = y3; |
| 387 | } else { |
| 388 | index++; |
| 389 | continue; |
| 390 | } |
| 391 | polygon.push([x, y]); |
| 392 | } |
| 393 |
| 394 | // The outward normal is on the right of a clockwise outline (y down) and on the left otherwise. |
| 395 | let area = 0; |
| 396 | polygon.forEach(([ax, ay], index) => { |
| 397 | const [bx, by] = polygon[(index + 1) % polygon.length] ?? [ax, ay]; |
| 398 | area += ax * by - bx * ay; |
| 399 | }); |
| 400 | const turn = area > 0 ? 1 : -1; |
| 401 | const points = { x: [] as number[], y: [] as number[], nx: [] as number[], ny: [] as number[] }; |
| 402 | polygon.forEach(([ax, ay], index) => { |
| 403 | const [bx, by] = polygon[(index + 1) % polygon.length] ?? [ax, ay]; |
| 404 | const length = Math.hypot(bx - ax, by - ay); |
| 405 | if (length === 0) return; |
| 406 | const steps = Math.ceil(length / spacing); |
| 407 | for (let step = 0; step < steps; step++) { |
| 408 | const t = step / steps; |
| 409 | points.x.push(ax + (bx - ax) * t); |
| 410 | points.y.push(ay + (by - ay) * t); |
| 411 | points.nx.push((turn * (by - ay)) / length); |
| 412 | points.ny.push((-turn * (bx - ax)) / length); |
| 413 | } |
| 414 | }); |
| 415 | return points; |
| 416 | } |
| 417 |
| 418 | /** |
| 419 | * The nearest outline point to every pixel centre: seeded where the outline crosses |
| 420 | * the grid, then carried across it in two sweeps (8SSEDT). |
| 421 | */ |
| 422 | function nearestOutlinePoints(outline: ReturnType<typeof outlinePoints>, columns: number, rows: number, pixelRatio: number) { |
| 423 | const nearest = new Int32Array(columns * rows).fill(-1); |
| 424 | const distance = new Float32Array(columns * rows).fill(Infinity); |
| 425 | const offer = (column: number, row: number, point: number) => { |
| 426 | if (column < 0 || row < 0 || column >= columns || row >= rows) return; |
| 427 | const pixel = row * columns + column; |
| 428 | const dx = column + 0.5 - (outline.x[point] ?? 0) * pixelRatio; |
| 429 | const dy = row + 0.5 - (outline.y[point] ?? 0) * pixelRatio; |
| 430 | const squared = dx * dx + dy * dy; |
| 431 | if (squared < (distance[pixel] ?? Infinity)) { |
| 432 | distance[pixel] = squared; |
| 433 | nearest[pixel] = point; |
| 434 | } |
| 435 | }; |
| 436 | outline.x.forEach((pointX, point) => { |
| 437 | const column = Math.floor(pointX * pixelRatio); |
| 438 | const row = Math.floor((outline.y[point] ?? 0) * pixelRatio); |
| 439 | for (let dy = -1; dy <= 1; dy++) { |
| 440 | for (let dx = -1; dx <= 1; dx++) offer(column + dx, row + dy, point); |
| 441 | } |
| 442 | }); |
| 443 | const pull = (column: number, row: number, fromColumn: number, fromRow: number) => { |
| 444 | if (fromColumn < 0 || fromRow < 0 || fromColumn >= columns || fromRow >= rows) return; |
| 445 | const point = nearest[fromRow * columns + fromColumn] ?? -1; |
| 446 | if (point >= 0) offer(column, row, point); |
| 447 | }; |
| 448 | for (let row = 0; row < rows; row++) { |
| 449 | for (let column = 0; column < columns; column++) { |
| 450 | pull(column, row, column - 1, row); |
| 451 | pull(column, row, column - 1, row - 1); |
| 452 | pull(column, row, column, row - 1); |
| 453 | pull(column, row, column + 1, row - 1); |
| 454 | } |
| 455 | for (let column = columns - 1; column >= 0; column--) pull(column, row, column + 1, row); |
| 456 | } |
| 457 | for (let row = rows - 1; row >= 0; row--) { |
| 458 | for (let column = columns - 1; column >= 0; column--) { |
| 459 | pull(column, row, column + 1, row); |
| 460 | pull(column, row, column + 1, row + 1); |
| 461 | pull(column, row, column, row + 1); |
| 462 | pull(column, row, column - 1, row + 1); |
| 463 | } |
| 464 | for (let column = 0; column < columns; column++) pull(column, row, column - 1, row); |
| 465 | } |
| 466 | return nearest; |
| 467 | } |
| 468 |
| 469 | /** Device-pixel rectangles of the eight slices, in SLICES order. */ |
| 470 | export function sliceRects(slices: Pick<GlassSlices, "radius" | "pixelRatio">) { |
| 471 | const corner = slices.radius * slices.pixelRatio; |
| 472 | const edge = slices.pixelRatio; |
| 473 | const far = corner + edge; |
| 474 | return [ |
| 475 | [0, 0, corner, corner], [far, 0, corner, corner], [0, far, corner, corner], [far, far, corner, corner], |
| 476 | [corner, 0, edge, corner], [corner, far, edge, corner], [0, corner, corner, edge], [far, corner, corner, edge], |
| 477 | ] as const; |
| 478 | } |
| 479 |