diff --git a/libs/@hashintel/petrinaut/src/notifications/notifications-provider.tsx b/libs/@hashintel/petrinaut/src/notifications/notifications-provider.tsx index 6c6acb4844f..a281f1251ae 100644 --- a/libs/@hashintel/petrinaut/src/notifications/notifications-provider.tsx +++ b/libs/@hashintel/petrinaut/src/notifications/notifications-provider.tsx @@ -106,7 +106,7 @@ export const NotificationsProvider: React.FC = ({ )} > {notification.message} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx index 4b0a859bc02..cfb8b796647 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/bottom-bar.tsx @@ -122,8 +122,8 @@ export const BottomBar: React.FC = ({ refraction={{ radius: 8, blur: 3, - bezelWidth: 20, - glassThickness: 100, + edgeSize: 20, + thickness: 100, }} >
@@ -142,8 +142,8 @@ export const BottomBar: React.FC = ({ refraction={{ radius: 8, blur: 3, - bezelWidth: 20, - glassThickness: 100, + edgeSize: 20, + thickness: 100, }} >
diff --git a/libs/@hashintel/refractive/CLAUDE.md b/libs/@hashintel/refractive/CLAUDE.md new file mode 100644 index 00000000000..3f1afa05900 --- /dev/null +++ b/libs/@hashintel/refractive/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Package Overview + +`@hashintel/refractive` is a React library that applies refractive glass effects to components using SVG filters. It lives in the HASH monorepo at `libs/@hashintel/refractive`. + +## Commands + +```bash +# Build the library (outputs to dist/) +yarn build # or: turbo run build --filter '@hashintel/refractive' + +# Run Storybook for visual development (port 6006) +yarn dev + +# Build in watch mode for consumers +yarn dev:lib + +# Lint +yarn lint:eslint # oxlint (not ESLint) +yarn lint:tsc # tsgo (native TypeScript) + +# Fix lint issues +yarn fix:eslint +``` + +## Architecture + +The library exports two things from `src/main.ts`: + +1. **`refractive`** — a Proxy-based HOC that wraps any React component or HTML element (`refractive.div`, `refractive(MyComponent)`) to apply a refractive glass effect via `backdrop-filter: url(#filterId)`. +2. **Surface equation functions** (`convex`, `concave`, `convexCircle`, `lip`) — mathematical curves used to shape the bezel height profile. + +### Rendering Pipeline + +1. **`refractive` HOC** (`src/hoc/refractive.tsx`) — Observes element size via `ResizeObserver`, renders a hidden `` containing the `` alongside the wrapped component. +2. **`Filter`** (`src/components/filter.tsx`) — Orchestrates the SVG filter graph: + - Computes a **displacement map** (refraction) and **specular map** (highlights) as `ImageData` bitmaps. + - Feeds them to `CompositeParts` which slices each bitmap into 9-patch parts (4 corners, 4 edges, 1 center) for stretching to any element size. + - Chains SVG filter primitives: `feGaussianBlur` → `feDisplacementMap` → specular overlay via `feComposite`. +3. **Map generators** (`src/maps/`): + - `displacement-map.ts` — Computes per-pixel refraction offsets using Snell's law, with configurable glass thickness, bezel width, and refractive index. Uses `calculateRoundedSquareMap` for distance-to-border computation. + - `specular.ts` — Computes specular highlight intensity based on dot product with a light angle vector. Uses `calculateCircleMap`. + - `calculate-rounded-square-map.ts` / `calculate-circle-map.ts` — Iterate over pixels, compute distance-from-border fields, and call a `processPixel` callback to fill RGBA buffers. +4. **Helpers** (`src/helpers/`): + - `split-imagedata-to-parts.ts` — Slices an `ImageData` into the 9-patch data URLs. + - `image-data-to-url.ts` — Converts `ImageData` to a base64 data URL via `OffscreenCanvas`. + +### Key Design Decisions + +- **9-patch compositing**: Displacement/specular maps are generated once at the corner size, then split into 9 regions and stretched via `` + `` to handle any element dimensions without regenerating the full bitmap. +- **Pixel ratio**: Maps are rendered at `pixelRatio: 6` for quality, independent of device pixel ratio. +- **ResizeObserver dependency**: Currently required to pass explicit width/height to the SVG filter. There's a TODO (FE-43) to switch to `objectBoundingBox` filter units to eliminate this. + +## Roadmap: Toward a Fully Declarative SVG Filter + +The library is evolving from rasterized bitmap computation toward a purely declarative SVG filter. Each stage reduces computational cost and coupling to raster images. + +1. **Per-parameter rasterization** (current default) — Full bitmap recomputed on every parameter change. Existing: `Filter` + `CompositeParts`. + +2. **Polar coordinate indirection** — Decouple shape geometry from the optical transfer function. Compute a single "polar field" image encoding (angle, distance-to-border) per shape, then apply the 1D displacement lookup via SVG filter math (`feComponentTransfer` table + trig via `feColorMatrix`). The shape image is reused across optical parameter changes, reducing recomputation. Existing starting point: `FilterPolar` + `polar-distance-map.ts`. Could further reuse a single high-resolution polar field scaled down per border-radius. + +3. **`objectBoundingBox` filter units** — Eliminate `ResizeObserver` by using relative (percentage-based) filter coordinates so the filter auto-scales with the element. Existing: `FilterOBB` + `CompositeImage`. Orthogonal to stages 2 and 4; can be combined with either. + +4. **Fully procedural SVG filter** — Compute the distance field, displacement, and specular entirely within the SVG filter graph (e.g., turbulence, lighting, morphology primitives). No raster images at all. This would make the filter resolution-independent and applicable to arbitrary shapes, not just rounded rectangles. + +## Tooling Notes + +- Linting uses **oxlint**, not ESLint. The config is in `.oxlintrc.json`. +- Type checking uses **tsgo** (native TypeScript preview), not `tsc`. +- Build uses **Vite 8** with **Rolldown** bundler and `rolldown-plugin-dts` for type declarations. +- React Compiler (babel-plugin-react-compiler) is enabled for build optimization. +- Storybook 10 with `@storybook/react-vite` framework; stories are in `stories/`. diff --git a/libs/@hashintel/refractive/src/components/composite/image.tsx b/libs/@hashintel/refractive/src/components/composite/image.tsx new file mode 100644 index 00000000000..9df58d41faa --- /dev/null +++ b/libs/@hashintel/refractive/src/components/composite/image.tsx @@ -0,0 +1,83 @@ +import type { Parts } from "../../helpers/split-imagedata-to-parts"; + +/** + * Builds an SVG string containing all 9 image parts composited together, + * then returns it as a base64 data URL. + * + * The SVG has no viewBox and no explicit dimensions, so it adapts to whatever + * size the feImage renders it at. Corners are placed at fixed pixel sizes using + * nested SVGs with overflow="visible" and percentage-based positioning. + */ +export function buildCompositeSvgUrl( + parts: Parts, + cornerWidth: number, + hideTop?: boolean, + hideBottom?: boolean, + hideLeft?: boolean, + hideRight?: boolean, +): string { + const cw = cornerWidth; + const elements: string[] = []; + + // Center (base layer, stretched to fill) + elements.push( + ``, + ); + + // Edges + if (!hideTop) { + elements.push( + ``, + ); + } + if (!hideLeft) { + elements.push( + ``, + ); + } + if (!hideRight) { + elements.push( + `` + + `` + + ``, + ); + } + if (!hideBottom) { + elements.push( + `` + + `` + + ``, + ); + } + + // Corners + if (!hideTop && !hideLeft) { + elements.push( + ``, + ); + } + if (!hideTop && !hideRight) { + elements.push( + `` + + `` + + ``, + ); + } + if (!hideBottom && !hideLeft) { + elements.push( + `` + + `` + + ``, + ); + } + if (!hideBottom && !hideRight) { + elements.push( + `` + + `` + + ``, + ); + } + + const svg = `${elements.join("")}`; + return `data:image/svg+xml;base64,${btoa(svg)}`; +} diff --git a/libs/@hashintel/refractive/src/components/composite-parts.tsx b/libs/@hashintel/refractive/src/components/composite/parts.tsx similarity index 73% rename from libs/@hashintel/refractive/src/components/composite-parts.tsx rename to libs/@hashintel/refractive/src/components/composite/parts.tsx index d8c19ddb07a..f9cb7707cfc 100644 --- a/libs/@hashintel/refractive/src/components/composite-parts.tsx +++ b/libs/@hashintel/refractive/src/components/composite/parts.tsx @@ -1,11 +1,12 @@ -import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; +import { useEffect, useState } from "react"; + +import type { Parts } from "../../helpers/split-imagedata-to-parts"; type CompositePartsProps = { - imageData: ImageData; + parts: Parts; cornerWidth: number; - pixelRatio: number; - width: number; - height: number; + /** Ref to the element whose dimensions drive the filter layout. */ + elementRef: React.RefObject; result: string; hideTop?: boolean; hideBottom?: boolean; @@ -15,29 +16,50 @@ type CompositePartsProps = { /** * @private - * Component that renders the 8 parts of an image and composites them together. - * - * Used internally by the Filter component, for DisplacementMap and SpecularMap. + * Renders pre-split 9-patch parts as feImage primitives and composites them together. * - * @return {JSX.Element} Fragment containing all image parts for the refractive effect, along with compositing. + * Observes the referenced element's size via ResizeObserver to position + * parts at the correct pixel coordinates. */ export const CompositeParts: React.FC = ({ - imageData, + parts, cornerWidth, - width, - height, - pixelRatio, + elementRef, result, hideTop, hideBottom, hideLeft, hideRight, }) => { - const parts = splitImageDataToParts({ - imageData, - cornerWidth, - pixelRatio, - }); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + + useEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const borderBox = entry.borderBoxSize[0]; + + if (borderBox) { + setWidth(borderBox.inlineSize); + setHeight(borderBox.blockSize); + } else { + setWidth(entry.contentRect.width); + setHeight(entry.contentRect.height); + } + } + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, [elementRef]); const widthMinusCorner = width - cornerWidth; const heightMinusCorner = height - cornerWidth; diff --git a/libs/@hashintel/refractive/src/components/effects/diffuse-reflection.tsx b/libs/@hashintel/refractive/src/components/effects/diffuse-reflection.tsx new file mode 100644 index 00000000000..8d5d08de2fe --- /dev/null +++ b/libs/@hashintel/refractive/src/components/effects/diffuse-reflection.tsx @@ -0,0 +1,125 @@ +import { generateTableValues } from "../../helpers/generate-table-values"; + +/** + * Generates a directional cosine table centered at 0.5 (signed encoding). + * + * Maps the polar map angle channel [0,255] → [0,2π] to + * `(cos(angle - lightAngle) + 1) / 2`, where 0.5 = perpendicular, + * 1 = facing light, 0 = facing away. + */ +function generateCosAngleTable(lightAngle: number): string { + return generateTableValues(256, (i) => { + const angle = (i / 255) * 2 * Math.PI; + return (Math.cos(angle - lightAngle) + 1) / 2; + }); +} + +type DiffuseReflectionProps = { + /** Input result name containing the polar map (R=distance ratio, G=angle). */ + in: string; + /** Input source image to apply diffuse shading to. */ + source: string; + /** Light angle in radians (0 = right, π/2 = down, π = left, 3π/2 = up). */ + lightAngle: number; + /** + * Pre-computed surface tilt lookup table (from generateSurfaceTiltTable). + * Maps distance ratio → normalized tilt [0,1]. + */ + surfaceTiltTable: string; + /** Strength of the diffuse shading [0,1]. */ + intensity?: number; + /** Output result name. */ + result: string; +}; + +/** + * @private + * Diffuse reflection effect: applies Lambertian-style shading based on the + * surface normal (derived from the edge profile) and light direction. + * + * Surfaces facing the light are brightened (white overlay), surfaces facing + * away are darkened (black overlay). Neutral areas have alpha=0 and don't + * affect the source at all — no gray wash on dark backgrounds. + * + * Pipeline: + * 1. Extract angle (G) → cosine of angle relative to light direction (signed, centered at 0.5) + * 2. Extract distance ratio (R) → surface tilt from edge profile (unsigned [0,1]) + * 3. Signed × unsigned multiply → diffuse value centered at 0.5 + * 4. Light pass: white with alpha = max(0, diffuse - 0.5) × 2 × intensity + * 5. Dark pass: black with alpha = max(0, 0.5 - diffuse) × 2 × intensity + */ +export const DiffuseReflection: React.FC = ({ + in: inResult, + source, + lightAngle, + surfaceTiltTable, + intensity = 0.3, + result, +}) => { + const cosAngleTable = generateCosAngleTable(lightAngle); + const lightAlphaScale = 2 * intensity; + + return ( + <> + {/* 1. Copy angle (G) into R, apply signed cosine table */} + + + + + + {/* 2. Apply surface tilt table to R channel (distance ratio → tilt) */} + + + + + {/* 3. Signed × unsigned multiply: cos(centered 0.5) × tilt(unsigned) + result = A·B - 0.5·B + 0.5 + Maps to [0,1] centered at 0.5: >0.5 = lit, <0.5 = shadow */} + + + {/* 4. Light pass: white overlay where diffuse > 0.5 + A = intensity × 2 × (R - 0.5), clamped to 0 when R < 0.5 */} + + + + {/* 5. Dark pass: black overlay where diffuse < 0.5 + A = intensity × 2 × (0.5 - R), clamped to 0 when R > 0.5 */} + + + + ); +}; diff --git a/libs/@hashintel/refractive/src/components/effects/refraction.tsx b/libs/@hashintel/refractive/src/components/effects/refraction.tsx new file mode 100644 index 00000000000..d1e14edd2b0 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/effects/refraction.tsx @@ -0,0 +1,99 @@ +import { generateTableValues } from "../../helpers/generate-table-values"; + +// Trig tables are constant — computed once at module level. +const cosTable = generateTableValues(256, (i) => { + const angle = (i / 255) * 2 * Math.PI; + return (Math.cos(angle) + 1) / 2; +}); + +const sinTable = generateTableValues(256, (i) => { + const angle = (i / 255) * 2 * Math.PI; + return (Math.sin(angle) + 1) / 2; +}); + +type RefractionProps = { + /** Magnitude lookup table (from generateMagnitudeTable). */ + magnitudeTable: string; + /** Displacement scale factor. */ + scale: number; + /** Input result name containing the polar map (R=ratio, G=angle). */ + in: string; + /** Input result name for the source to be displaced. */ + source: string; + /** Output result name for the displaced image. */ + result: string; +}; + +/** + * @private + * Refraction effect: converts a polar distance map into a cartesian displacement + * field and applies it to a source image via feDisplacementMap. + * + * Pipeline: + * 1. Extract angle (G) → apply cos/sin lookup tables via feComponentTransfer + * 2. Extract distance ratio (R) → apply magnitude lookup table (Snell's law) + * 3. Signed multiplication via feComposite arithmetic: magnitude × trig + * 4. Apply displacement to source + * + * The signed multiplication formula `result = 2·A·B − A − B + 1` correctly + * multiplies two values encoded in [0,1] centered at 0.5. + */ +export const Refraction: React.FC = ({ + magnitudeTable, + scale, + in: inResult, + source, + result, +}) => ( + <> + {/* Copy angle (G) into R and G for trig lookup */} + + + {/* Apply cos table to R, sin table to G */} + + + + + + {/* Copy distance ratio (R) into R and G for magnitude lookup */} + + + {/* Apply optical transfer function (Snell's law) to both channels */} + + + + + + {/* Signed multiplication: magnitude × trig → cartesian displacement */} + + + {/* Apply displacement to source */} + + +); diff --git a/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx b/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx new file mode 100644 index 00000000000..183efa5defc --- /dev/null +++ b/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx @@ -0,0 +1,131 @@ +import { generateTableValues } from "../../helpers/generate-table-values"; + +/** + * Generates a rim falloff lookup table. + * + * Maps distance-to-border ratio [0,1] to rim intensity [0,1]. + * The rim is always ~2px wide: `ratio = 2/radius` corresponds to 2px. + * A subtle exponential tail extends a few pixels beyond. + */ +function generateRimTable(radius: number): string { + // 2px expressed as a ratio of the total edge + const onePixelRatio = radius > 0 ? 2 / radius : 1; + + return generateTableValues(256, (i) => { + const ratio = i / 255; + // Sharp peak at the border (first pixel), exponential decay after + return Math.exp((-ratio / onePixelRatio) * 2); + }); +} + +/** + * Generates a directional highlight lookup table. + * + * Maps the polar map angle channel [0,255] → [0,2π] to a cosine + * lobe along the `lightAngle` axis. The light direction is treated + * as an axis — both the light-facing and opposite sides receive + * the highlight. The lobe width is controlled by `spread`. + */ +function generateDirectionalTable(lightAngle: number, spread: number): string { + return generateTableValues(256, (i) => { + // The polar map G channel encodes the displacement angle (toward center). + const angle = (i / 255) * 2 * Math.PI; + const dot = Math.cos(angle - lightAngle); + // Use |cos| so both sides of the axis get the highlight + return Math.pow(Math.abs(dot), spread); + }); +} + +type SpecularRimProps = { + /** Input result name containing the polar map (R=distance ratio, G=angle). */ + in: string; + /** Input source image to overlay the specular rim onto. */ + source: string; + /** Corner radius in pixels — used to scale the rim to always be ~1px wide. */ + radius: number; + /** Light angle in radians (0 = right, π/2 = down, π = left, 3π/2 = up). */ + lightAngle?: number; + /** Controls the tightness of the directional highlight (1 = broad, higher = tighter). */ + spread?: number; + /** Brightness multiplier for the specular highlight [0,1]. */ + intensity?: number; + /** Output result name. */ + result: string; +}; + +/** + * @private + * Specular rim effect: produces a directional edge highlight from the polar map + * and composites it over a source image. + * + * Pipeline: + * 1. Apply rim falloff table to R channel (distance-to-border → rim intensity) + * 2. Copy G channel (angle) to R, apply directional cosine table + * 3. Multiply rim × directional → combined specular intensity + * 4. Convert to white highlight with alpha = intensity + * 5. Composite over source + */ +export const SpecularRim: React.FC = ({ + in: inResult, + source, + radius, + lightAngle = Math.PI / 4, + spread = 2, + intensity = 0.6, + result, +}) => { + const rimTable = generateRimTable(radius); + const directionalTable = generateDirectionalTable(lightAngle, spread); + + return ( + <> + {/* 1. Distance-based rim falloff: R channel → rim intensity */} + + + + + + + {/* 2. Copy angle (G) into R for directional lookup */} + + + {/* 3. Apply directional cosine table to R channel */} + + + + + {/* 4. Multiply rim × directional (both in R channel) */} + + + {/* 5. Convert to white highlight: RGB=1, A=R×intensity */} + + + {/* 6. Composite highlight over source */} + + + ); +}; diff --git a/libs/@hashintel/refractive/src/components/filter-shell.tsx b/libs/@hashintel/refractive/src/components/filter-shell.tsx new file mode 100644 index 00000000000..3c4a45c7981 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -0,0 +1,152 @@ +import type { Parts } from "../helpers/split-imagedata-to-parts"; +import { buildCompositeSvgUrl } from "./composite/image"; +import { CompositeParts } from "./composite/parts"; +import { DiffuseReflection } from "./effects/diffuse-reflection"; +import { Refraction } from "./effects/refraction"; +import { SpecularRim } from "./effects/specular-rim"; + +export type CompositeMode = "image" | "parts"; + +type FilterShellProps = { + id: string; + blur: number; + scale: number; + magnitudeTable: string; + surfaceTiltTable: string; + parts: Parts; + cornerWidth: number; + /** + * Compositing strategy for the polar map: + * - `"image"` (default): Builds a single composite SVG data URL. + * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver needed. + * - `"parts"`: Renders 9 feImage + 8 feComposite filter primitives. + * Observes the element via `elementRef` to get pixel dimensions. + */ + compositing?: CompositeMode; + /** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */ + elementRef?: React.RefObject; + /** Light direction in radians. Enables diffuse and specular effects. */ + lightAngle?: number; + /** Strength of diffuse reflection shading [0,1]. 0 or undefined disables. */ + diffuseIntensity?: number; + /** Whether to enable the specular rim highlight. Requires lightAngle. */ + specular?: boolean; + hideTop?: boolean; + hideBottom?: boolean; + hideLeft?: boolean; + hideRight?: boolean; +}; + +/** + * @private + * Full SVG filter pipeline: blur → polar map compositing → effects. + * + * Effect chain: refraction → diffuse reflection → specular rim. + * Each effect consumes the shared polar map independently. + */ +export const FilterShell: React.FC = ({ + id, + blur, + scale, + magnitudeTable, + surfaceTiltTable, + parts, + cornerWidth, + compositing = "image", + elementRef, + lightAngle, + diffuseIntensity, + specular, + hideTop, + hideBottom, + hideLeft, + hideRight, +}) => { + const isImage = compositing === "image"; + const hasDiffuse = + lightAngle !== undefined && + diffuseIntensity !== undefined && + diffuseIntensity > 0; + const hasSpecular = specular !== false && lightAngle !== undefined; + + // Build the effect chain: refraction → diffuse → specular + // Each effect reads "polar_map" and takes the previous result as source. + const refractionResult = "refracted"; + const diffuseResult = hasDiffuse ? "with_diffuse" : refractionResult; + const specularResult = hasSpecular ? "with_specular" : diffuseResult; + // The last enabled effect's result is used as the filter output. + // SVG uses the last result in the filter chain automatically. + void specularResult; // referenced implicitly by the filter + + return ( + + + + + + {isImage ? ( + + ) : ( + + )} + + + + {hasDiffuse && ( + + )} + + {hasSpecular && ( + + )} + + + + ); +}; diff --git a/libs/@hashintel/refractive/src/components/filter.tsx b/libs/@hashintel/refractive/src/components/filter.tsx deleted file mode 100644 index 066ef26d781..00000000000 --- a/libs/@hashintel/refractive/src/components/filter.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { - calculateDisplacementMap, - calculateDisplacementMapRadius, -} from "../maps/displacement-map"; -import { calculateSpecularImage } from "../maps/specular"; -import { CompositeParts } from "./composite-parts"; - -type FilterProps = { - id: string; - scaleRatio: number; - blur: number; - width: number; - height: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - specularOpacity: number; - specularAngle: number; - bezelHeightFn: (x: number) => number; - pixelRatio: number; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; - -/** - * @private - * Creates an SVG containing a filter that can be used as `backdrop-filter`to create a refractive effect. - * - * At the moment, width and height need to be explicitly provided to match the size of element it will be applied to. - * - * Usage: - * ```tsx - * const filterId = "my-refractive-filter"; - * - * - *
- * ``` - * - * @param props - The properties for the Filter component. - * @returns An SVG element containing the filter definition. - */ -export const Filter: React.FC = ({ - id, - width, - height, - radius, - blur, - glassThickness, - bezelWidth, - refractiveIndex, - scaleRatio, - specularOpacity, - bezelHeightFn, - pixelRatio, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - // Size of each corner area - // If bezelWidth < radius, corners will be in a circle shape - // If bezelWidth >= radius, corners will be in a rounded square shape - const cornerWidth = Math.max(radius, bezelWidth); - - // Calculated image width and height are always odd, - // so we always have at least 1 pixel in the middle we can stretch - const imageSide = cornerWidth * 2 + 1; - - const map = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, - bezelHeightFn, - refractiveIndex, - ); - - const maximumDisplacement = Math.max(...map.map(Math.abs)); - - const displacementMap = calculateDisplacementMap({ - width: imageSide, - height: imageSide, - radius, - bezelWidth, - precomputedDisplacementMap: map, - maximumDisplacement, - pixelRatio, - }); - - const specularMap = calculateSpecularImage({ - width: imageSide, - height: imageSide, - radius, - specularAngle: Math.PI / 4, // Default angle, could be made configurable - pixelRatio, - }); - - const scale = maximumDisplacement * scaleRatio; - - const content = ( - - - - - - - - - - - - - - - - - - - - - - ); - - return ( - - {content} - - ); -}; diff --git a/libs/@hashintel/refractive/src/helpers/generate-table-values.ts b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts new file mode 100644 index 00000000000..20a3746d1c2 --- /dev/null +++ b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts @@ -0,0 +1,69 @@ +/** + * Generate a space-separated string of `size` values from a mapping function, + * suitable for SVG `feComponentTransfer` `tableValues`. + */ +export function generateTableValues( + size: number, + fn: (index: number) => number, +): string { + return Array.from({ length: size }, (_, i) => fn(i).toFixed(6)).join(" "); +} + +/** + * Generate a magnitude lookup table that maps border distance ratio + * to signed normalized displacement, centered at 0.5. + * + * @param displacementRadius - Pre-computed displacement samples from Snell's law. + * @param maximumDisplacement - Max absolute displacement value. + * @param ratioScale - Multiplier to remap the input ratio (e.g. radius/edgeSize + * when edgeSize < radius, compressing the bezel into a narrower band). + */ +export function generateMagnitudeTable( + displacementRadius: number[], + maximumDisplacement: number, + ratioScale: number = 1, +): string { + return generateTableValues(256, (i) => { + if (i === 0 || maximumDisplacement === 0) { + return 0.5; + } + const ratio = Math.min(1, (i / 255) * ratioScale); + const sampleIndex = Math.min( + Math.round(ratio * displacementRadius.length), + displacementRadius.length - 1, + ); + const displacement = displacementRadius[sampleIndex] ?? 0; + return Math.max( + 0, + Math.min(1, (displacement / maximumDisplacement + 1) / 2), + ); + }); +} + +/** + * Generate a surface tilt lookup table from an edge profile function. + * + * Maps border distance ratio [0,1] to normalized surface tilt [0,1], + * where 0 = flat surface (no diffuse contribution) and 1 = maximum tilt. + * + * The tilt is `sin(atan(derivative))` = `|d| / sqrt(1 + d²)` where d is + * the edge profile's derivative at the given ratio. + * + * @param edgeProfile - Surface equation function (e.g. convex, concave). + * @param ratioScale - Multiplier to remap the input ratio (e.g. radius/edgeSize). + */ +export function generateSurfaceTiltTable( + edgeProfile: (x: number) => number, + ratioScale: number = 1, +): string { + return generateTableValues(256, (i) => { + if (i === 0) return 0; // border/outside → no effect + const ratio = Math.min(1, (i / 255) * ratioScale); + if (ratio >= 1) return 0; // past the edge → flat + const dx = 0.0001; + const derivative = + (edgeProfile(Math.min(1, ratio + dx)) - edgeProfile(ratio)) / dx; + // sin(atan(d)) = |d| / sqrt(1 + d²) + return Math.abs(derivative) / Math.sqrt(1 + derivative * derivative); + }); +} diff --git a/libs/@hashintel/refractive/src/helpers/surface-equations.ts b/libs/@hashintel/refractive/src/helpers/surface-equations.ts index 20d041d8282..22a5887a801 100644 --- a/libs/@hashintel/refractive/src/helpers/surface-equations.ts +++ b/libs/@hashintel/refractive/src/helpers/surface-equations.ts @@ -1,12 +1,12 @@ -export type SurfaceFnDef = (x: number) => number; +export type EdgeProfile = (x: number) => number; -export const convexCircle: SurfaceFnDef = (x) => Math.sqrt(1 - (1 - x) ** 2); +export const convexCircle: EdgeProfile = (x) => Math.sqrt(1 - (1 - x) ** 2); -export const convex: SurfaceFnDef = (x) => (1 - (1 - x) ** 4) ** (1 / 4); +export const convex: EdgeProfile = (x) => (1 - (1 - x) ** 4) ** (1 / 4); -export const concave: SurfaceFnDef = (x) => 1 - convexCircle(x); +export const concave: EdgeProfile = (x) => 1 - convexCircle(x); -export const lip: SurfaceFnDef = (x) => { +export const lip: EdgeProfile = (x) => { const cvx = convex(x * 2); const ccv = concave(x) + 0.1; const smootherstep = 6 * x ** 5 - 15 * x ** 4 + 10 * x ** 3; diff --git a/libs/@hashintel/refractive/src/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index fcc1c331558..b12e29fc5ba 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -1,20 +1,63 @@ import type { ComponentType } from "react"; -import { createElement, useEffect, useId, useRef, useState } from "react"; +import { createElement, useId, useRef } from "react"; import type { JSX } from "react/jsx-runtime"; -import { Filter } from "../components/filter"; +import type { CompositeMode } from "../components/filter-shell"; +import { FilterShell } from "../components/filter-shell"; +import { + generateMagnitudeTable, + generateSurfaceTiltTable, +} from "../helpers/generate-table-values"; +import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; import { convex } from "../helpers/surface-equations"; +import { calculateDisplacementMapRadius } from "../maps/displacement-radius"; +import { calculatePolarDistanceToBorderMap } from "../maps/polar-distance-to-border-map"; + +/** + * Reference radius used to generate the hi-res polar field. + * The image is (REFERENCE_RADIUS * 2 + 1) = 513 pixels per side. + * This is computed once and reused for any actual radius. + */ +const REFERENCE_RADIUS = 256; + +/** + * Pre-computed hi-res geometric polar field at 513×513 pixels. + * Since the map encodes normalized values (border distance ratio + angle), + * the same image works for any actual radius. + */ +const hiResPolarMap = calculatePolarDistanceToBorderMap(REFERENCE_RADIUS); + +/** + * Pre-split 9-patch parts from the hi-res polar map. + * These are sliced at the reference resolution (256px corners) + * and can be positioned at any target radius in the SVG. + */ +const hiResParts = splitImageDataToParts({ + imageData: hiResPolarMap, + cornerWidth: REFERENCE_RADIUS, + pixelRatio: 1, +}); type RefractionProps = { refraction: { radius: number; blur?: number; - glassThickness?: number; - bezelWidth?: number; + thickness?: number; + edgeSize?: number; refractiveIndex?: number; - specularOpacity?: number; - specularAngle?: number; - bezelHeightFn?: (x: number) => number; + edgeProfile?: (x: number) => number; + /** + * Compositing strategy for the polar map: + * - `"image"` (default): Single composite SVG, auto-sizes via objectBoundingBox. + * - `"parts"`: 9-patch feImage primitives, requires explicit sizing. + */ + compositing?: CompositeMode; + /** Light direction in radians (0 = right, π/2 = down). Used by diffuse and specular effects. */ + lightAngle?: number; + /** Strength of diffuse reflection shading [0,1]. 0 disables. */ + diffuseIntensity?: number; + /** Whether to enable the specular rim highlight. Default true when lightAngle is set. */ + specular?: boolean; }; }; @@ -39,58 +82,46 @@ function createRefractiveComponent< } = props as P & RefractionProps & { ref?: React.Ref }; const filterId = useId(); const internalRef = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - // If a ref is passed in props, use it; otherwise, use internalRef. - // If the passed ref is updated later, it will trigger a re-render. const elementRef = externalRef ?? internalRef; - // TODO: (FE-43) Remove ResizeObserver and rely on `objectBoundingBox` to automatically size the filter. - // This will removed the need of `useState` here. - useEffect(() => { - const element = elementRef.current; - if (!element) { - return; - } - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const borderBox = entry.borderBoxSize[0]; - - if (borderBox) { - setWidth(borderBox.inlineSize); - setHeight(borderBox.blockSize); - } else { - setWidth(entry.contentRect.width); - setHeight(entry.contentRect.height); - } - } - }); - - resizeObserver.observe(element); - - return () => { - resizeObserver.disconnect(); - }; - }, [elementRef]); + const edgeSize = refraction.edgeSize ?? 0; + const radius = refraction.radius; + const clampedEdgeSize = Math.min(edgeSize, radius); + + const displacementRadius = calculateDisplacementMapRadius( + refraction.thickness ?? 70, + clampedEdgeSize, + refraction.edgeProfile ?? convex, + refraction.refractiveIndex ?? 1.5, + ); + + const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); + const ratioScale = clampedEdgeSize > 0 ? radius / clampedEdgeSize : 1; + const magnitudeTable = generateMagnitudeTable( + displacementRadius, + maximumDisplacement, + ratioScale, + ); + + const edgeProfile = refraction.edgeProfile ?? convex; + const surfaceTiltTable = generateSurfaceTiltTable(edgeProfile, ratioScale); return ( <> - {/* @ts-expect-error Need to fix types in this file */} diff --git a/libs/@hashintel/refractive/src/maps/calculate-circle-map.ts b/libs/@hashintel/refractive/src/maps/calculate-circle-map.ts index a81561b6b22..75439457787 100644 --- a/libs/@hashintel/refractive/src/maps/calculate-circle-map.ts +++ b/libs/@hashintel/refractive/src/maps/calculate-circle-map.ts @@ -67,12 +67,12 @@ export function calculateCircleMap(props: { // Process pixels that are in bezel or near bezel edge for anti-aliasing if (isInBezel) { const distanceFromCenter = Math.sqrt(distanceToCenterSquared); - const distanceFromBorder = radius - distanceFromCenter; - const distanceFromBorderRatio = distanceFromBorder / radius; + const distanceToBorder = radius - distanceFromCenter; + const distanceToBorderRatio = distanceToBorder / radius; const angle = Math.atan2(y, x); // H-5525: Fix antialiasing calculation const opacity = - distanceToCenterSquared > radiusSquared ? 1 - distanceFromBorder : 1; + distanceToCenterSquared > radiusSquared ? 1 - distanceToBorder : 1; processPixel( x, @@ -80,8 +80,8 @@ export function calculateCircleMap(props: { imageData.data, idx, distanceFromCenter, - distanceFromBorder, - distanceFromBorderRatio, + distanceToBorder, + distanceToBorderRatio, angle, opacity, ); diff --git a/libs/@hashintel/refractive/src/maps/calculate-rounded-square-map.ts b/libs/@hashintel/refractive/src/maps/calculate-rounded-square-map.ts index e42c5e83d37..89c07337549 100644 --- a/libs/@hashintel/refractive/src/maps/calculate-rounded-square-map.ts +++ b/libs/@hashintel/refractive/src/maps/calculate-rounded-square-map.ts @@ -144,12 +144,12 @@ export function calculateRoundedSquareMap(props: { // Process pixels that are in rounded square or near bezel edge for anti-aliasing if (isInRoundedSquare) { const distanceFromCenter = Math.sqrt(distanceToCenterSquared); - const distanceFromBorder = Math.sqrt(distanceToBorderSquared); - const distanceFromBorderRatio = - distanceFromBorder / (distanceFromCenter + distanceFromBorder); + const distanceToBorder = Math.sqrt(distanceToBorderSquared); + const distanceToBorderRatio = + distanceToBorder / (distanceFromCenter + distanceToBorder); const angle = Math.atan2(y, x); // H-5525: Fix antialiasing calculation - const opacity = isOutsideRadius ? 1 - distanceFromBorder : 1; + const opacity = isOutsideRadius ? 1 - distanceToBorder : 1; processPixel( x, @@ -157,8 +157,8 @@ export function calculateRoundedSquareMap(props: { imageData.data, idx, distanceFromCenter, - distanceFromBorder, - distanceFromBorderRatio, + distanceToBorder, + distanceToBorderRatio, angle, opacity, ); diff --git a/libs/@hashintel/refractive/src/maps/displacement-map.ts b/libs/@hashintel/refractive/src/maps/displacement-map.ts deleted file mode 100644 index cc4a7ca96f9..00000000000 --- a/libs/@hashintel/refractive/src/maps/displacement-map.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; - -export function calculateDisplacementMapRadius( - glassThickness: number = 200, - bezelWidth: number = 50, - bezelHeightFn: (x: number) => number = (x) => x, - refractiveIndex: number = 1.5, - samples: number = 128, -): number[] { - // Pre-calculate the distance the ray will be deviated - // given the distance to border (ratio of bezel) - // and height of the glass - const eta = 1 / refractiveIndex; - - // Simplified refraction, which only handles fully vertical incident ray [0, 1] - function refract(normalX: number, normalY: number): [number, number] | null { - const dot = normalY; - const k = 1 - eta * eta * (1 - dot * dot); - if (k < 0) { - // Total internal reflection - return null; - } - const kSqrt = Math.sqrt(k); - return [ - -(eta * dot + kSqrt) * normalX, - eta - (eta * dot + kSqrt) * normalY, - ] as const; - } - - return Array.from({ length: samples }, (_, i) => { - const x = i / samples; - const y = bezelHeightFn(x); - - // Calculate derivative in x - const dx = x < 1 ? 0.0001 : -0.0001; - const y2 = bezelHeightFn(x + dx); - const derivative = (y2 - y) / dx; - const magnitude = Math.sqrt(derivative * derivative + 1); - const normal = [-derivative / magnitude, -1 / magnitude] as const; - const refracted = refract(normal[0], normal[1]); - - if (!refracted) { - return 0; - } else { - const remainingHeightOnBezel = y * bezelWidth; - const remainingHeight = remainingHeightOnBezel + glassThickness; - - // Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass) - return refracted[0] * (remainingHeight / refracted[1]); - } - }); -} - -export function calculateDisplacementMap(props: { - width: number; - height: number; - radius: number; - bezelWidth: number; - maximumDisplacement: number; - precomputedDisplacementMap: number[]; - pixelRatio: number; -}) { - const { pixelRatio, maximumDisplacement, precomputedDisplacementMap } = props; - - const width = Math.round(props.width * pixelRatio); - const height = Math.round(props.height * pixelRatio); - - const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2); - const bezel = Math.min(props.bezelWidth * pixelRatio, width / 2, height / 2); - - return calculateRoundedSquareMap({ - width, - height, - radius, - maximumDistanceToBorder: bezel, - fillColor: 0xff008080, - processPixel( - _x, - _y, - buffer, - offset, - _distanceFromCenter, - distanceFromBorder, - distanceFromBorderRatio, - angle, - opacity, - ) { - // Viewed from top - const cos = Math.cos(angle); - const sin = Math.sin(angle); - - const ratio = - bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel; - - const bezelIndex = Math.round(ratio * precomputedDisplacementMap.length); - const distance = precomputedDisplacementMap[bezelIndex] ?? 0; - - const dX = (-cos * distance) / maximumDisplacement; - const dY = (-sin * distance) / maximumDisplacement; - - buffer[offset] = 128 + dX * 127 * opacity; // R - buffer[offset + 1] = 128 + dY * 127 * opacity; // G - buffer[offset + 2] = 0; // B - buffer[offset + 3] = 255; // A - }, - }); -} diff --git a/libs/@hashintel/refractive/src/maps/displacement-radius.ts b/libs/@hashintel/refractive/src/maps/displacement-radius.ts new file mode 100644 index 00000000000..5e4c7a4d904 --- /dev/null +++ b/libs/@hashintel/refractive/src/maps/displacement-radius.ts @@ -0,0 +1,57 @@ +/** + * Pre-computes per-sample ray deviation using Snell's law. + * + * Given optical parameters, returns an array of displacement values + * (one per sample across the edge size) encoding how far a vertical + * ray is deflected horizontally after passing through the surface. + */ +export function calculateDisplacementMapRadius( + thickness: number = 200, + edgeSize: number = 50, + edgeProfile: (x: number) => number = (x) => x, + refractiveIndex: number = 1.5, + samples: number = 128, +): number[] { + // Pre-calculate the distance the ray will be deviated + // given the distance to border (ratio of edge) + // and thickness of the material + const eta = 1 / refractiveIndex; + + // Simplified refraction, which only handles fully vertical incident ray [0, 1] + function refract(normalX: number, normalY: number): [number, number] | null { + const dot = normalY; + const k = 1 - eta * eta * (1 - dot * dot); + if (k < 0) { + // Total internal reflection + return null; + } + const kSqrt = Math.sqrt(k); + return [ + -(eta * dot + kSqrt) * normalX, + eta - (eta * dot + kSqrt) * normalY, + ] as const; + } + + return Array.from({ length: samples }, (_, i) => { + const x = i / samples; + const y = edgeProfile(x); + + // Calculate derivative in x + const dx = x < 1 ? 0.0001 : -0.0001; + const y2 = edgeProfile(x + dx); + const derivative = (y2 - y) / dx; + const magnitude = Math.sqrt(derivative * derivative + 1); + const normal = [-derivative / magnitude, -1 / magnitude] as const; + const refracted = refract(normal[0], normal[1]); + + if (!refracted) { + return 0; + } else { + const remainingHeightOnEdge = y * edgeSize; + const remainingHeight = remainingHeightOnEdge + thickness; + + // Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom) + return refracted[0] * (remainingHeight / refracted[1]); + } + }); +} diff --git a/libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts b/libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts new file mode 100644 index 00000000000..8f8607aa4d0 --- /dev/null +++ b/libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-param-reassign */ +import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; + +/** + * Computes a geometry-only polar field encoding (distance-to-border ratio, angle) + * for each pixel of a rounded rectangle. + * + * The output image is (radius * 2 + 1) pixels per side, with the edge filling + * the entire corner area. The actual edge-to-radius ratio is handled later + * by the magnitude lookup table's `ratioScale` parameter. + * + * This map depends only on shape geometry, NOT on optical parameters + * (thickness, refractiveIndex, edgeProfile). The optical transfer + * function is applied later via SVG feComponentTransfer lookup tables. + * + * Channel encoding: + * - R: border distance ratio [0, 1] → [0, 255], where 0 = at border, 255 = deep inside bezel + * - G: angle toward center mapped from [0, 2π] to [0, 255] + * - B: 0 + * - A: 255 + */ +export function calculatePolarDistanceToBorderMap(radius: number) { + const side = radius * 2 + 1; + + return calculateRoundedSquareMap({ + width: side, + height: side, + radius, + maximumDistanceToBorder: radius, + // R=0 (at border), G=0, B=0, A=255 + fillColor: 0xff000000, + processPixel( + _x, + _y, + buffer, + offset, + _distanceFromCenter, + _distanceToBorder, + distanceToBorderRatio, + angle, + opacity, + ) { + // R: border distance ratio, scaled by opacity for anti-aliasing. + // At opacity < 1 (anti-aliased edges), ratio trends toward 0, + // which the magnitude lookup table maps to "no displacement". + buffer[offset] = Math.round(distanceToBorderRatio * 255 * opacity); + + // G: angle toward center (displacement direction) + const displacementAngle = (angle + Math.PI) % (2 * Math.PI); + buffer[offset + 1] = Math.round( + (displacementAngle / (2 * Math.PI)) * 255, + ); + + buffer[offset + 2] = 0; // B + buffer[offset + 3] = 255; // A + }, + }); +} diff --git a/libs/@hashintel/refractive/src/maps/process-pixel.type.ts b/libs/@hashintel/refractive/src/maps/process-pixel.type.ts index ca2ba8e2c91..c97fa2712b4 100644 --- a/libs/@hashintel/refractive/src/maps/process-pixel.type.ts +++ b/libs/@hashintel/refractive/src/maps/process-pixel.type.ts @@ -4,8 +4,8 @@ export type ProcessPixelFunction = ( buffer: Uint8ClampedArray, offset: number, distanceFromCenter: number, - distanceFromBorder: number, - distanceFromBorderRatio: number, + distanceToBorder: number, + distanceToBorderRatio: number, /** * Angle from center to pixel in radians. */ diff --git a/libs/@hashintel/refractive/src/maps/specular.ts b/libs/@hashintel/refractive/src/maps/specular.ts deleted file mode 100644 index 976acace83b..00000000000 --- a/libs/@hashintel/refractive/src/maps/specular.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { calculateCircleMap } from "./calculate-circle-map"; - -const NEAR_EDGE_DISTANCE = 20; - -export function calculateSpecularImage(props: { - width: number; - height: number; - radius: number; - specularAngle: number; - pixelRatio: number; -}) { - const { pixelRatio, specularAngle } = props; - - // Calculate real dimensions using pixel ratio - const width = Math.round(props.width * pixelRatio); - const height = Math.round(props.height * pixelRatio); - const radius = Math.min(props.radius * pixelRatio, width / 2, height / 2); - - // Vector along which we should see specular - const specular_vector = [ - Math.cos(specularAngle), - Math.sin(specularAngle), - ] as const; - - return calculateCircleMap({ - width, - height, - fillColor: 0x00000000, - radius, - maximumDistanceToBorder: NEAR_EDGE_DISTANCE * pixelRatio, - processPixel( - x, - y, - buffer, - offset, - distanceFromCenter, - _distanceFromBorder, - _distanceFromBorderRatio, - _angle, - opacity, - ) { - const distanceFromSide = radius - distanceFromCenter; - - // Viewed from top - const cos = x / distanceFromCenter; - const sin = -y / distanceFromCenter; - - // Dot product of orientation - const dotProduct = Math.abs( - cos * specular_vector[0] + sin * specular_vector[1], - ); - - const coefficient = - dotProduct * - Math.sqrt(1 - (1 - distanceFromSide / (1 * pixelRatio)) ** 2); - - const color = 255 * coefficient; - const finalOpacity = color * coefficient * opacity; - - buffer[offset] = color; // R - buffer[offset + 1] = color; // G - buffer[offset + 2] = color; // B - buffer[offset + 3] = finalOpacity; // A - }, - }); -} diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx new file mode 100644 index 00000000000..38cec800958 --- /dev/null +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -0,0 +1,132 @@ +import { useId } from "react"; + +import type { EdgeProfile } from "../src/helpers/surface-equations"; +import { convex } from "../src/helpers/surface-equations"; +import { ExampleArticle } from "./example-article"; + +export type BackgroundType = "article" | "checkerboard"; + +export type SharedFilterProps = { + blur: number; + radius: number; + thickness: number; + edgeSize: number; + refractiveIndex: number; + edgeProfile: EdgeProfile; + background: BackgroundType; +}; + +export const defaultFilterArgs: SharedFilterProps = { + blur: 2, + radius: 20, + thickness: 70, + edgeSize: 30, + refractiveIndex: 1.5, + edgeProfile: convex, + background: "article", +}; + +export const filterArgTypes = { + blur: { control: { type: "range" as const, min: 0, max: 20, step: 0.5 } }, + radius: { control: { type: "range" as const, min: 0, max: 100, step: 1 } }, + thickness: { + control: { type: "range" as const, min: 0, max: 300, step: 1 }, + }, + edgeSize: { + control: { type: "range" as const, min: 0, max: 100, step: 1 }, + }, + refractiveIndex: { + control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, + }, + edgeProfile: { table: { disable: true } }, + background: { + control: { type: "inline-radio" as const }, + options: ["article", "checkerboard"], + }, +}; + +/** + * Glass pane that applies an SVG backdrop-filter. + * `children` should render the / SVG element. + */ +const GlassPane: React.FC<{ + filterId: string; + radius: number; + children: React.ReactNode; +}> = ({ filterId, radius, children }) => ( + <> + {children} +
+ Refractive Glass +
+ +); + +/** + * Wrapper that renders a filter + a div with that filter applied, over a selectable background. + */ +export const FilterShowcase: React.FC<{ + radius?: number; + background?: BackgroundType; + children: (id: string) => React.ReactNode; +}> = ({ radius = 20, background = "article", children }) => { + const filterId = useId(); + + const glass = ( + + {children(filterId)} + + ); + + if (background === "checkerboard") { + return ( +
+ {glass} +
+ ); + } + + return ( +
+
+
{glass}
+
+ +
+ ); +}; diff --git a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx new file mode 100644 index 00000000000..8f8586452c9 --- /dev/null +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import { calculatePolarDistanceToBorderMap } from "../../src/maps/polar-distance-to-border-map"; + +type Props = { + radius: number; +}; + +const PolarDistanceToBorderMapVis = ({ radius }: Props) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const imageData = calculatePolarDistanceToBorderMap(radius); + + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + }, [radius]); + + return ( +
+

+ Red = border distance ratio [0,1], Green = angle [0,2π] → [0,255] +

+ +
+ ); +}; + +const meta = { + title: "Internals/Polar DistanceToBorder Map", + component: PolarDistanceToBorderMapVis, + argTypes: { + radius: { control: { type: "range", min: 5, max: 100, step: 1 } }, + }, + args: { + radius: 30, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Small: Story = { + args: { radius: 10 }, +}; + +export const Large: Story = { + args: { radius: 80 }, +}; diff --git a/libs/@hashintel/refractive/stories/playground.stories.tsx b/libs/@hashintel/refractive/stories/playground.stories.tsx index a2338d512cb..ea60773a281 100644 --- a/libs/@hashintel/refractive/stories/playground.stories.tsx +++ b/libs/@hashintel/refractive/stories/playground.stories.tsx @@ -1,21 +1,27 @@ import type { Meta, StoryObj } from "@storybook/react"; +import type { CompositeMode } from "../src/components/filter-shell"; import { convex } from "../src/helpers/surface-equations"; import { refractive } from "../src/hoc/refractive"; import { ExampleArticle } from "./example-article"; -const refraction = { - blur: 2, - radius: 20, - specularOpacity: 0.9, - bezelWidth: 30, - glassThickness: 70, - refractiveIndex: 1.5, - bezelHeightFn: convex, - specularAngle: 2, +type Props = { + radius: number; + compositing: CompositeMode; + lightAngle: number | undefined; + diffuseIntensity: number; + specular: boolean; + shadowOpacity: number; }; -const GlassOverArticle = () => ( +const GlassOverArticle = ({ + radius, + compositing, + lightAngle, + diffuseIntensity, + specular, + shadowOpacity, +}: Props) => (
( height: 200, resize: "both", overflow: "auto", - backgroundColor: "rgba(255, 255, 255, 0.6)", + // backgroundColor: "rgba(255, 255, 255, 0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1, + boxShadow: `0 8px 24px rgba(0, 0, 0, ${shadowOpacity})`, + }} + refraction={{ + blur: 2, + radius, + edgeSize: 30, + thickness: 70, + refractiveIndex: 1.5, + edgeProfile: convex, + compositing, + lightAngle, + diffuseIntensity, + specular, }} - refraction={refraction} > Refractive Glass @@ -44,6 +62,35 @@ const GlassOverArticle = () => ( const meta = { title: "Playground", component: GlassOverArticle, + argTypes: { + radius: { + control: { type: "range", min: 0, max: 100, step: 1 }, + }, + compositing: { + control: { type: "inline-radio" as const }, + options: ["image", "parts"], + }, + lightAngle: { + control: { type: "range", min: 0, max: 6.28, step: 0.01 }, + }, + diffuseIntensity: { + control: { type: "range", min: 0, max: 1, step: 0.01 }, + }, + specular: { + control: { type: "boolean" }, + }, + shadowOpacity: { + control: { type: "range", min: 0, max: 1, step: 0.01 }, + }, + }, + args: { + radius: 20, + compositing: "image", + lightAngle: Math.PI / 4, + diffuseIntensity: 0.3, + specular: true, + shadowOpacity: 0.15, + }, } satisfies Meta; export default meta; @@ -51,3 +98,7 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const NoLighting: Story = { + args: { lightAngle: undefined, diffuseIntensity: 0 }, +};