From 0684aef418d3f410547c9c700aee8c3a48cfc799 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 21:33:00 +0100 Subject: [PATCH 01/19] FE-43: Add alternative OBB and polar distance map filters for refractive Add experimental filter variants that use objectBoundingBox sizing (no ResizeObserver needed) via composite SVG data URLs, and a polar distance map encoding displacement as (distance, angle) channels. Co-Authored-By: Claude Opus 4.6 --- .../src/components/composite-image.tsx | 138 ++++++++++++++ .../refractive/src/components/filter-obb.tsx | 171 ++++++++++++++++++ .../src/components/filter-polar.tsx | 90 +++++++++ .../refractive/src/maps/polar-distance-map.ts | 58 ++++++ 4 files changed, 457 insertions(+) create mode 100644 libs/@hashintel/refractive/src/components/composite-image.tsx create mode 100644 libs/@hashintel/refractive/src/components/filter-obb.tsx create mode 100644 libs/@hashintel/refractive/src/components/filter-polar.tsx create mode 100644 libs/@hashintel/refractive/src/maps/polar-distance-map.ts 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..1e5859b98ba --- /dev/null +++ b/libs/@hashintel/refractive/src/components/composite-image.tsx @@ -0,0 +1,138 @@ +import type { ImageData } from "canvas"; + +import type { Parts } from "../helpers/split-imagedata-to-parts"; +import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; + +type CompositeImageProps = { + imageData: ImageData; + cornerWidth: number; + pixelRatio: number; + result: string; + hideTop?: boolean; + hideBottom?: boolean; + hideLeft?: boolean; + hideRight?: boolean; +}; + +/** + * 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. + */ +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)}`; +} + +/** + * @private + * Component that builds a composite SVG from 9 image parts and returns a single feImage. + * + * Unlike CompositeParts which uses 9 feImage + 8 feComposite filter primitives and requires + * explicit width/height, this component generates a single SVG data URL that adapts to + * whatever size the feImage renders it at. Corners have fixed pixel sizes and are positioned + * via percentage-based nested SVGs with overflow="visible". + * + * Used internally by the FilterOBB component, for DisplacementMap and SpecularMap. + * + * @return {JSX.Element} A single feImage element referencing the composite SVG data URL. + */ +export const CompositeImage: React.FC = ({ + imageData, + cornerWidth, + pixelRatio, + result, + hideTop, + hideBottom, + hideLeft, + hideRight, +}) => { + const parts = splitImageDataToParts({ + imageData, + cornerWidth, + pixelRatio, + }); + + const svgUrl = buildCompositeSvgUrl( + parts, + cornerWidth, + hideTop, + hideBottom, + hideLeft, + hideRight, + ); + + return ; +}; diff --git a/libs/@hashintel/refractive/src/components/filter-obb.tsx b/libs/@hashintel/refractive/src/components/filter-obb.tsx new file mode 100644 index 00000000000..ece24db26d1 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/filter-obb.tsx @@ -0,0 +1,171 @@ +import { + calculateDisplacementMap, + calculateDisplacementMapRadius, +} from "../maps/displacement-map"; +import { calculateSpecularImage } from "../maps/specular"; +import { CompositeImage } from "./composite-image"; + +type FilterOBBProps = { + id: string; + scaleRatio: number; + blur: 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 + * Alternative filter that uses `objectBoundingBox` to automatically size itself. + * + * Instead of requiring explicit width/height and a ResizeObserver, this filter: + * - Sets the filter region to exactly match the element's bounding box (`x="0" y="0" width="1" height="1"`) + * - Uses a single feImage per map (displacement + specular) referencing SVG data URLs + * - The SVG data URLs contain all 9 image parts composited via nested SVGs with percentage positioning + * + * Usage: + * ```tsx + * const filterId = "my-refractive-filter"; + * + * + *
+ * ``` + * + * @param props - The properties for the FilterOBB component. + * @returns An SVG element containing the filter definition. + */ +export const FilterOBB: React.FC = ({ + id, + 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/components/filter-polar.tsx b/libs/@hashintel/refractive/src/components/filter-polar.tsx new file mode 100644 index 00000000000..b13bc856030 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/filter-polar.tsx @@ -0,0 +1,90 @@ +import { calculateDisplacementMapRadius } from "../maps/displacement-map"; +import { calculatePolarDistanceMap } from "../maps/polar-distance-map"; +import { CompositeImage } from "./composite-image"; + +type FilterPolarProps = { + id: string; + blur: number; + radius: number; + glassThickness: number; + bezelWidth: number; + refractiveIndex: number; + bezelHeightFn: (x: number) => number; + pixelRatio: number; + hideTop?: boolean; + hideBottom?: boolean; + hideLeft?: boolean; + hideRight?: boolean; +}; + +/** + * @private + * Filter that displays a polar distance map (distance + angle) instead of applying displacement. + * + * The polar distance map encodes: + * - Red channel: displacement distance in pixels, clamped to [0, 255] + * - Green channel: displacement angle mapped from [0, 2π] to [0, 255] + * + * Uses objectBoundingBox sizing via a composite SVG data URL (no ResizeObserver needed). + * Does not apply displacement or specular — just shows the raw polar map. + */ +export const FilterPolar: React.FC = ({ + id, + radius, + blur, + glassThickness, + bezelWidth, + refractiveIndex, + bezelHeightFn, + pixelRatio, + hideTop, + hideBottom, + hideLeft, + hideRight, +}) => { + const cornerWidth = Math.max(radius, bezelWidth); + const imageSide = cornerWidth * 2 + 1; + + const map = calculateDisplacementMapRadius( + glassThickness, + bezelWidth, + bezelHeightFn, + refractiveIndex, + ); + + const polarMap = calculatePolarDistanceMap({ + width: imageSide, + height: imageSide, + radius, + bezelWidth, + precomputedDisplacementMap: map, + pixelRatio, + }); + + const content = ( + + + + + + ); + + return ( + + {content} + + ); +}; diff --git a/libs/@hashintel/refractive/src/maps/polar-distance-map.ts b/libs/@hashintel/refractive/src/maps/polar-distance-map.ts new file mode 100644 index 00000000000..dd6f138ac60 --- /dev/null +++ b/libs/@hashintel/refractive/src/maps/polar-distance-map.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-param-reassign */ +import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; + +export function calculatePolarDistanceMap(props: { + width: number; + height: number; + radius: number; + bezelWidth: number; + precomputedDisplacementMap: number[]; + pixelRatio: number; +}) { + const { pixelRatio, 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, + // R=0 (no distance), G=0, B=0, A=255 + fillColor: 0xff000000, + processPixel( + _x, + _y, + buffer, + offset, + _distanceFromCenter, + distanceFromBorder, + distanceFromBorderRatio, + angle, + opacity, + ) { + const ratio = + bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel; + + const bezelIndex = Math.round(ratio * precomputedDisplacementMap.length); + const distance = Math.abs(precomputedDisplacementMap[bezelIndex] ?? 0); + + // Red: distance in pixels, clamped to [0, 255] + buffer[offset] = Math.min(255, Math.round(distance * opacity)); + + // Green: displacement angle mapped from [0, 2π] to [0, 255] + // Displacement direction is opposite to position angle (toward center) + 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 + }, + }); +} From 6c94db6efe0df0737cc2801ee7b745875327381e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 15 Mar 2026 17:12:07 +0100 Subject: [PATCH 02/19] PARTIAL --- .../stories/filters/filter-obb.stories.tsx | 48 ++++ .../stories/filters/filter-polar.stories.tsx | 64 +++++ .../stories/filters/filter.stories.tsx | 57 +++++ .../@hashintel/refractive/stories/helpers.tsx | 91 +++++++ .../internals/displacement-map.stories.tsx | 123 ++++++++++ .../internals/polar-distance-map.stories.tsx | 119 +++++++++ .../internals/specular-map.stories.tsx | 84 +++++++ .../internals/surface-equations.stories.tsx | 232 ++++++++++++++++++ 8 files changed, 818 insertions(+) create mode 100644 libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/filters/filter.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/helpers.tsx create mode 100644 libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx create mode 100644 libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx diff --git a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx new file mode 100644 index 00000000000..c9829bca2b6 --- /dev/null +++ b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FilterOBB } from "../../src/components/filter-obb"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { + defaultFilterArgs, + FilterShowcase, + filterArgTypes, + type SharedFilterProps, +} from "../helpers"; + +const FilterOBBStory = (props: SharedFilterProps) => ( + + {(id) => } + +); + +const meta = { + title: "Filters/Filter OBB (ObjectBoundingBox)", + component: FilterOBBStory, + argTypes: filterArgTypes, + args: defaultFilterArgs, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { + args: { bezelHeightFn: CONVEX }, +}; + +export const ConvexCircle: Story = { + args: { bezelHeightFn: CONVEX_CIRCLE }, +}; + +export const Concave: Story = { + args: { bezelHeightFn: CONCAVE }, +}; + +export const Lip: Story = { + args: { bezelHeightFn: LIP }, +}; diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx new file mode 100644 index 00000000000..462a55141f6 --- /dev/null +++ b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FilterPolar } from "../../src/components/filter-polar"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { + defaultFilterArgs, + FilterShowcase, + filterArgTypes, + type SharedFilterProps, +} from "../helpers"; + +type FilterPolarStoryProps = Omit< + SharedFilterProps, + "specularOpacity" | "specularAngle" +>; + +const FilterPolarStory = (props: FilterPolarStoryProps) => ( + + {(id) => } + +); + +const { + specularOpacity: _a, + specularAngle: _b, + ...polarArgTypes +} = filterArgTypes; +const { + specularOpacity: _c, + specularAngle: _d, + ...polarArgs +} = defaultFilterArgs; + +const meta = { + title: "Filters/Filter Polar (Debug)", + component: FilterPolarStory, + argTypes: polarArgTypes, + args: polarArgs, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { + args: { bezelHeightFn: CONVEX }, +}; + +export const ConvexCircle: Story = { + args: { bezelHeightFn: CONVEX_CIRCLE }, +}; + +export const Concave: Story = { + args: { bezelHeightFn: CONCAVE }, +}; + +export const Lip: Story = { + args: { bezelHeightFn: LIP }, +}; diff --git a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx new file mode 100644 index 00000000000..4868c3556f8 --- /dev/null +++ b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Filter } from "../../src/components/filter"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { + defaultFilterArgs, + FilterShowcase, + filterArgTypes, + type SharedFilterProps, +} from "../helpers"; + +const FilterStory = (props: SharedFilterProps) => ( + + {(id) => ( + + )} + +); + +const meta = { + title: "Filters/Filter (Explicit Size)", + component: FilterStory, + argTypes: filterArgTypes, + args: defaultFilterArgs, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { + args: { bezelHeightFn: CONVEX }, +}; + +export const ConvexCircle: Story = { + args: { bezelHeightFn: CONVEX_CIRCLE }, +}; + +export const Concave: Story = { + args: { bezelHeightFn: CONCAVE }, +}; + +export const Lip: Story = { + args: { bezelHeightFn: LIP }, +}; diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx new file mode 100644 index 00000000000..3ea14bfcd37 --- /dev/null +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -0,0 +1,91 @@ +import { useId } from "react"; + +import type { SurfaceFnDef } from "../src/helpers/surface-equations"; +import { CONVEX } from "../src/helpers/surface-equations"; + +export type SharedFilterProps = { + blur: number; + radius: number; + glassThickness: number; + bezelWidth: number; + refractiveIndex: number; + specularOpacity: number; + specularAngle: number; + bezelHeightFn: SurfaceFnDef; +}; + +export const defaultFilterArgs: SharedFilterProps = { + blur: 2, + radius: 20, + glassThickness: 70, + bezelWidth: 30, + refractiveIndex: 1.5, + specularOpacity: 0.9, + specularAngle: 2, + bezelHeightFn: CONVEX, +}; + +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 } }, + glassThickness: { + control: { type: "range" as const, min: 0, max: 300, step: 1 }, + }, + bezelWidth: { + control: { type: "range" as const, min: 0, max: 100, step: 1 }, + }, + refractiveIndex: { + control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, + }, + specularOpacity: { + control: { type: "range" as const, min: 0, max: 1, step: 0.01 }, + }, + specularAngle: { + control: { type: "range" as const, min: 0, max: 6.28, step: 0.01 }, + }, + bezelHeightFn: { table: { disable: true } }, +}; + +/** + * Wrapper that renders a filter + a div with that filter applied, over a colorful background. + */ +export const FilterShowcase: React.FC<{ + children: (id: string) => React.ReactNode; +}> = ({ children }) => { + const filterId = useId(); + + return ( +
+ {children(filterId)} + +
+ Refractive Glass +
+
+ ); +}; diff --git a/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx new file mode 100644 index 00000000000..c7b2718a28f --- /dev/null +++ b/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { + calculateDisplacementMap, + calculateDisplacementMapRadius, +} from "../../src/maps/displacement-map"; + +type Props = { + radius: number; + glassThickness: number; + bezelWidth: number; + refractiveIndex: number; + bezelHeightFn: SurfaceFnDef; + pixelRatio: number; +}; + +const DisplacementMapVis = ({ + radius, + glassThickness, + bezelWidth, + refractiveIndex, + bezelHeightFn, + pixelRatio, +}: Props) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const cornerWidth = Math.max(radius, bezelWidth); + const imageSide = cornerWidth * 2 + 1; + + const map = calculateDisplacementMapRadius( + glassThickness, + bezelWidth, + bezelHeightFn, + refractiveIndex, + ); + const maximumDisplacement = Math.max(...map.map(Math.abs)); + + const imageData = calculateDisplacementMap({ + width: imageSide, + height: imageSide, + radius, + bezelWidth, + precomputedDisplacementMap: map, + maximumDisplacement, + pixelRatio, + }); + + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + }, [ + radius, + glassThickness, + bezelWidth, + refractiveIndex, + bezelHeightFn, + pixelRatio, + ]); + + return ( +
+

+ Red = X displacement, Green = Y displacement (128 = neutral) +

+ +
+ ); +}; + +const meta = { + title: "Internals/Displacement Map", + component: DisplacementMapVis, + argTypes: { + radius: { control: { type: "range", min: 0, max: 100, step: 1 } }, + glassThickness: { control: { type: "range", min: 0, max: 300, step: 1 } }, + bezelWidth: { control: { type: "range", min: 0, max: 100, step: 1 } }, + refractiveIndex: { + control: { type: "range", min: 1, max: 3, step: 0.01 }, + }, + pixelRatio: { control: { type: "range", min: 1, max: 12, step: 1 } }, + bezelHeightFn: { table: { disable: true } }, + }, + args: { + radius: 20, + glassThickness: 70, + bezelWidth: 30, + refractiveIndex: 1.5, + pixelRatio: 6, + bezelHeightFn: CONVEX, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { args: { bezelHeightFn: CONVEX } }; +export const ConvexCircle: Story = { args: { bezelHeightFn: CONVEX_CIRCLE } }; +export const Concave: Story = { args: { bezelHeightFn: CONCAVE } }; +export const Lip: Story = { args: { bezelHeightFn: LIP } }; 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..ec2fb3f4838 --- /dev/null +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; +import { calculatePolarDistanceMap } from "../../src/maps/polar-distance-map"; + +type Props = { + radius: number; + glassThickness: number; + bezelWidth: number; + refractiveIndex: number; + bezelHeightFn: SurfaceFnDef; + pixelRatio: number; +}; + +const PolarDistanceMapVis = ({ + radius, + glassThickness, + bezelWidth, + refractiveIndex, + bezelHeightFn, + pixelRatio, +}: Props) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const cornerWidth = Math.max(radius, bezelWidth); + const imageSide = cornerWidth * 2 + 1; + + const map = calculateDisplacementMapRadius( + glassThickness, + bezelWidth, + bezelHeightFn, + refractiveIndex, + ); + + const imageData = calculatePolarDistanceMap({ + width: imageSide, + height: imageSide, + radius, + bezelWidth, + precomputedDisplacementMap: map, + pixelRatio, + }); + + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + }, [ + radius, + glassThickness, + bezelWidth, + refractiveIndex, + bezelHeightFn, + pixelRatio, + ]); + + return ( +
+

+ Red = distance (px), Green = angle (0-2pi mapped to 0-255) +

+ +
+ ); +}; + +const meta = { + title: "Internals/Polar Distance Map", + component: PolarDistanceMapVis, + argTypes: { + radius: { control: { type: "range", min: 0, max: 100, step: 1 } }, + glassThickness: { control: { type: "range", min: 0, max: 300, step: 1 } }, + bezelWidth: { control: { type: "range", min: 0, max: 100, step: 1 } }, + refractiveIndex: { + control: { type: "range", min: 1, max: 3, step: 0.01 }, + }, + pixelRatio: { control: { type: "range", min: 1, max: 12, step: 1 } }, + bezelHeightFn: { table: { disable: true } }, + }, + args: { + radius: 20, + glassThickness: 70, + bezelWidth: 30, + refractiveIndex: 1.5, + pixelRatio: 6, + bezelHeightFn: CONVEX, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { args: { bezelHeightFn: CONVEX } }; +export const ConvexCircle: Story = { args: { bezelHeightFn: CONVEX_CIRCLE } }; +export const Concave: Story = { args: { bezelHeightFn: CONCAVE } }; +export const Lip: Story = { args: { bezelHeightFn: LIP } }; diff --git a/libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx new file mode 100644 index 00000000000..87c29100d66 --- /dev/null +++ b/libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import { calculateSpecularImage } from "../../src/maps/specular"; + +type Props = { + radius: number; + specularAngle: number; + pixelRatio: number; +}; + +const SpecularMapVis = ({ radius, specularAngle, pixelRatio }: Props) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const imageSide = radius * 2 + 1; + + const imageData = calculateSpecularImage({ + width: imageSide, + height: imageSide, + radius, + specularAngle, + pixelRatio, + }); + + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + }, [radius, specularAngle, pixelRatio]); + + return ( +
+

+ RGB = brightness, Alpha = specular intensity +

+ +
+ ); +}; + +const meta = { + title: "Internals/Specular Map", + component: SpecularMapVis, + argTypes: { + radius: { control: { type: "range", min: 5, max: 100, step: 1 } }, + specularAngle: { + control: { type: "range", min: 0, max: 6.28, step: 0.01 }, + }, + pixelRatio: { control: { type: "range", min: 1, max: 12, step: 1 } }, + }, + args: { + radius: 40, + specularAngle: Math.PI / 4, + pixelRatio: 6, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const TopLight: Story = { + args: { specularAngle: Math.PI / 2 }, +}; + +export const SideLight: Story = { + args: { specularAngle: 0 }, +}; diff --git a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx new file mode 100644 index 00000000000..f80ac248d89 --- /dev/null +++ b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx @@ -0,0 +1,232 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useRef } from "react"; + +import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; +import { + CONCAVE, + CONVEX, + CONVEX_CIRCLE, + LIP, +} from "../../src/helpers/surface-equations"; +import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; + +const PLOT_WIDTH = 400; +const PLOT_HEIGHT = 300; +const PADDING = 40; + +const equations: { name: string; fn: SurfaceFnDef; color: string }[] = [ + { name: "CONVEX", fn: CONVEX, color: "#4fc3f7" }, + { name: "CONVEX_CIRCLE", fn: CONVEX_CIRCLE, color: "#81c784" }, + { name: "CONCAVE", fn: CONCAVE, color: "#ff8a65" }, + { name: "LIP", fn: LIP, color: "#ce93d8" }, +]; + +type Props = { + glassThickness: number; + bezelWidth: number; + refractiveIndex: number; + samples: number; +}; + +class PlotRenderer { + private ctx: CanvasRenderingContext2D; + + constructor(canvas: HTMLCanvasElement) { + const w = (PLOT_WIDTH + PADDING * 2) * 2; + const h = (PLOT_HEIGHT + PADDING * 2) * 2; + canvas.width = w; // eslint-disable-line no-param-reassign + canvas.height = h; // eslint-disable-line no-param-reassign + canvas.style.width = `${w / 2}px`; // eslint-disable-line no-param-reassign + canvas.style.height = `${h / 2}px`; // eslint-disable-line no-param-reassign + this.ctx = canvas.getContext("2d")!; + this.ctx.scale(2, 2); + } + + draw( + title: string, + getPoints: (fn: SurfaceFnDef) => [number, number][], + centered = false, + ) { + const w = PLOT_WIDTH + PADDING * 2; + const h = PLOT_HEIGHT + PADDING * 2; + this.ctx.clearRect(0, 0, w, h); + + this.ctx.fillStyle = "#16213e"; + this.ctx.fillRect(PADDING, PADDING, PLOT_WIDTH, PLOT_HEIGHT); + + // Grid + this.ctx.strokeStyle = "#2a2a4a"; + this.ctx.lineWidth = 0.5; + for (let i = 0; i <= 10; i++) { + const x = PADDING + (PLOT_WIDTH * i) / 10; + const y = PADDING + (PLOT_HEIGHT * i) / 10; + this.ctx.beginPath(); + this.ctx.moveTo(x, PADDING); + this.ctx.lineTo(x, PADDING + PLOT_HEIGHT); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.moveTo(PADDING, y); + this.ctx.lineTo(PADDING + PLOT_WIDTH, y); + this.ctx.stroke(); + } + + if (centered) { + this.ctx.strokeStyle = "#555"; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + const zeroY = PADDING + PLOT_HEIGHT / 2; + this.ctx.moveTo(PADDING, zeroY); + this.ctx.lineTo(PADDING + PLOT_WIDTH, zeroY); + this.ctx.stroke(); + } + + for (const eq of equations) { + const points = getPoints(eq.fn); + if (points.length === 0) { + continue; + } + + this.ctx.strokeStyle = eq.color; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + + for (let i = 0; i < points.length; i++) { + const [px, py] = points[i]!; + const x = PADDING + px * PLOT_WIDTH; + const y = centered + ? PADDING + PLOT_HEIGHT / 2 - (py * PLOT_HEIGHT) / 2 + : PADDING + PLOT_HEIGHT - py * PLOT_HEIGHT; + + if (i === 0) { + this.ctx.moveTo(x, y); + } else { + this.ctx.lineTo(x, y); + } + } + this.ctx.stroke(); + } + + // Title + this.ctx.fillStyle = "#aaa"; + this.ctx.font = "13px monospace"; + this.ctx.textAlign = "center"; + this.ctx.fillText(title, w / 2, PADDING - 10); + + // Axis labels + this.ctx.fillStyle = "#666"; + this.ctx.font = "11px monospace"; + this.ctx.textAlign = "left"; + this.ctx.fillText("0", PADDING - 2, PADDING + PLOT_HEIGHT + 14); + this.ctx.textAlign = "right"; + this.ctx.fillText( + "1", + PADDING + PLOT_WIDTH + 2, + PADDING + PLOT_HEIGHT + 14, + ); + } +} + +const SurfaceEquationsVis = ({ + glassThickness, + bezelWidth, + refractiveIndex, + samples, +}: Props) => { + const surfaceCanvasRef = useRef(null); + const displacementCanvasRef = useRef(null); + + useEffect(() => { + const surfaceCanvas = surfaceCanvasRef.current; + const displacementCanvas = displacementCanvasRef.current; + if (!surfaceCanvas || !displacementCanvas) { + return; + } + + new PlotRenderer(surfaceCanvas).draw("Surface Shape: f(x)", (fn) => { + const points: [number, number][] = []; + for (let i = 0; i <= samples; i++) { + const x = i / samples; + points.push([x, fn(x)]); + } + return points; + }); + + new PlotRenderer(displacementCanvas).draw( + "Displacement Radius (px)", + (fn) => { + const map = calculateDisplacementMapRadius( + glassThickness, + bezelWidth, + fn, + refractiveIndex, + samples, + ); + const maxVal = Math.max(...map.map(Math.abs), 1); + return map.map( + (v, i) => [i / map.length, v / maxVal] as [number, number], + ); + }, + true, + ); + }, [glassThickness, bezelWidth, refractiveIndex, samples]); + + return ( +
+
+ {equations.map((eq) => ( + + {eq.name} + + ))} +
+ + +
+ ); +}; + +const meta = { + title: "Internals/Surface Equations", + component: SurfaceEquationsVis, + argTypes: { + glassThickness: { + control: { type: "range" as const, min: 0, max: 300, step: 1 }, + }, + bezelWidth: { + control: { type: "range" as const, min: 0, max: 100, step: 1 }, + }, + refractiveIndex: { + control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, + }, + samples: { + control: { type: "range" as const, min: 16, max: 512, step: 16 }, + }, + }, + args: { + glassThickness: 70, + bezelWidth: 30, + refractiveIndex: 1.5, + samples: 128, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AllCurves: Story = {}; From 559238e6151fdd1a0fc23719ae0307d11ef4e4d9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 01:41:00 +0100 Subject: [PATCH 03/19] FE-518: Update stories to use renamed camelCase surface equation exports Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stories/filters/filter-obb.stories.tsx | 16 ++++++++-------- .../stories/filters/filter-polar.stories.tsx | 16 ++++++++-------- .../stories/filters/filter.stories.tsx | 16 ++++++++-------- libs/@hashintel/refractive/stories/helpers.tsx | 4 ++-- .../internals/displacement-map.stories.tsx | 18 +++++++++--------- .../internals/polar-distance-map.stories.tsx | 18 +++++++++--------- .../internals/surface-equations.stories.tsx | 16 ++++++++-------- 7 files changed, 52 insertions(+), 52 deletions(-) diff --git a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx index c9829bca2b6..258564bee3a 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx @@ -2,10 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { FilterOBB } from "../../src/components/filter-obb"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { defaultFilterArgs, @@ -32,17 +32,17 @@ export default meta; type Story = StoryObj; export const Convex: Story = { - args: { bezelHeightFn: CONVEX }, + args: { bezelHeightFn: convex }, }; export const ConvexCircle: Story = { - args: { bezelHeightFn: CONVEX_CIRCLE }, + args: { bezelHeightFn: convexCircle }, }; export const Concave: Story = { - args: { bezelHeightFn: CONCAVE }, + args: { bezelHeightFn: concave }, }; export const Lip: Story = { - args: { bezelHeightFn: LIP }, + args: { bezelHeightFn: lip }, }; diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx index 462a55141f6..2728411a50f 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx @@ -2,10 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { FilterPolar } from "../../src/components/filter-polar"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { defaultFilterArgs, @@ -48,17 +48,17 @@ export default meta; type Story = StoryObj; export const Convex: Story = { - args: { bezelHeightFn: CONVEX }, + args: { bezelHeightFn: convex }, }; export const ConvexCircle: Story = { - args: { bezelHeightFn: CONVEX_CIRCLE }, + args: { bezelHeightFn: convexCircle }, }; export const Concave: Story = { - args: { bezelHeightFn: CONCAVE }, + args: { bezelHeightFn: concave }, }; export const Lip: Story = { - args: { bezelHeightFn: LIP }, + args: { bezelHeightFn: lip }, }; diff --git a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx index 4868c3556f8..e0cfa47d02d 100644 --- a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx @@ -2,10 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Filter } from "../../src/components/filter"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { defaultFilterArgs, @@ -41,17 +41,17 @@ export default meta; type Story = StoryObj; export const Convex: Story = { - args: { bezelHeightFn: CONVEX }, + args: { bezelHeightFn: convex }, }; export const ConvexCircle: Story = { - args: { bezelHeightFn: CONVEX_CIRCLE }, + args: { bezelHeightFn: convexCircle }, }; export const Concave: Story = { - args: { bezelHeightFn: CONCAVE }, + args: { bezelHeightFn: concave }, }; export const Lip: Story = { - args: { bezelHeightFn: LIP }, + args: { bezelHeightFn: lip }, }; diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx index 3ea14bfcd37..65efdf9b7d1 100644 --- a/libs/@hashintel/refractive/stories/helpers.tsx +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -1,7 +1,7 @@ import { useId } from "react"; import type { SurfaceFnDef } from "../src/helpers/surface-equations"; -import { CONVEX } from "../src/helpers/surface-equations"; +import { convex } from "../src/helpers/surface-equations"; export type SharedFilterProps = { blur: number; @@ -22,7 +22,7 @@ export const defaultFilterArgs: SharedFilterProps = { refractiveIndex: 1.5, specularOpacity: 0.9, specularAngle: 2, - bezelHeightFn: CONVEX, + bezelHeightFn: convex, }; export const filterArgTypes = { diff --git a/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx index c7b2718a28f..ca41641b24b 100644 --- a/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx @@ -3,10 +3,10 @@ import { useEffect, useRef } from "react"; import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { calculateDisplacementMap, @@ -109,7 +109,7 @@ const meta = { bezelWidth: 30, refractiveIndex: 1.5, pixelRatio: 6, - bezelHeightFn: CONVEX, + bezelHeightFn: convex, }, } satisfies Meta; @@ -117,7 +117,7 @@ export default meta; type Story = StoryObj; -export const Convex: Story = { args: { bezelHeightFn: CONVEX } }; -export const ConvexCircle: Story = { args: { bezelHeightFn: CONVEX_CIRCLE } }; -export const Concave: Story = { args: { bezelHeightFn: CONCAVE } }; -export const Lip: Story = { args: { bezelHeightFn: LIP } }; +export const Convex: Story = { args: { bezelHeightFn: convex } }; +export const ConvexCircle: Story = { args: { bezelHeightFn: convexCircle } }; +export const Concave: Story = { args: { bezelHeightFn: concave } }; +export const Lip: Story = { args: { bezelHeightFn: lip } }; diff --git a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx index ec2fb3f4838..5c610fb09f4 100644 --- a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -3,10 +3,10 @@ import { useEffect, useRef } from "react"; import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; import { calculatePolarDistanceMap } from "../../src/maps/polar-distance-map"; @@ -105,7 +105,7 @@ const meta = { bezelWidth: 30, refractiveIndex: 1.5, pixelRatio: 6, - bezelHeightFn: CONVEX, + bezelHeightFn: convex, }, } satisfies Meta; @@ -113,7 +113,7 @@ export default meta; type Story = StoryObj; -export const Convex: Story = { args: { bezelHeightFn: CONVEX } }; -export const ConvexCircle: Story = { args: { bezelHeightFn: CONVEX_CIRCLE } }; -export const Concave: Story = { args: { bezelHeightFn: CONCAVE } }; -export const Lip: Story = { args: { bezelHeightFn: LIP } }; +export const Convex: Story = { args: { bezelHeightFn: convex } }; +export const ConvexCircle: Story = { args: { bezelHeightFn: convexCircle } }; +export const Concave: Story = { args: { bezelHeightFn: concave } }; +export const Lip: Story = { args: { bezelHeightFn: lip } }; diff --git a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx index f80ac248d89..196a4ccaab5 100644 --- a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx @@ -3,10 +3,10 @@ import { useEffect, useRef } from "react"; import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; import { - CONCAVE, - CONVEX, - CONVEX_CIRCLE, - LIP, + concave, + convex, + convexCircle, + lip, } from "../../src/helpers/surface-equations"; import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; @@ -15,10 +15,10 @@ const PLOT_HEIGHT = 300; const PADDING = 40; const equations: { name: string; fn: SurfaceFnDef; color: string }[] = [ - { name: "CONVEX", fn: CONVEX, color: "#4fc3f7" }, - { name: "CONVEX_CIRCLE", fn: CONVEX_CIRCLE, color: "#81c784" }, - { name: "CONCAVE", fn: CONCAVE, color: "#ff8a65" }, - { name: "LIP", fn: LIP, color: "#ce93d8" }, + { name: "convex", fn: convex, color: "#4fc3f7" }, + { name: "convexCircle", fn: convexCircle, color: "#81c784" }, + { name: "concave", fn: concave, color: "#ff8a65" }, + { name: "lip", fn: lip, color: "#ce93d8" }, ]; type Props = { From e9d58fcc101b2616b86b6cb5c06eef6703b0ec3a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 01:50:44 +0100 Subject: [PATCH 04/19] FE-43: Add switchable background to filter stories Default background is now the scrollable ExampleArticle with a sticky glass overlay. A "background" radio control lets you switch to the checkerboard pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stories/filters/filter-obb.stories.tsx | 4 +- .../stories/filters/filter-polar.stories.tsx | 4 +- .../stories/filters/filter.stories.tsx | 4 +- .../@hashintel/refractive/stories/helpers.tsx | 89 ++++++++++++++----- 4 files changed, 73 insertions(+), 28 deletions(-) diff --git a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx index 258564bee3a..bdf5203956c 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx @@ -14,8 +14,8 @@ import { type SharedFilterProps, } from "../helpers"; -const FilterOBBStory = (props: SharedFilterProps) => ( - +const FilterOBBStory = ({ background, ...props }: SharedFilterProps) => ( + {(id) => } ); diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx index 2728411a50f..28b59d5ca8a 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx @@ -19,8 +19,8 @@ type FilterPolarStoryProps = Omit< "specularOpacity" | "specularAngle" >; -const FilterPolarStory = (props: FilterPolarStoryProps) => ( - +const FilterPolarStory = ({ background, ...props }: FilterPolarStoryProps) => ( + {(id) => } ); diff --git a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx index e0cfa47d02d..79dae36d7c8 100644 --- a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx @@ -14,8 +14,8 @@ import { type SharedFilterProps, } from "../helpers"; -const FilterStory = (props: SharedFilterProps) => ( - +const FilterStory = ({ background, ...props }: SharedFilterProps) => ( + {(id) => ( / SVG element. */ -export const FilterShowcase: React.FC<{ - children: (id: string) => React.ReactNode; -}> = ({ children }) => { - const filterId = useId(); - - return ( +const GlassPane: React.FC<{ + filterId: string; + children: React.ReactNode; +}> = ({ filterId, children }) => ( + <> + {children}
- {children(filterId)} + Refractive Glass +
+ +); + +/** + * Wrapper that renders a filter + a div with that filter applied, over a selectable background. + */ +export const FilterShowcase: React.FC<{ + background?: BackgroundType; + children: (id: string) => React.ReactNode; +}> = ({ background = "article", children }) => { + const filterId = useId(); + + const glass = {children(filterId)}; + if (background === "checkerboard") { + return (
- Refractive Glass + {glass} +
+ ); + } + + return ( +
+
+
{glass}
+
); }; From 8370027c31df0abea2bf9cb372d50eb149293e27 Mon Sep 17 00:00:00 2001 From: hash Date: Thu, 19 Mar 2026 15:12:05 +0100 Subject: [PATCH 05/19] FE-43: Implement polar coordinate indirection filter and remove specular Add FilterPolar with SVG filter math to decouple shape geometry from optical parameters. The geometric polar map (distance ratio + angle) is rasterized once per shape, then Snell's law refraction is applied via feComponentTransfer lookup tables and polar-to-cartesian conversion uses feColorMatrix + feComposite arithmetic signed multiplication. Remove specular props and computation from all filter components (Filter, FilterOBB, FilterPolar) and the refractive HOC. Add filter evolution roadmap to CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/@hashintel/refractive/CLAUDE.md | 75 +++++++ .../refractive/src/components/filter-obb.tsx | 51 +---- .../src/components/filter-polar.tsx | 184 ++++++++++++++---- .../refractive/src/components/filter.tsx | 51 ----- .../refractive/src/hoc/refractive.tsx | 4 - .../src/maps/geometric-polar-map.ts | 69 +++++++ .../stories/filters/filter-polar.stories.tsx | 26 +-- .../@hashintel/refractive/stories/helpers.tsx | 10 - .../refractive/stories/playground.stories.tsx | 2 - 9 files changed, 298 insertions(+), 174 deletions(-) create mode 100644 libs/@hashintel/refractive/CLAUDE.md create mode 100644 libs/@hashintel/refractive/src/maps/geometric-polar-map.ts 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/filter-obb.tsx b/libs/@hashintel/refractive/src/components/filter-obb.tsx index ece24db26d1..2d516b277ec 100644 --- a/libs/@hashintel/refractive/src/components/filter-obb.tsx +++ b/libs/@hashintel/refractive/src/components/filter-obb.tsx @@ -2,7 +2,6 @@ import { calculateDisplacementMap, calculateDisplacementMapRadius, } from "../maps/displacement-map"; -import { calculateSpecularImage } from "../maps/specular"; import { CompositeImage } from "./composite-image"; type FilterOBBProps = { @@ -13,8 +12,6 @@ type FilterOBBProps = { glassThickness: number; bezelWidth: number; refractiveIndex: number; - specularOpacity: number; - specularAngle: number; bezelHeightFn: (x: number) => number; pixelRatio: number; hideTop?: boolean; @@ -29,7 +26,7 @@ type FilterOBBProps = { * * Instead of requiring explicit width/height and a ResizeObserver, this filter: * - Sets the filter region to exactly match the element's bounding box (`x="0" y="0" width="1" height="1"`) - * - Uses a single feImage per map (displacement + specular) referencing SVG data URLs + * - Uses a single feImage per map (displacement) referencing SVG data URLs * - The SVG data URLs contain all 9 image parts composited via nested SVGs with percentage positioning * * Usage: @@ -51,7 +48,6 @@ export const FilterOBB: React.FC = ({ bezelWidth, refractiveIndex, scaleRatio, - specularOpacity, bezelHeightFn, pixelRatio, hideTop, @@ -87,14 +83,6 @@ export const FilterOBB: React.FC = ({ 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 = ( @@ -116,49 +104,12 @@ export const FilterOBB: React.FC = ({ hideRight={hideRight} /> - - - - - - - - - - - - - - ); diff --git a/libs/@hashintel/refractive/src/components/filter-polar.tsx b/libs/@hashintel/refractive/src/components/filter-polar.tsx index b13bc856030..925cafc863a 100644 --- a/libs/@hashintel/refractive/src/components/filter-polar.tsx +++ b/libs/@hashintel/refractive/src/components/filter-polar.tsx @@ -1,9 +1,10 @@ import { calculateDisplacementMapRadius } from "../maps/displacement-map"; -import { calculatePolarDistanceMap } from "../maps/polar-distance-map"; +import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; import { CompositeImage } from "./composite-image"; type FilterPolarProps = { id: string; + scaleRatio: number; blur: number; radius: number; glassThickness: number; @@ -17,21 +18,42 @@ type FilterPolarProps = { hideRight?: boolean; }; +/** + * Generate a space-separated string of `size` values from a mapping function, + * suitable for SVG `feComponentTransfer` `tableValues`. + */ +function generateTableValues( + size: number, + fn: (index: number) => number, +): string { + return Array.from({ length: size }, (_, i) => fn(i).toFixed(6)).join(" "); +} + /** * @private - * Filter that displays a polar distance map (distance + angle) instead of applying displacement. + * Filter that uses polar coordinate indirection to decouple shape geometry + * from the optical transfer function. + * + * Instead of computing a full displacement bitmap per parameter change, this filter: + * 1. Rasterizes a geometry-only polar field (border distance ratio + angle toward center). + * This depends only on shape (radius, bezelWidth), not optical parameters. + * 2. Applies the optical transfer function (Snell's law refraction) via an SVG + * `feComponentTransfer` lookup table — a cheap update when parameters change. + * 3. Converts polar (magnitude, angle) → cartesian (dx, dy) via SVG filter math: + * `feColorMatrix` to separate channels, `feComponentTransfer` for cos/sin tables, + * and `feComposite` arithmetic for signed multiplication. * - * The polar distance map encodes: - * - Red channel: displacement distance in pixels, clamped to [0, 255] - * - Green channel: displacement angle mapped from [0, 2π] to [0, 255] + * The polar field bitmap is reusable across changes to optical parameters + * (glassThickness, refractiveIndex, bezelHeightFn). Only the lookup table + * values need to change. * - * Uses objectBoundingBox sizing via a composite SVG data URL (no ResizeObserver needed). - * Does not apply displacement or specular — just shows the raw polar map. + * Uses `objectBoundingBox` filter units (no ResizeObserver needed). */ export const FilterPolar: React.FC = ({ id, radius, blur, + scaleRatio, glassThickness, bezelWidth, refractiveIndex, @@ -45,46 +67,136 @@ export const FilterPolar: React.FC = ({ const cornerWidth = Math.max(radius, bezelWidth); const imageSide = cornerWidth * 2 + 1; - const map = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, - bezelHeightFn, - refractiveIndex, - ); - - const polarMap = calculatePolarDistanceMap({ + // --- Geometry-only polar map (reusable across optical parameter changes) --- + const polarMap = calculateGeometricPolarMap({ width: imageSide, height: imageSide, radius, bezelWidth, - precomputedDisplacementMap: map, pixelRatio, }); - const content = ( - - - - - + // --- Optical transfer function (encoded as SVG lookup tables) --- + const displacementRadius = calculateDisplacementMapRadius( + glassThickness, + bezelWidth, + bezelHeightFn, + refractiveIndex, ); + const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); + + // Magnitude table: border distance ratio → signed normalized displacement. + // Maps [0, 1] (ratio) through the Snell's law displacement curve, + // then normalizes to [0, 1] centered at 0.5 (where 0.5 = no displacement). + // Index 0 is forced to 0.5 so fill-color pixels produce no displacement. + const magnitudeTable = generateTableValues(256, (i) => { + if (i === 0 || maximumDisplacement === 0) { + return 0.5; + } + const ratio = i / 255; + 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), + ); + }); + + // Trig tables: angle index [0..255] → cos/sin mapped to [0, 1] centered at 0.5. + 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; + }); + + // Scale factor accounts for the signed-multiplication encoding: + // the effective max displacement from feComposite arithmetic is ±0.5, + // so we double the scale to compensate. + const scale = 2 * maximumDisplacement * scaleRatio; + + const hideProps = { hideTop, hideBottom, hideLeft, hideRight }; + return ( - {content} + + + {/* 1. Blur source graphic */} + + + {/* 2. Composite the geometry-only polar map (R=ratio, G=angle) */} + + + {/* 3. Copy angle (G) into R and G for trig lookup */} + + + {/* 4. Apply cos table to R, sin table to G */} + + + + + + {/* 5. Copy distance ratio (R) into R and G for magnitude lookup */} + + + {/* 6. Apply optical transfer function (Snell's law) to both channels */} + + + + + + {/* 7. Signed multiplication: magnitude × trig → displacement map. + For two signed values centered at 0.5 (a_signed = 2a−1, b_signed = 2b−1): + result = (a_signed × b_signed + 1) / 2 = 2ab − a − b + 1 + So: k1=2, k2=−1, k3=−1, k4=1 */} + + + {/* 8. Apply displacement */} + + + ); }; diff --git a/libs/@hashintel/refractive/src/components/filter.tsx b/libs/@hashintel/refractive/src/components/filter.tsx index 066ef26d781..8462cc400e9 100644 --- a/libs/@hashintel/refractive/src/components/filter.tsx +++ b/libs/@hashintel/refractive/src/components/filter.tsx @@ -2,7 +2,6 @@ import { calculateDisplacementMap, calculateDisplacementMapRadius, } from "../maps/displacement-map"; -import { calculateSpecularImage } from "../maps/specular"; import { CompositeParts } from "./composite-parts"; type FilterProps = { @@ -15,8 +14,6 @@ type FilterProps = { glassThickness: number; bezelWidth: number; refractiveIndex: number; - specularOpacity: number; - specularAngle: number; bezelHeightFn: (x: number) => number; pixelRatio: number; hideTop?: boolean; @@ -52,7 +49,6 @@ export const Filter: React.FC = ({ bezelWidth, refractiveIndex, scaleRatio, - specularOpacity, bezelHeightFn, pixelRatio, hideTop, @@ -88,14 +84,6 @@ export const Filter: React.FC = ({ 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 = ( @@ -119,51 +107,12 @@ export const Filter: React.FC = ({ hideRight={hideRight} /> - - - - - - - - - - - - - -
); diff --git a/libs/@hashintel/refractive/src/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index fcc1c331558..066f8e44576 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -12,8 +12,6 @@ type RefractionProps = { glassThickness?: number; bezelWidth?: number; refractiveIndex?: number; - specularOpacity?: number; - specularAngle?: number; bezelHeightFn?: (x: number) => number; }; }; @@ -88,8 +86,6 @@ function createRefractiveComponent< glassThickness={refraction.glassThickness ?? 70} bezelWidth={refraction.bezelWidth ?? 0} refractiveIndex={refraction.refractiveIndex ?? 1.5} - specularOpacity={refraction.specularOpacity ?? 0} - specularAngle={refraction.specularAngle ?? 0} bezelHeightFn={refraction.bezelHeightFn ?? convex} /> diff --git a/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts new file mode 100644 index 00000000000..294b0ed70dc --- /dev/null +++ b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts @@ -0,0 +1,69 @@ +/* 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. + * + * This map depends only on shape parameters (radius, bezelWidth), NOT on optical + * parameters (glassThickness, refractiveIndex, bezelHeightFn). 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 calculateGeometricPolarMap(props: { + width: number; + height: number; + radius: number; + bezelWidth: number; + pixelRatio: number; +}) { + const { pixelRatio } = 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, + // R=0 (at border), G=0, B=0, A=255 + fillColor: 0xff000000, + processPixel( + _x, + _y, + buffer, + offset, + _distanceFromCenter, + distanceFromBorder, + distanceFromBorderRatio, + angle, + opacity, + ) { + const ratio = + bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel; + + // 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(ratio * 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/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx index 28b59d5ca8a..3005407eb53 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx @@ -14,33 +14,17 @@ import { type SharedFilterProps, } from "../helpers"; -type FilterPolarStoryProps = Omit< - SharedFilterProps, - "specularOpacity" | "specularAngle" ->; - -const FilterPolarStory = ({ background, ...props }: FilterPolarStoryProps) => ( +const FilterPolarStory = ({ background, ...props }: SharedFilterProps) => ( - {(id) => } + {(id) => } ); -const { - specularOpacity: _a, - specularAngle: _b, - ...polarArgTypes -} = filterArgTypes; -const { - specularOpacity: _c, - specularAngle: _d, - ...polarArgs -} = defaultFilterArgs; - const meta = { - title: "Filters/Filter Polar (Debug)", + title: "Filters/Filter Polar (Indirection)", component: FilterPolarStory, - argTypes: polarArgTypes, - args: polarArgs, + argTypes: filterArgTypes, + args: defaultFilterArgs, } satisfies Meta; export default meta; diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx index 96afc509d30..2c36e43e932 100644 --- a/libs/@hashintel/refractive/stories/helpers.tsx +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -12,8 +12,6 @@ export type SharedFilterProps = { glassThickness: number; bezelWidth: number; refractiveIndex: number; - specularOpacity: number; - specularAngle: number; bezelHeightFn: SurfaceFnDef; background: BackgroundType; }; @@ -24,8 +22,6 @@ export const defaultFilterArgs: SharedFilterProps = { glassThickness: 70, bezelWidth: 30, refractiveIndex: 1.5, - specularOpacity: 0.9, - specularAngle: 2, bezelHeightFn: convex, background: "article", }; @@ -42,12 +38,6 @@ export const filterArgTypes = { refractiveIndex: { control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, }, - specularOpacity: { - control: { type: "range" as const, min: 0, max: 1, step: 0.01 }, - }, - specularAngle: { - control: { type: "range" as const, min: 0, max: 6.28, step: 0.01 }, - }, bezelHeightFn: { table: { disable: true } }, background: { control: { type: "inline-radio" as const }, diff --git a/libs/@hashintel/refractive/stories/playground.stories.tsx b/libs/@hashintel/refractive/stories/playground.stories.tsx index a2338d512cb..60cce0ba195 100644 --- a/libs/@hashintel/refractive/stories/playground.stories.tsx +++ b/libs/@hashintel/refractive/stories/playground.stories.tsx @@ -7,12 +7,10 @@ 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, }; const GlassOverArticle = () => ( From 87a9e5b628d9ff0f93d5cce8bdcfe0f297ce93be Mon Sep 17 00:00:00 2001 From: hash Date: Thu, 19 Mar 2026 15:29:30 +0100 Subject: [PATCH 06/19] FE-43: Fix hardcoded borderRadius in filter stories The GlassPane had borderRadius: 20 hardcoded, causing the CSS border radius to drift from the filter's radius when adjusted via controls. Now the radius prop flows through FilterShowcase to GlassPane. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stories/filters/filter-obb.stories.tsx | 18 +++++++++++++++--- .../stories/filters/filter-polar.stories.tsx | 18 +++++++++++++++--- .../stories/filters/filter.stories.tsx | 5 +++-- libs/@hashintel/refractive/stories/helpers.tsx | 14 ++++++++++---- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx index bdf5203956c..4f259515aff 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx @@ -14,9 +14,21 @@ import { type SharedFilterProps, } from "../helpers"; -const FilterOBBStory = ({ background, ...props }: SharedFilterProps) => ( - - {(id) => } +const FilterOBBStory = ({ + background, + radius, + ...props +}: SharedFilterProps) => ( + + {(id) => ( + + )} ); diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx index 3005407eb53..623763d830b 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx @@ -14,9 +14,21 @@ import { type SharedFilterProps, } from "../helpers"; -const FilterPolarStory = ({ background, ...props }: SharedFilterProps) => ( - - {(id) => } +const FilterPolarStory = ({ + background, + radius, + ...props +}: SharedFilterProps) => ( + + {(id) => ( + + )} ); diff --git a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx index 79dae36d7c8..33fbd964812 100644 --- a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx @@ -14,8 +14,8 @@ import { type SharedFilterProps, } from "../helpers"; -const FilterStory = ({ background, ...props }: SharedFilterProps) => ( - +const FilterStory = ({ background, radius, ...props }: SharedFilterProps) => ( + {(id) => ( ( pixelRatio={6} width={400} height={300} + radius={radius} {...props} /> )} diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx index 2c36e43e932..82ee6df426f 100644 --- a/libs/@hashintel/refractive/stories/helpers.tsx +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -51,8 +51,9 @@ export const filterArgTypes = { */ const GlassPane: React.FC<{ filterId: string; + radius: number; children: React.ReactNode; -}> = ({ filterId, children }) => ( +}> = ({ filterId, radius, children }) => ( <> {children}
React.ReactNode; -}> = ({ background = "article", children }) => { +}> = ({ radius = 20, background = "article", children }) => { const filterId = useId(); - const glass = {children(filterId)}; + const glass = ( + + {children(filterId)} + + ); if (background === "checkerboard") { return ( From d14b7f423cbdea03181ff685dfa7ca5000a33b56 Mon Sep 17 00:00:00 2001 From: hash Date: Thu, 19 Mar 2026 15:58:50 +0100 Subject: [PATCH 07/19] FE-43: Add hi-res single-image polar filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterPolarHiRes pre-computes a 513×513 geometric polar map once at module load and reuses it for any radius. On render, only cheap string operations run (SVG composite URL + magnitude lookup table). The bezel width always equals the radius. Export buildCompositeSvgUrl from composite-image so the hi-res filter can split at reference resolution but position at actual radius. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/composite-image.tsx | 2 +- .../src/components/filter-polar-hires.tsx | 208 ++++++++++++++++++ .../filters/filter-polar-hires.stories.tsx | 85 +++++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 libs/@hashintel/refractive/src/components/filter-polar-hires.tsx create mode 100644 libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx diff --git a/libs/@hashintel/refractive/src/components/composite-image.tsx b/libs/@hashintel/refractive/src/components/composite-image.tsx index 1e5859b98ba..a760876f394 100644 --- a/libs/@hashintel/refractive/src/components/composite-image.tsx +++ b/libs/@hashintel/refractive/src/components/composite-image.tsx @@ -22,7 +22,7 @@ type CompositeImageProps = { * size the feImage renders it at. Corners are placed at fixed pixel sizes using * nested SVGs with overflow="visible" and percentage-based positioning. */ -function buildCompositeSvgUrl( +export function buildCompositeSvgUrl( parts: Parts, cornerWidth: number, hideTop?: boolean, diff --git a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx new file mode 100644 index 00000000000..bfd8452f938 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx @@ -0,0 +1,208 @@ +import { calculateDisplacementMapRadius } from "../maps/displacement-map"; +import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; +import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; +import { buildCompositeSvgUrl } from "./composite-image"; + +/** + * 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 bezelWidth = radius and the map encodes normalized values + * (border distance ratio + angle), the same image works for any radius. + */ +const hiResPolarMap = calculateGeometricPolarMap({ + width: REFERENCE_RADIUS * 2 + 1, + height: REFERENCE_RADIUS * 2 + 1, + radius: REFERENCE_RADIUS, + bezelWidth: REFERENCE_RADIUS, + pixelRatio: 1, +}); + +/** + * 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 FilterPolarHiResProps = { + id: string; + scaleRatio: number; + blur: number; + radius: number; + glassThickness: number; + refractiveIndex: number; + bezelHeightFn: (x: number) => number; + hideTop?: boolean; + hideBottom?: boolean; + hideLeft?: boolean; + hideRight?: boolean; +}; + +/** + * Generate a space-separated string of `size` values from a mapping function, + * suitable for SVG `feComponentTransfer` `tableValues`. + */ +function generateTableValues( + size: number, + fn: (index: number) => number, +): string { + return Array.from({ length: size }, (_, i) => fn(i).toFixed(6)).join(" "); +} + +// 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; +}); + +/** + * @private + * Filter that reuses a single pre-computed hi-res (513×513) geometric polar field + * for any radius. The bezel width is always equal to the radius. + * + * The hi-res polar map and its 9-patch parts are computed once at module load. + * On each render, only the SVG composite URL (positioning corners at the actual + * radius) and the magnitude lookup table (encoding optical parameters) are + * recomputed — both are cheap string operations. + * + * Uses `objectBoundingBox` filter units (no ResizeObserver needed). + */ +export const FilterPolarHiRes: React.FC = ({ + id, + radius, + blur, + scaleRatio, + glassThickness, + refractiveIndex, + bezelHeightFn, + hideTop, + hideBottom, + hideLeft, + hideRight, +}) => { + // Build composite SVG positioned at the actual radius (corners are hi-res, + // browser downscales them to fit). + const svgUrl = buildCompositeSvgUrl( + hiResParts, + radius, + hideTop, + hideBottom, + hideLeft, + hideRight, + ); + + // Optical transfer function: bezelWidth = radius for this filter. + const displacementRadius = calculateDisplacementMapRadius( + glassThickness, + radius, + bezelHeightFn, + refractiveIndex, + ); + + const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); + + // Magnitude table: border distance ratio → signed normalized displacement. + const magnitudeTable = generateTableValues(256, (i) => { + if (i === 0 || maximumDisplacement === 0) { + return 0.5; + } + const ratio = i / 255; + 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), + ); + }); + + const scale = 2 * maximumDisplacement * scaleRatio; + + return ( + + + + {/* 1. Blur source graphic */} + + + {/* 2. Hi-res polar map, corners positioned at actual radius */} + + + {/* 3. Copy angle (G) into R and G for trig lookup */} + + + {/* 4. Apply cos table to R, sin table to G */} + + + + + + {/* 5. Copy distance ratio (R) into R and G for magnitude lookup */} + + + {/* 6. Apply optical transfer function (Snell's law) to both channels */} + + + + + + {/* 7. Signed multiplication: magnitude × trig → displacement map */} + + + {/* 8. Apply displacement */} + + + + + ); +}; diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx new file mode 100644 index 00000000000..f580bbac347 --- /dev/null +++ b/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { FilterPolarHiRes } from "../../src/components/filter-polar-hires"; +import { + concave, + convex, + convexCircle, + lip, +} from "../../src/helpers/surface-equations"; +import { FilterShowcase } from "../helpers"; +import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; +import type { BackgroundType } from "../helpers"; + +type FilterPolarHiResStoryProps = { + blur: number; + radius: number; + glassThickness: number; + refractiveIndex: number; + bezelHeightFn: SurfaceFnDef; + background: BackgroundType; +}; + +const FilterPolarHiResStory = ({ + background, + radius, + ...props +}: FilterPolarHiResStoryProps) => ( + + {(id) => ( + + )} + +); + +const meta = { + title: "Filters/Filter Polar Hi-Res (Single Image)", + component: FilterPolarHiResStory, + argTypes: { + 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 }, + }, + glassThickness: { + control: { type: "range" as const, min: 0, max: 300, step: 1 }, + }, + refractiveIndex: { + control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, + }, + bezelHeightFn: { table: { disable: true } }, + background: { + control: { type: "inline-radio" as const }, + options: ["article", "checkerboard"], + }, + }, + args: { + blur: 2, + radius: 20, + glassThickness: 70, + refractiveIndex: 1.5, + bezelHeightFn: convex, + background: "article", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Convex: Story = { + args: { bezelHeightFn: convex }, +}; + +export const ConvexCircle: Story = { + args: { bezelHeightFn: convexCircle }, +}; + +export const Concave: Story = { + args: { bezelHeightFn: concave }, +}; + +export const Lip: Story = { + args: { bezelHeightFn: lip }, +}; From 4abac808516f4612882e4d80298b20cfd120d8b2 Mon Sep 17 00:00:00 2001 From: hash Date: Thu, 19 Mar 2026 19:38:57 +0100 Subject: [PATCH 08/19] FE-43: Add bezelWidth prop to FilterPolarHiRes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remap the magnitude table input by radius/bezelWidth so the bezel occupies only the outer fraction of the corner. Ratios beyond the bezel saturate at the deepest displacement value. No image recomputation needed — purely a table change. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/filter-polar-hires.tsx | 20 +++++++++++++++---- .../filters/filter-polar-hires.stories.tsx | 5 +++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx index bfd8452f938..fed3302f18a 100644 --- a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx +++ b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx @@ -40,6 +40,7 @@ type FilterPolarHiResProps = { blur: number; radius: number; glassThickness: number; + bezelWidth: number; refractiveIndex: number; bezelHeightFn: (x: number) => number; hideTop?: boolean; @@ -73,7 +74,9 @@ const sinTable = generateTableValues(256, (i) => { /** * @private * Filter that reuses a single pre-computed hi-res (513×513) geometric polar field - * for any radius. The bezel width is always equal to the radius. + * for any radius. The hi-res map is computed with bezelWidth = radius (full corner). + * When bezelWidth < radius, the magnitude table remaps the distance ratio by + * `radius / bezelWidth` (capped at 1), compressing the bezel into a narrower band. * * The hi-res polar map and its 9-patch parts are computed once at module load. * On each render, only the SVG composite URL (positioning corners at the actual @@ -88,6 +91,7 @@ export const FilterPolarHiRes: React.FC = ({ blur, scaleRatio, glassThickness, + bezelWidth, refractiveIndex, bezelHeightFn, hideTop, @@ -106,22 +110,30 @@ export const FilterPolarHiRes: React.FC = ({ hideRight, ); - // Optical transfer function: bezelWidth = radius for this filter. + const clampedBezelWidth = Math.min(bezelWidth, radius); + const displacementRadius = calculateDisplacementMapRadius( glassThickness, - radius, + clampedBezelWidth, bezelHeightFn, refractiveIndex, ); const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); + // The hi-res map encodes ratio over the full radius (bezelWidth = radius). + // When bezelWidth < radius, remap: the bezel occupies only the outer + // (bezelWidth / radius) fraction of the corner, so we scale the ratio + // by (radius / bezelWidth) and cap at 1. Anything beyond the bezel + // gets the deepest displacement value. + const ratioScale = clampedBezelWidth > 0 ? radius / clampedBezelWidth : 1; + // Magnitude table: border distance ratio → signed normalized displacement. const magnitudeTable = generateTableValues(256, (i) => { if (i === 0 || maximumDisplacement === 0) { return 0.5; } - const ratio = i / 255; + const ratio = Math.min(1, (i / 255) * ratioScale); const sampleIndex = Math.min( Math.round(ratio * displacementRadius.length), displacementRadius.length - 1, diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx index f580bbac347..80c319a0eb7 100644 --- a/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx +++ b/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx @@ -15,6 +15,7 @@ type FilterPolarHiResStoryProps = { blur: number; radius: number; glassThickness: number; + bezelWidth: number; refractiveIndex: number; bezelHeightFn: SurfaceFnDef; background: BackgroundType; @@ -45,6 +46,9 @@ const meta = { glassThickness: { control: { type: "range" as const, min: 0, max: 300, step: 1 }, }, + bezelWidth: { + control: { type: "range" as const, min: 0, max: 100, step: 1 }, + }, refractiveIndex: { control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, }, @@ -58,6 +62,7 @@ const meta = { blur: 2, radius: 20, glassThickness: 70, + bezelWidth: 30, refractiveIndex: 1.5, bezelHeightFn: convex, background: "article", From 4c7db0956e46b006112b9e4e70d7d9da35e5ee11 Mon Sep 17 00:00:00 2001 From: hash Date: Fri, 20 Mar 2026 00:40:40 +0100 Subject: [PATCH 09/19] FE-43: Extract FilterShell, PolarToCartesian, and generateMagnitudeTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the four filter variants into thin compositions over shared building blocks: - FilterShell: SVG wrapper with blur → children → feDisplacementMap - PolarToCartesian: polar (R=ratio, G=angle) → cartesian (R=dx, G=dy) - generateMagnitudeTable: optical transfer function lookup table Removes ~285 lines of duplication across filter components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../refractive/src/components/filter-obb.tsx | 59 ++---- .../src/components/filter-polar-hires.tsx | 153 +++------------- .../src/components/filter-polar.tsx | 173 ++++-------------- .../src/components/filter-shell.tsx | 49 +++++ .../refractive/src/components/filter.tsx | 51 +----- .../src/components/polar-to-cartesian.tsx | 83 +++++++++ .../src/helpers/generate-table-values.ts | 41 +++++ 7 files changed, 251 insertions(+), 358 deletions(-) create mode 100644 libs/@hashintel/refractive/src/components/filter-shell.tsx create mode 100644 libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx create mode 100644 libs/@hashintel/refractive/src/helpers/generate-table-values.ts diff --git a/libs/@hashintel/refractive/src/components/filter-obb.tsx b/libs/@hashintel/refractive/src/components/filter-obb.tsx index 2d516b277ec..648965ac37d 100644 --- a/libs/@hashintel/refractive/src/components/filter-obb.tsx +++ b/libs/@hashintel/refractive/src/components/filter-obb.tsx @@ -3,6 +3,7 @@ import { calculateDisplacementMapRadius, } from "../maps/displacement-map"; import { CompositeImage } from "./composite-image"; +import { FilterShell } from "./filter-shell"; type FilterOBBProps = { id: string; @@ -22,23 +23,8 @@ type FilterOBBProps = { /** * @private - * Alternative filter that uses `objectBoundingBox` to automatically size itself. - * - * Instead of requiring explicit width/height and a ResizeObserver, this filter: - * - Sets the filter region to exactly match the element's bounding box (`x="0" y="0" width="1" height="1"`) - * - Uses a single feImage per map (displacement) referencing SVG data URLs - * - The SVG data URLs contain all 9 image parts composited via nested SVGs with percentage positioning - * - * Usage: - * ```tsx - * const filterId = "my-refractive-filter"; - * - * - *
- * ``` - * - * @param props - The properties for the FilterOBB component. - * @returns An SVG element containing the filter definition. + * Rasterized displacement map + SVG composite image (CompositeImage). + * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. */ export const FilterOBB: React.FC = ({ id, @@ -55,13 +41,7 @@ export const FilterOBB: React.FC = ({ 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( @@ -83,16 +63,13 @@ export const FilterOBB: React.FC = ({ pixelRatio, }); - const scale = maximumDisplacement * scaleRatio; - - const content = ( - - - + return ( + = ({ hideLeft={hideLeft} hideRight={hideRight} /> - - - - ); - - return ( - - {content} - + ); }; diff --git a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx index fed3302f18a..74023913ea1 100644 --- a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx +++ b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx @@ -1,7 +1,10 @@ import { calculateDisplacementMapRadius } from "../maps/displacement-map"; import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; +import { generateMagnitudeTable } from "../helpers/generate-table-values"; import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; import { buildCompositeSvgUrl } from "./composite-image"; +import { FilterShell } from "./filter-shell"; +import { PolarToCartesian } from "./polar-to-cartesian"; /** * Reference radius used to generate the hi-res polar field. @@ -49,41 +52,16 @@ type FilterPolarHiResProps = { hideRight?: boolean; }; -/** - * Generate a space-separated string of `size` values from a mapping function, - * suitable for SVG `feComponentTransfer` `tableValues`. - */ -function generateTableValues( - size: number, - fn: (index: number) => number, -): string { - return Array.from({ length: size }, (_, i) => fn(i).toFixed(6)).join(" "); -} - -// 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; -}); - /** * @private - * Filter that reuses a single pre-computed hi-res (513×513) geometric polar field - * for any radius. The hi-res map is computed with bezelWidth = radius (full corner). - * When bezelWidth < radius, the magnitude table remaps the distance ratio by - * `radius / bezelWidth` (capped at 1), compressing the bezel into a narrower band. + * Pre-computed hi-res polar map + SVG filter math. * - * The hi-res polar map and its 9-patch parts are computed once at module load. - * On each render, only the SVG composite URL (positioning corners at the actual - * radius) and the magnitude lookup table (encoding optical parameters) are - * recomputed — both are cheap string operations. + * Reuses a single 513×513 geometric polar field for any radius. + * On each render, only the SVG composite URL (positioning corners at the + * actual radius) and the magnitude lookup table (encoding optical parameters) + * are recomputed — both are cheap string operations. * - * Uses `objectBoundingBox` filter units (no ResizeObserver needed). + * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. */ export const FilterPolarHiRes: React.FC = ({ id, @@ -99,8 +77,6 @@ export const FilterPolarHiRes: React.FC = ({ hideLeft, hideRight, }) => { - // Build composite SVG positioned at the actual radius (corners are hi-res, - // browser downscales them to fit). const svgUrl = buildCompositeSvgUrl( hiResParts, radius, @@ -120,101 +96,26 @@ export const FilterPolarHiRes: React.FC = ({ ); const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); - - // The hi-res map encodes ratio over the full radius (bezelWidth = radius). - // When bezelWidth < radius, remap: the bezel occupies only the outer - // (bezelWidth / radius) fraction of the corner, so we scale the ratio - // by (radius / bezelWidth) and cap at 1. Anything beyond the bezel - // gets the deepest displacement value. const ratioScale = clampedBezelWidth > 0 ? radius / clampedBezelWidth : 1; - - // Magnitude table: border distance ratio → signed normalized displacement. - const magnitudeTable = 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), - ); - }); - - const scale = 2 * maximumDisplacement * scaleRatio; + const magnitudeTable = generateMagnitudeTable( + displacementRadius, + maximumDisplacement, + ratioScale, + ); return ( - - - - {/* 1. Blur source graphic */} - - - {/* 2. Hi-res polar map, corners positioned at actual radius */} - - - {/* 3. Copy angle (G) into R and G for trig lookup */} - - - {/* 4. Apply cos table to R, sin table to G */} - - - - - - {/* 5. Copy distance ratio (R) into R and G for magnitude lookup */} - - - {/* 6. Apply optical transfer function (Snell's law) to both channels */} - - - - - - {/* 7. Signed multiplication: magnitude × trig → displacement map */} - - - {/* 8. Apply displacement */} - - - - + + + + ); }; diff --git a/libs/@hashintel/refractive/src/components/filter-polar.tsx b/libs/@hashintel/refractive/src/components/filter-polar.tsx index 925cafc863a..a1f04bd8b55 100644 --- a/libs/@hashintel/refractive/src/components/filter-polar.tsx +++ b/libs/@hashintel/refractive/src/components/filter-polar.tsx @@ -1,6 +1,9 @@ import { calculateDisplacementMapRadius } from "../maps/displacement-map"; import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; +import { generateMagnitudeTable } from "../helpers/generate-table-values"; import { CompositeImage } from "./composite-image"; +import { FilterShell } from "./filter-shell"; +import { PolarToCartesian } from "./polar-to-cartesian"; type FilterPolarProps = { id: string; @@ -18,36 +21,15 @@ type FilterPolarProps = { hideRight?: boolean; }; -/** - * Generate a space-separated string of `size` values from a mapping function, - * suitable for SVG `feComponentTransfer` `tableValues`. - */ -function generateTableValues( - size: number, - fn: (index: number) => number, -): string { - return Array.from({ length: size }, (_, i) => fn(i).toFixed(6)).join(" "); -} - /** * @private - * Filter that uses polar coordinate indirection to decouple shape geometry - * from the optical transfer function. + * Polar distance map + SVG filter math + SVG composite image (CompositeImage). * - * Instead of computing a full displacement bitmap per parameter change, this filter: - * 1. Rasterizes a geometry-only polar field (border distance ratio + angle toward center). - * This depends only on shape (radius, bezelWidth), not optical parameters. - * 2. Applies the optical transfer function (Snell's law refraction) via an SVG - * `feComponentTransfer` lookup table — a cheap update when parameters change. - * 3. Converts polar (magnitude, angle) → cartesian (dx, dy) via SVG filter math: - * `feColorMatrix` to separate channels, `feComponentTransfer` for cos/sin tables, - * and `feComposite` arithmetic for signed multiplication. + * Rasterizes a geometry-only polar field (reusable across optical parameter + * changes), then applies the optical transfer function and polar→cartesian + * conversion entirely in the SVG filter graph. * - * The polar field bitmap is reusable across changes to optical parameters - * (glassThickness, refractiveIndex, bezelHeightFn). Only the lookup table - * values need to change. - * - * Uses `objectBoundingBox` filter units (no ResizeObserver needed). + * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. */ export const FilterPolar: React.FC = ({ id, @@ -67,7 +49,6 @@ export const FilterPolar: React.FC = ({ const cornerWidth = Math.max(radius, bezelWidth); const imageSide = cornerWidth * 2 + 1; - // --- Geometry-only polar map (reusable across optical parameter changes) --- const polarMap = calculateGeometricPolarMap({ width: imageSide, height: imageSide, @@ -76,7 +57,6 @@ export const FilterPolar: React.FC = ({ pixelRatio, }); - // --- Optical transfer function (encoded as SVG lookup tables) --- const displacementRadius = calculateDisplacementMapRadius( glassThickness, bezelWidth, @@ -85,118 +65,33 @@ export const FilterPolar: React.FC = ({ ); const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); - - // Magnitude table: border distance ratio → signed normalized displacement. - // Maps [0, 1] (ratio) through the Snell's law displacement curve, - // then normalizes to [0, 1] centered at 0.5 (where 0.5 = no displacement). - // Index 0 is forced to 0.5 so fill-color pixels produce no displacement. - const magnitudeTable = generateTableValues(256, (i) => { - if (i === 0 || maximumDisplacement === 0) { - return 0.5; - } - const ratio = i / 255; - 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), - ); - }); - - // Trig tables: angle index [0..255] → cos/sin mapped to [0, 1] centered at 0.5. - 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; - }); - - // Scale factor accounts for the signed-multiplication encoding: - // the effective max displacement from feComposite arithmetic is ±0.5, - // so we double the scale to compensate. - const scale = 2 * maximumDisplacement * scaleRatio; - - const hideProps = { hideTop, hideBottom, hideLeft, hideRight }; + const magnitudeTable = generateMagnitudeTable( + displacementRadius, + maximumDisplacement, + ); return ( - - - - {/* 1. Blur source graphic */} - - - {/* 2. Composite the geometry-only polar map (R=ratio, G=angle) */} - - - {/* 3. Copy angle (G) into R and G for trig lookup */} - - - {/* 4. Apply cos table to R, sin table to G */} - - - - - - {/* 5. Copy distance ratio (R) into R and G for magnitude lookup */} - - - {/* 6. Apply optical transfer function (Snell's law) to both channels */} - - - - - - {/* 7. Signed multiplication: magnitude × trig → displacement map. - For two signed values centered at 0.5 (a_signed = 2a−1, b_signed = 2b−1): - result = (a_signed × b_signed + 1) / 2 = 2ab − a − b + 1 - So: k1=2, k2=−1, k3=−1, k4=1 */} - - - {/* 8. Apply displacement */} - - - - + + + + ); }; 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..997d68ae785 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -0,0 +1,49 @@ +type FilterShellProps = { + id: string; + blur: number; + scale: number; + /** Use objectBoundingBox filter units (auto-sizing, no ResizeObserver). */ + obb?: boolean; + /** Filter primitives that produce a result named "displacement_map". */ + children: React.ReactNode; +}; + +/** + * @private + * Shared SVG filter wrapper. Renders blur → children → feDisplacementMap. + * + * Children must render SVG filter primitives that produce a result + * named `"displacement_map"` (the R/G encoded displacement field). + */ +export const FilterShell: React.FC = ({ + id, + blur, + scale, + obb, + children, +}) => ( + + + + + + {children} + + + + + +); diff --git a/libs/@hashintel/refractive/src/components/filter.tsx b/libs/@hashintel/refractive/src/components/filter.tsx index 8462cc400e9..88b12286aba 100644 --- a/libs/@hashintel/refractive/src/components/filter.tsx +++ b/libs/@hashintel/refractive/src/components/filter.tsx @@ -3,6 +3,7 @@ import { calculateDisplacementMapRadius, } from "../maps/displacement-map"; import { CompositeParts } from "./composite-parts"; +import { FilterShell } from "./filter-shell"; type FilterProps = { id: string; @@ -24,20 +25,8 @@ type FilterProps = { /** * @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. + * Rasterized displacement map + JavaScript compositing (CompositeParts). + * Requires explicit width/height (needs ResizeObserver in the HOC). */ export const Filter: React.FC = ({ id, @@ -56,13 +45,7 @@ export const Filter: React.FC = ({ 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( @@ -84,16 +67,8 @@ export const Filter: React.FC = ({ pixelRatio, }); - const scale = maximumDisplacement * scaleRatio; - - const content = ( - - - + return ( + = ({ hideLeft={hideLeft} hideRight={hideRight} /> - - - - ); - - return ( - - {content} - + ); }; diff --git a/libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx b/libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx new file mode 100644 index 00000000000..9f0ea6172c6 --- /dev/null +++ b/libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx @@ -0,0 +1,83 @@ +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 PolarToCartesianProps = { + /** Magnitude lookup table (from generateMagnitudeTable). */ + magnitudeTable: string; + /** Input result name containing the polar map (R=ratio, G=angle). */ + in: string; + /** Output result name for the cartesian displacement map. */ + result: string; +}; + +/** + * @private + * SVG filter primitives that convert a polar distance map (R = border distance + * ratio, G = displacement angle) into a cartesian displacement map (R = dx, + * G = dy, centered at 0.5). + * + * Pipeline: + * 1. Extract angle (G) → apply cos/sin lookup tables via feComponentTransfer + * 2. Extract distance ratio (R) → apply magnitude lookup table + * 3. Signed multiplication via feComposite arithmetic: magnitude × trig + * + * 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 PolarToCartesian: React.FC = ({ + magnitudeTable, + in: inResult, + 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 */} + + +); 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..f1fbc4f5abc --- /dev/null +++ b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts @@ -0,0 +1,41 @@ +/** + * 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/bezelWidth + * when bezelWidth < 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), + ); + }); +} From ef741274bd0fd6903565b9b1bd4160b089392ec2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 17:18:23 +0200 Subject: [PATCH 10/19] FE-43: Consolidate filter pipeline into FilterShell with compositing prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove rasterized displacement map, specular map, and all intermediate filter variants (Filter, FilterOBB, FilterPolar, FilterPolarHiRes). The HOC now uses FilterShell directly with a pre-computed hi-res geometric polar field and SVG filter math (PolarToCartesian + magnitude lookup table). - Extract calculateDisplacementMapRadius to maps/displacement-radius.ts - Move composite components to components/composite/{image,parts}.tsx - FilterShell is now the full pipeline: blur → compositing → PolarToCartesian → displacement - Add compositing prop ("image" default, "parts") to choose compositing strategy - Simplify calculateGeometricPolarMap to take only radius - Remove specular support (to be re-implemented as SVG-only filter later) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../image.tsx} | 57 +------- .../parts.tsx} | 21 +-- .../refractive/src/components/filter-obb.tsx | 85 ------------ .../src/components/filter-polar-hires.tsx | 121 ---------------- .../src/components/filter-polar.tsx | 97 ------------- .../src/components/filter-shell.tsx | 131 +++++++++++++----- .../refractive/src/components/filter.tsx | 86 ------------ .../refractive/src/hoc/refractive.tsx | 84 +++++++++-- .../refractive/src/maps/displacement-map.ts | 108 --------------- .../src/maps/displacement-radius.ts | 57 ++++++++ .../src/maps/geometric-polar-map.ts | 37 ++--- .../refractive/src/maps/polar-distance-map.ts | 58 -------- .../refractive/src/maps/specular.ts | 67 --------- .../stories/filters/filter-obb.stories.tsx | 60 -------- .../filters/filter-polar-hires.stories.tsx | 90 ------------ .../stories/filters/filter-polar.stories.tsx | 60 -------- .../stories/filters/filter.stories.tsx | 58 -------- .../internals/displacement-map.stories.tsx | 123 ---------------- .../internals/polar-distance-map.stories.tsx | 89 +++--------- .../internals/specular-map.stories.tsx | 84 ----------- .../internals/surface-equations.stories.tsx | 2 +- 21 files changed, 264 insertions(+), 1311 deletions(-) rename libs/@hashintel/refractive/src/components/{composite-image.tsx => composite/image.tsx} (64%) rename libs/@hashintel/refractive/src/components/{composite-parts.tsx => composite/parts.tsx} (86%) delete mode 100644 libs/@hashintel/refractive/src/components/filter-obb.tsx delete mode 100644 libs/@hashintel/refractive/src/components/filter-polar-hires.tsx delete mode 100644 libs/@hashintel/refractive/src/components/filter-polar.tsx delete mode 100644 libs/@hashintel/refractive/src/components/filter.tsx delete mode 100644 libs/@hashintel/refractive/src/maps/displacement-map.ts create mode 100644 libs/@hashintel/refractive/src/maps/displacement-radius.ts delete mode 100644 libs/@hashintel/refractive/src/maps/polar-distance-map.ts delete mode 100644 libs/@hashintel/refractive/src/maps/specular.ts delete mode 100644 libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx delete mode 100644 libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx delete mode 100644 libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx delete mode 100644 libs/@hashintel/refractive/stories/filters/filter.stories.tsx delete mode 100644 libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx delete mode 100644 libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx diff --git a/libs/@hashintel/refractive/src/components/composite-image.tsx b/libs/@hashintel/refractive/src/components/composite/image.tsx similarity index 64% rename from libs/@hashintel/refractive/src/components/composite-image.tsx rename to libs/@hashintel/refractive/src/components/composite/image.tsx index a760876f394..9df58d41faa 100644 --- a/libs/@hashintel/refractive/src/components/composite-image.tsx +++ b/libs/@hashintel/refractive/src/components/composite/image.tsx @@ -1,18 +1,4 @@ -import type { ImageData } from "canvas"; - -import type { Parts } from "../helpers/split-imagedata-to-parts"; -import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; - -type CompositeImageProps = { - imageData: ImageData; - cornerWidth: number; - pixelRatio: number; - result: string; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; +import type { Parts } from "../../helpers/split-imagedata-to-parts"; /** * Builds an SVG string containing all 9 image parts composited together, @@ -95,44 +81,3 @@ export function buildCompositeSvgUrl( const svg = `${elements.join("")}`; return `data:image/svg+xml;base64,${btoa(svg)}`; } - -/** - * @private - * Component that builds a composite SVG from 9 image parts and returns a single feImage. - * - * Unlike CompositeParts which uses 9 feImage + 8 feComposite filter primitives and requires - * explicit width/height, this component generates a single SVG data URL that adapts to - * whatever size the feImage renders it at. Corners have fixed pixel sizes and are positioned - * via percentage-based nested SVGs with overflow="visible". - * - * Used internally by the FilterOBB component, for DisplacementMap and SpecularMap. - * - * @return {JSX.Element} A single feImage element referencing the composite SVG data URL. - */ -export const CompositeImage: React.FC = ({ - imageData, - cornerWidth, - pixelRatio, - result, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - const parts = splitImageDataToParts({ - imageData, - cornerWidth, - pixelRatio, - }); - - const svgUrl = buildCompositeSvgUrl( - parts, - cornerWidth, - hideTop, - hideBottom, - hideLeft, - hideRight, - ); - - return ; -}; diff --git a/libs/@hashintel/refractive/src/components/composite-parts.tsx b/libs/@hashintel/refractive/src/components/composite/parts.tsx similarity index 86% rename from libs/@hashintel/refractive/src/components/composite-parts.tsx rename to libs/@hashintel/refractive/src/components/composite/parts.tsx index d8c19ddb07a..2aff5db0d7d 100644 --- a/libs/@hashintel/refractive/src/components/composite-parts.tsx +++ b/libs/@hashintel/refractive/src/components/composite/parts.tsx @@ -1,9 +1,8 @@ -import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; +import type { Parts } from "../../helpers/split-imagedata-to-parts"; type CompositePartsProps = { - imageData: ImageData; + parts: Parts; cornerWidth: number; - pixelRatio: number; width: number; height: number; result: string; @@ -15,30 +14,22 @@ type CompositePartsProps = { /** * @private - * Component that renders the 8 parts of an image and composites them together. + * Renders pre-split 9-patch parts as feImage primitives and composites them together. * - * Used internally by the Filter component, for DisplacementMap and SpecularMap. - * - * @return {JSX.Element} Fragment containing all image parts for the refractive effect, along with compositing. + * Unlike the "image" compositing strategy (which builds a single SVG data URL), + * this uses explicit pixel positions and requires width/height from a ResizeObserver. */ export const CompositeParts: React.FC = ({ - imageData, + parts, cornerWidth, width, height, - pixelRatio, result, hideTop, hideBottom, hideLeft, hideRight, }) => { - const parts = splitImageDataToParts({ - imageData, - cornerWidth, - pixelRatio, - }); - const widthMinusCorner = width - cornerWidth; const heightMinusCorner = height - cornerWidth; diff --git a/libs/@hashintel/refractive/src/components/filter-obb.tsx b/libs/@hashintel/refractive/src/components/filter-obb.tsx deleted file mode 100644 index 648965ac37d..00000000000 --- a/libs/@hashintel/refractive/src/components/filter-obb.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { - calculateDisplacementMap, - calculateDisplacementMapRadius, -} from "../maps/displacement-map"; -import { CompositeImage } from "./composite-image"; -import { FilterShell } from "./filter-shell"; - -type FilterOBBProps = { - id: string; - scaleRatio: number; - blur: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: (x: number) => number; - pixelRatio: number; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; - -/** - * @private - * Rasterized displacement map + SVG composite image (CompositeImage). - * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. - */ -export const FilterOBB: React.FC = ({ - id, - radius, - blur, - glassThickness, - bezelWidth, - refractiveIndex, - scaleRatio, - bezelHeightFn, - pixelRatio, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - const cornerWidth = Math.max(radius, bezelWidth); - 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, - }); - - return ( - - - - ); -}; diff --git a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx b/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx deleted file mode 100644 index 74023913ea1..00000000000 --- a/libs/@hashintel/refractive/src/components/filter-polar-hires.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { calculateDisplacementMapRadius } from "../maps/displacement-map"; -import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; -import { generateMagnitudeTable } from "../helpers/generate-table-values"; -import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts"; -import { buildCompositeSvgUrl } from "./composite-image"; -import { FilterShell } from "./filter-shell"; -import { PolarToCartesian } from "./polar-to-cartesian"; - -/** - * 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 bezelWidth = radius and the map encodes normalized values - * (border distance ratio + angle), the same image works for any radius. - */ -const hiResPolarMap = calculateGeometricPolarMap({ - width: REFERENCE_RADIUS * 2 + 1, - height: REFERENCE_RADIUS * 2 + 1, - radius: REFERENCE_RADIUS, - bezelWidth: REFERENCE_RADIUS, - pixelRatio: 1, -}); - -/** - * 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 FilterPolarHiResProps = { - id: string; - scaleRatio: number; - blur: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: (x: number) => number; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; - -/** - * @private - * Pre-computed hi-res polar map + SVG filter math. - * - * Reuses a single 513×513 geometric polar field for any radius. - * On each render, only the SVG composite URL (positioning corners at the - * actual radius) and the magnitude lookup table (encoding optical parameters) - * are recomputed — both are cheap string operations. - * - * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. - */ -export const FilterPolarHiRes: React.FC = ({ - id, - radius, - blur, - scaleRatio, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - const svgUrl = buildCompositeSvgUrl( - hiResParts, - radius, - hideTop, - hideBottom, - hideLeft, - hideRight, - ); - - const clampedBezelWidth = Math.min(bezelWidth, radius); - - const displacementRadius = calculateDisplacementMapRadius( - glassThickness, - clampedBezelWidth, - bezelHeightFn, - refractiveIndex, - ); - - const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); - const ratioScale = clampedBezelWidth > 0 ? radius / clampedBezelWidth : 1; - const magnitudeTable = generateMagnitudeTable( - displacementRadius, - maximumDisplacement, - ratioScale, - ); - - return ( - - - - - ); -}; diff --git a/libs/@hashintel/refractive/src/components/filter-polar.tsx b/libs/@hashintel/refractive/src/components/filter-polar.tsx deleted file mode 100644 index a1f04bd8b55..00000000000 --- a/libs/@hashintel/refractive/src/components/filter-polar.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { calculateDisplacementMapRadius } from "../maps/displacement-map"; -import { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; -import { generateMagnitudeTable } from "../helpers/generate-table-values"; -import { CompositeImage } from "./composite-image"; -import { FilterShell } from "./filter-shell"; -import { PolarToCartesian } from "./polar-to-cartesian"; - -type FilterPolarProps = { - id: string; - scaleRatio: number; - blur: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: (x: number) => number; - pixelRatio: number; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; - -/** - * @private - * Polar distance map + SVG filter math + SVG composite image (CompositeImage). - * - * Rasterizes a geometry-only polar field (reusable across optical parameter - * changes), then applies the optical transfer function and polar→cartesian - * conversion entirely in the SVG filter graph. - * - * Uses objectBoundingBox — auto-sizes with the element, no ResizeObserver. - */ -export const FilterPolar: React.FC = ({ - id, - radius, - blur, - scaleRatio, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - pixelRatio, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - const cornerWidth = Math.max(radius, bezelWidth); - const imageSide = cornerWidth * 2 + 1; - - const polarMap = calculateGeometricPolarMap({ - width: imageSide, - height: imageSide, - radius, - bezelWidth, - pixelRatio, - }); - - const displacementRadius = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, - bezelHeightFn, - refractiveIndex, - ); - - const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); - const magnitudeTable = generateMagnitudeTable( - displacementRadius, - maximumDisplacement, - ); - - return ( - - - - - ); -}; diff --git a/libs/@hashintel/refractive/src/components/filter-shell.tsx b/libs/@hashintel/refractive/src/components/filter-shell.tsx index 997d68ae785..fa5af4ed86a 100644 --- a/libs/@hashintel/refractive/src/components/filter-shell.tsx +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -1,49 +1,114 @@ +import type { Parts } from "../helpers/split-imagedata-to-parts"; +import { buildCompositeSvgUrl } from "./composite/image"; +import { CompositeParts } from "./composite/parts"; +import { PolarToCartesian } from "./polar-to-cartesian"; + +export type CompositeMode = "image" | "parts"; + type FilterShellProps = { id: string; blur: number; scale: number; - /** Use objectBoundingBox filter units (auto-sizing, no ResizeObserver). */ - obb?: boolean; - /** Filter primitives that produce a result named "displacement_map". */ - children: React.ReactNode; + magnitudeTable: 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. + * Requires explicit width/height (needs ResizeObserver in the HOC). + */ + compositing?: CompositeMode; + /** Required when compositing is "parts". */ + width?: number; + /** Required when compositing is "parts". */ + height?: number; + hideTop?: boolean; + hideBottom?: boolean; + hideLeft?: boolean; + hideRight?: boolean; }; /** * @private - * Shared SVG filter wrapper. Renders blur → children → feDisplacementMap. + * Full SVG filter pipeline: blur → polar map compositing → polar-to-cartesian → displacement. * - * Children must render SVG filter primitives that produce a result - * named `"displacement_map"` (the R/G encoded displacement field). + * The `compositing` prop controls how the 9-patch polar map is assembled + * inside the SVG filter graph. */ export const FilterShell: React.FC = ({ id, blur, scale, - obb, - children, -}) => ( - - - - + magnitudeTable, + parts, + cornerWidth, + compositing = "image", + width, + height, + hideTop, + hideBottom, + hideLeft, + hideRight, +}) => { + const isImage = compositing === "image"; + + return ( + + + + - {children} + {isImage ? ( + + ) : ( + + )} - - - - -); + + + + + + + ); +}; diff --git a/libs/@hashintel/refractive/src/components/filter.tsx b/libs/@hashintel/refractive/src/components/filter.tsx deleted file mode 100644 index 88b12286aba..00000000000 --- a/libs/@hashintel/refractive/src/components/filter.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - calculateDisplacementMap, - calculateDisplacementMapRadius, -} from "../maps/displacement-map"; -import { CompositeParts } from "./composite-parts"; -import { FilterShell } from "./filter-shell"; - -type FilterProps = { - id: string; - scaleRatio: number; - blur: number; - width: number; - height: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: (x: number) => number; - pixelRatio: number; - hideTop?: boolean; - hideBottom?: boolean; - hideLeft?: boolean; - hideRight?: boolean; -}; - -/** - * @private - * Rasterized displacement map + JavaScript compositing (CompositeParts). - * Requires explicit width/height (needs ResizeObserver in the HOC). - */ -export const Filter: React.FC = ({ - id, - width, - height, - radius, - blur, - glassThickness, - bezelWidth, - refractiveIndex, - scaleRatio, - bezelHeightFn, - pixelRatio, - hideTop, - hideBottom, - hideLeft, - hideRight, -}) => { - const cornerWidth = Math.max(radius, bezelWidth); - 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, - }); - - return ( - - - - ); -}; diff --git a/libs/@hashintel/refractive/src/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index 066f8e44576..5620ed3c8ac 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -2,8 +2,38 @@ import type { ComponentType } from "react"; import { createElement, useEffect, useId, useRef, useState } 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 } 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 { calculateGeometricPolarMap } from "../maps/geometric-polar-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 = calculateGeometricPolarMap(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: { @@ -13,6 +43,12 @@ type RefractionProps = { bezelWidth?: number; refractiveIndex?: number; bezelHeightFn?: (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; }; }; @@ -40,13 +76,14 @@ function createRefractiveComponent< 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; + const compositing = refraction.compositing ?? "image"; - // TODO: (FE-43) Remove ResizeObserver and rely on `objectBoundingBox` to automatically size the filter. - // This will removed the need of `useState` here. useEffect(() => { + if (compositing === "image") { + return; + } + const element = elementRef.current; if (!element) { return; @@ -71,22 +108,39 @@ function createRefractiveComponent< return () => { resizeObserver.disconnect(); }; - }, [elementRef]); + }, [elementRef, compositing]); + + const bezelWidth = refraction.bezelWidth ?? 0; + const radius = refraction.radius; + const clampedBezelWidth = Math.min(bezelWidth, radius); + + const displacementRadius = calculateDisplacementMapRadius( + refraction.glassThickness ?? 70, + clampedBezelWidth, + refraction.bezelHeightFn ?? convex, + refraction.refractiveIndex ?? 1.5, + ); + + const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); + const ratioScale = clampedBezelWidth > 0 ? radius / clampedBezelWidth : 1; + const magnitudeTable = generateMagnitudeTable( + displacementRadius, + maximumDisplacement, + ratioScale, + ); return ( <> - {/* @ts-expect-error Need to fix types in this file */} 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..f3a9ab154d1 --- /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 glass parameters, returns an array of displacement values + * (one per sample across the bezel width) encoding how far a vertical + * ray is deflected horizontally after passing through the glass surface. + */ +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]); + } + }); +} diff --git a/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts index 294b0ed70dc..6f18c38c850 100644 --- a/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts +++ b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts @@ -5,8 +5,12 @@ 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. * - * This map depends only on shape parameters (radius, bezelWidth), NOT on optical - * parameters (glassThickness, refractiveIndex, bezelHeightFn). The optical transfer + * The output image is (radius * 2 + 1) pixels per side, with the bezel filling + * the entire corner area. The actual bezel-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 + * (glassThickness, refractiveIndex, bezelHeightFn). The optical transfer * function is applied later via SVG feComponentTransfer lookup tables. * * Channel encoding: @@ -15,26 +19,14 @@ import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; * - B: 0 * - A: 255 */ -export function calculateGeometricPolarMap(props: { - width: number; - height: number; - radius: number; - bezelWidth: number; - pixelRatio: number; -}) { - const { pixelRatio } = 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); +export function calculateGeometricPolarMap(radius: number) { + const side = radius * 2 + 1; return calculateRoundedSquareMap({ - width, - height, + width: side, + height: side, radius, - maximumDistanceToBorder: bezel, + maximumDistanceToBorder: radius, // R=0 (at border), G=0, B=0, A=255 fillColor: 0xff000000, processPixel( @@ -43,18 +35,15 @@ export function calculateGeometricPolarMap(props: { buffer, offset, _distanceFromCenter, - distanceFromBorder, + _distanceFromBorder, distanceFromBorderRatio, angle, opacity, ) { - const ratio = - bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel; - // 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(ratio * 255 * opacity); + buffer[offset] = Math.round(distanceFromBorderRatio * 255 * opacity); // G: angle toward center (displacement direction) const displacementAngle = (angle + Math.PI) % (2 * Math.PI); diff --git a/libs/@hashintel/refractive/src/maps/polar-distance-map.ts b/libs/@hashintel/refractive/src/maps/polar-distance-map.ts deleted file mode 100644 index dd6f138ac60..00000000000 --- a/libs/@hashintel/refractive/src/maps/polar-distance-map.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; - -export function calculatePolarDistanceMap(props: { - width: number; - height: number; - radius: number; - bezelWidth: number; - precomputedDisplacementMap: number[]; - pixelRatio: number; -}) { - const { pixelRatio, 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, - // R=0 (no distance), G=0, B=0, A=255 - fillColor: 0xff000000, - processPixel( - _x, - _y, - buffer, - offset, - _distanceFromCenter, - distanceFromBorder, - distanceFromBorderRatio, - angle, - opacity, - ) { - const ratio = - bezel > radius ? distanceFromBorderRatio : distanceFromBorder / bezel; - - const bezelIndex = Math.round(ratio * precomputedDisplacementMap.length); - const distance = Math.abs(precomputedDisplacementMap[bezelIndex] ?? 0); - - // Red: distance in pixels, clamped to [0, 255] - buffer[offset] = Math.min(255, Math.round(distance * opacity)); - - // Green: displacement angle mapped from [0, 2π] to [0, 255] - // Displacement direction is opposite to position angle (toward center) - 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/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/filters/filter-obb.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx deleted file mode 100644 index 4f259515aff..00000000000 --- a/libs/@hashintel/refractive/stories/filters/filter-obb.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { FilterOBB } from "../../src/components/filter-obb"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { - defaultFilterArgs, - FilterShowcase, - filterArgTypes, - type SharedFilterProps, -} from "../helpers"; - -const FilterOBBStory = ({ - background, - radius, - ...props -}: SharedFilterProps) => ( - - {(id) => ( - - )} - -); - -const meta = { - title: "Filters/Filter OBB (ObjectBoundingBox)", - component: FilterOBBStory, - argTypes: filterArgTypes, - args: defaultFilterArgs, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Convex: Story = { - args: { bezelHeightFn: convex }, -}; - -export const ConvexCircle: Story = { - args: { bezelHeightFn: convexCircle }, -}; - -export const Concave: Story = { - args: { bezelHeightFn: concave }, -}; - -export const Lip: Story = { - args: { bezelHeightFn: lip }, -}; diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx deleted file mode 100644 index 80c319a0eb7..00000000000 --- a/libs/@hashintel/refractive/stories/filters/filter-polar-hires.stories.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { FilterPolarHiRes } from "../../src/components/filter-polar-hires"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { FilterShowcase } from "../helpers"; -import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; -import type { BackgroundType } from "../helpers"; - -type FilterPolarHiResStoryProps = { - blur: number; - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: SurfaceFnDef; - background: BackgroundType; -}; - -const FilterPolarHiResStory = ({ - background, - radius, - ...props -}: FilterPolarHiResStoryProps) => ( - - {(id) => ( - - )} - -); - -const meta = { - title: "Filters/Filter Polar Hi-Res (Single Image)", - component: FilterPolarHiResStory, - argTypes: { - 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 }, - }, - glassThickness: { - control: { type: "range" as const, min: 0, max: 300, step: 1 }, - }, - bezelWidth: { - control: { type: "range" as const, min: 0, max: 100, step: 1 }, - }, - refractiveIndex: { - control: { type: "range" as const, min: 1, max: 3, step: 0.01 }, - }, - bezelHeightFn: { table: { disable: true } }, - background: { - control: { type: "inline-radio" as const }, - options: ["article", "checkerboard"], - }, - }, - args: { - blur: 2, - radius: 20, - glassThickness: 70, - bezelWidth: 30, - refractiveIndex: 1.5, - bezelHeightFn: convex, - background: "article", - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Convex: Story = { - args: { bezelHeightFn: convex }, -}; - -export const ConvexCircle: Story = { - args: { bezelHeightFn: convexCircle }, -}; - -export const Concave: Story = { - args: { bezelHeightFn: concave }, -}; - -export const Lip: Story = { - args: { bezelHeightFn: lip }, -}; diff --git a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx deleted file mode 100644 index 623763d830b..00000000000 --- a/libs/@hashintel/refractive/stories/filters/filter-polar.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { FilterPolar } from "../../src/components/filter-polar"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { - defaultFilterArgs, - FilterShowcase, - filterArgTypes, - type SharedFilterProps, -} from "../helpers"; - -const FilterPolarStory = ({ - background, - radius, - ...props -}: SharedFilterProps) => ( - - {(id) => ( - - )} - -); - -const meta = { - title: "Filters/Filter Polar (Indirection)", - component: FilterPolarStory, - argTypes: filterArgTypes, - args: defaultFilterArgs, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Convex: Story = { - args: { bezelHeightFn: convex }, -}; - -export const ConvexCircle: Story = { - args: { bezelHeightFn: convexCircle }, -}; - -export const Concave: Story = { - args: { bezelHeightFn: concave }, -}; - -export const Lip: Story = { - args: { bezelHeightFn: lip }, -}; diff --git a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx b/libs/@hashintel/refractive/stories/filters/filter.stories.tsx deleted file mode 100644 index 33fbd964812..00000000000 --- a/libs/@hashintel/refractive/stories/filters/filter.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { Filter } from "../../src/components/filter"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { - defaultFilterArgs, - FilterShowcase, - filterArgTypes, - type SharedFilterProps, -} from "../helpers"; - -const FilterStory = ({ background, radius, ...props }: SharedFilterProps) => ( - - {(id) => ( - - )} - -); - -const meta = { - title: "Filters/Filter (Explicit Size)", - component: FilterStory, - argTypes: filterArgTypes, - args: defaultFilterArgs, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Convex: Story = { - args: { bezelHeightFn: convex }, -}; - -export const ConvexCircle: Story = { - args: { bezelHeightFn: convexCircle }, -}; - -export const Concave: Story = { - args: { bezelHeightFn: concave }, -}; - -export const Lip: Story = { - args: { bezelHeightFn: lip }, -}; diff --git a/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx deleted file mode 100644 index ca41641b24b..00000000000 --- a/libs/@hashintel/refractive/stories/internals/displacement-map.stories.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useEffect, useRef } from "react"; - -import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { - calculateDisplacementMap, - calculateDisplacementMapRadius, -} from "../../src/maps/displacement-map"; - -type Props = { - radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: SurfaceFnDef; - pixelRatio: number; -}; - -const DisplacementMapVis = ({ - radius, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - pixelRatio, -}: Props) => { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - const cornerWidth = Math.max(radius, bezelWidth); - const imageSide = cornerWidth * 2 + 1; - - const map = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, - bezelHeightFn, - refractiveIndex, - ); - const maximumDisplacement = Math.max(...map.map(Math.abs)); - - const imageData = calculateDisplacementMap({ - width: imageSide, - height: imageSide, - radius, - bezelWidth, - precomputedDisplacementMap: map, - maximumDisplacement, - pixelRatio, - }); - - canvas.width = imageData.width; - canvas.height = imageData.height; - const ctx = canvas.getContext("2d")!; - ctx.putImageData(imageData, 0, 0); - }, [ - radius, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - pixelRatio, - ]); - - return ( -
-

- Red = X displacement, Green = Y displacement (128 = neutral) -

- -
- ); -}; - -const meta = { - title: "Internals/Displacement Map", - component: DisplacementMapVis, - argTypes: { - radius: { control: { type: "range", min: 0, max: 100, step: 1 } }, - glassThickness: { control: { type: "range", min: 0, max: 300, step: 1 } }, - bezelWidth: { control: { type: "range", min: 0, max: 100, step: 1 } }, - refractiveIndex: { - control: { type: "range", min: 1, max: 3, step: 0.01 }, - }, - pixelRatio: { control: { type: "range", min: 1, max: 12, step: 1 } }, - bezelHeightFn: { table: { disable: true } }, - }, - args: { - radius: 20, - glassThickness: 70, - bezelWidth: 30, - refractiveIndex: 1.5, - pixelRatio: 6, - bezelHeightFn: convex, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Convex: Story = { args: { bezelHeightFn: convex } }; -export const ConvexCircle: Story = { args: { bezelHeightFn: convexCircle } }; -export const Concave: Story = { args: { bezelHeightFn: concave } }; -export const Lip: Story = { args: { bezelHeightFn: lip } }; diff --git a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx index 5c610fb09f4..ee9d2c8ba99 100644 --- a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -1,33 +1,13 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useEffect, useRef } from "react"; -import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; -import { calculatePolarDistanceMap } from "../../src/maps/polar-distance-map"; +import { calculateGeometricPolarMap } from "../../src/maps/geometric-polar-map"; type Props = { radius: number; - glassThickness: number; - bezelWidth: number; - refractiveIndex: number; - bezelHeightFn: SurfaceFnDef; - pixelRatio: number; }; -const PolarDistanceMapVis = ({ - radius, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - pixelRatio, -}: Props) => { +const GeometricPolarMapVis = ({ radius }: Props) => { const canvasRef = useRef(null); useEffect(() => { @@ -36,42 +16,18 @@ const PolarDistanceMapVis = ({ return; } - const cornerWidth = Math.max(radius, bezelWidth); - const imageSide = cornerWidth * 2 + 1; - - const map = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, - bezelHeightFn, - refractiveIndex, - ); - - const imageData = calculatePolarDistanceMap({ - width: imageSide, - height: imageSide, - radius, - bezelWidth, - precomputedDisplacementMap: map, - pixelRatio, - }); + const imageData = calculateGeometricPolarMap(radius); canvas.width = imageData.width; canvas.height = imageData.height; const ctx = canvas.getContext("2d")!; ctx.putImageData(imageData, 0, 0); - }, [ - radius, - glassThickness, - bezelWidth, - refractiveIndex, - bezelHeightFn, - pixelRatio, - ]); + }, [radius]); return (

- Red = distance (px), Green = angle (0-2pi mapped to 0-255) + Red = border distance ratio [0,1], Green = angle [0,2π] → [0,255]

; +} satisfies Meta; export default meta; type Story = StoryObj; -export const Convex: Story = { args: { bezelHeightFn: convex } }; -export const ConvexCircle: Story = { args: { bezelHeightFn: convexCircle } }; -export const Concave: Story = { args: { bezelHeightFn: concave } }; -export const Lip: Story = { args: { bezelHeightFn: lip } }; +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/internals/specular-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx deleted file mode 100644 index 87c29100d66..00000000000 --- a/libs/@hashintel/refractive/stories/internals/specular-map.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useEffect, useRef } from "react"; - -import { calculateSpecularImage } from "../../src/maps/specular"; - -type Props = { - radius: number; - specularAngle: number; - pixelRatio: number; -}; - -const SpecularMapVis = ({ radius, specularAngle, pixelRatio }: Props) => { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) { - return; - } - - const imageSide = radius * 2 + 1; - - const imageData = calculateSpecularImage({ - width: imageSide, - height: imageSide, - radius, - specularAngle, - pixelRatio, - }); - - canvas.width = imageData.width; - canvas.height = imageData.height; - const ctx = canvas.getContext("2d")!; - ctx.putImageData(imageData, 0, 0); - }, [radius, specularAngle, pixelRatio]); - - return ( -
-

- RGB = brightness, Alpha = specular intensity -

- -
- ); -}; - -const meta = { - title: "Internals/Specular Map", - component: SpecularMapVis, - argTypes: { - radius: { control: { type: "range", min: 5, max: 100, step: 1 } }, - specularAngle: { - control: { type: "range", min: 0, max: 6.28, step: 0.01 }, - }, - pixelRatio: { control: { type: "range", min: 1, max: 12, step: 1 } }, - }, - args: { - radius: 40, - specularAngle: Math.PI / 4, - pixelRatio: 6, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; - -export const TopLight: Story = { - args: { specularAngle: Math.PI / 2 }, -}; - -export const SideLight: Story = { - args: { specularAngle: 0 }, -}; diff --git a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx index 196a4ccaab5..cea3da3566f 100644 --- a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx @@ -8,7 +8,7 @@ import { convexCircle, lip, } from "../../src/helpers/surface-equations"; -import { calculateDisplacementMapRadius } from "../../src/maps/displacement-map"; +import { calculateDisplacementMapRadius } from "../../src/maps/displacement-radius"; const PLOT_WIDTH = 400; const PLOT_HEIGHT = 300; From f026cde18224ffbc83caeef4d9960d2225bc0d02 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 17:29:15 +0200 Subject: [PATCH 11/19] FE-43: Rename bezel/glass terminology to edge/thickness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bezelWidth → edgeSize (direction-neutral) - bezelHeightFn → edgeProfile (describes the cross-section curve) - glassThickness → thickness (material-agnostic) - SurfaceFnDef → EdgeProfile (consistent with edgeProfile prop) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/helpers/generate-table-values.ts | 4 +-- .../src/helpers/surface-equations.ts | 10 +++---- .../refractive/src/hoc/refractive.tsx | 18 ++++++------ .../src/maps/displacement-radius.ts | 26 ++++++++--------- .../src/maps/geometric-polar-map.ts | 6 ++-- .../@hashintel/refractive/stories/helpers.tsx | 20 ++++++------- .../internals/surface-equations.stories.tsx | 28 +++++++++---------- .../refractive/stories/playground.stories.tsx | 6 ++-- 8 files changed, 59 insertions(+), 59 deletions(-) diff --git a/libs/@hashintel/refractive/src/helpers/generate-table-values.ts b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts index f1fbc4f5abc..d886f465aa6 100644 --- a/libs/@hashintel/refractive/src/helpers/generate-table-values.ts +++ b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts @@ -15,8 +15,8 @@ export function generateTableValues( * * @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/bezelWidth - * when bezelWidth < radius, compressing the bezel into a narrower band). + * @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[], 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 5620ed3c8ac..16b923ceb28 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -39,10 +39,10 @@ type RefractionProps = { refraction: { radius: number; blur?: number; - glassThickness?: number; - bezelWidth?: number; + thickness?: number; + edgeSize?: number; refractiveIndex?: number; - bezelHeightFn?: (x: number) => number; + edgeProfile?: (x: number) => number; /** * Compositing strategy for the polar map: * - `"image"` (default): Single composite SVG, auto-sizes via objectBoundingBox. @@ -110,19 +110,19 @@ function createRefractiveComponent< }; }, [elementRef, compositing]); - const bezelWidth = refraction.bezelWidth ?? 0; + const edgeSize = refraction.edgeSize ?? 0; const radius = refraction.radius; - const clampedBezelWidth = Math.min(bezelWidth, radius); + const clampedEdgeSize = Math.min(edgeSize, radius); const displacementRadius = calculateDisplacementMapRadius( - refraction.glassThickness ?? 70, - clampedBezelWidth, - refraction.bezelHeightFn ?? convex, + refraction.thickness ?? 70, + clampedEdgeSize, + refraction.edgeProfile ?? convex, refraction.refractiveIndex ?? 1.5, ); const maximumDisplacement = Math.max(...displacementRadius.map(Math.abs)); - const ratioScale = clampedBezelWidth > 0 ? radius / clampedBezelWidth : 1; + const ratioScale = clampedEdgeSize > 0 ? radius / clampedEdgeSize : 1; const magnitudeTable = generateMagnitudeTable( displacementRadius, maximumDisplacement, diff --git a/libs/@hashintel/refractive/src/maps/displacement-radius.ts b/libs/@hashintel/refractive/src/maps/displacement-radius.ts index f3a9ab154d1..5e4c7a4d904 100644 --- a/libs/@hashintel/refractive/src/maps/displacement-radius.ts +++ b/libs/@hashintel/refractive/src/maps/displacement-radius.ts @@ -1,20 +1,20 @@ /** * Pre-computes per-sample ray deviation using Snell's law. * - * Given glass parameters, returns an array of displacement values - * (one per sample across the bezel width) encoding how far a vertical - * ray is deflected horizontally after passing through the glass surface. + * 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( - glassThickness: number = 200, - bezelWidth: number = 50, - bezelHeightFn: (x: number) => number = (x) => x, + 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 bezel) - // and height of the glass + // 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] @@ -34,11 +34,11 @@ export function calculateDisplacementMapRadius( return Array.from({ length: samples }, (_, i) => { const x = i / samples; - const y = bezelHeightFn(x); + const y = edgeProfile(x); // Calculate derivative in x const dx = x < 1 ? 0.0001 : -0.0001; - const y2 = bezelHeightFn(x + dx); + 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; @@ -47,10 +47,10 @@ export function calculateDisplacementMapRadius( if (!refracted) { return 0; } else { - const remainingHeightOnBezel = y * bezelWidth; - const remainingHeight = remainingHeightOnBezel + glassThickness; + const remainingHeightOnEdge = y * edgeSize; + const remainingHeight = remainingHeightOnEdge + thickness; - // Return displacement (rest of travel on x-axis, depends on remaining height to hit bottom of glass) + // 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/geometric-polar-map.ts b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts index 6f18c38c850..a7445a59646 100644 --- a/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts +++ b/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts @@ -5,12 +5,12 @@ 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 bezel filling - * the entire corner area. The actual bezel-to-radius ratio is handled later + * 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 - * (glassThickness, refractiveIndex, bezelHeightFn). The optical transfer + * (thickness, refractiveIndex, edgeProfile). The optical transfer * function is applied later via SVG feComponentTransfer lookup tables. * * Channel encoding: diff --git a/libs/@hashintel/refractive/stories/helpers.tsx b/libs/@hashintel/refractive/stories/helpers.tsx index 82ee6df426f..38cec800958 100644 --- a/libs/@hashintel/refractive/stories/helpers.tsx +++ b/libs/@hashintel/refractive/stories/helpers.tsx @@ -1,6 +1,6 @@ import { useId } from "react"; -import type { SurfaceFnDef } from "../src/helpers/surface-equations"; +import type { EdgeProfile } from "../src/helpers/surface-equations"; import { convex } from "../src/helpers/surface-equations"; import { ExampleArticle } from "./example-article"; @@ -9,36 +9,36 @@ export type BackgroundType = "article" | "checkerboard"; export type SharedFilterProps = { blur: number; radius: number; - glassThickness: number; - bezelWidth: number; + thickness: number; + edgeSize: number; refractiveIndex: number; - bezelHeightFn: SurfaceFnDef; + edgeProfile: EdgeProfile; background: BackgroundType; }; export const defaultFilterArgs: SharedFilterProps = { blur: 2, radius: 20, - glassThickness: 70, - bezelWidth: 30, + thickness: 70, + edgeSize: 30, refractiveIndex: 1.5, - bezelHeightFn: convex, + 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 } }, - glassThickness: { + thickness: { control: { type: "range" as const, min: 0, max: 300, step: 1 }, }, - bezelWidth: { + 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 }, }, - bezelHeightFn: { table: { disable: true } }, + edgeProfile: { table: { disable: true } }, background: { control: { type: "inline-radio" as const }, options: ["article", "checkerboard"], diff --git a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx index cea3da3566f..1237ebc1860 100644 --- a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useEffect, useRef } from "react"; -import type { SurfaceFnDef } from "../../src/helpers/surface-equations"; +import type { EdgeProfile } from "../../src/helpers/surface-equations"; import { concave, convex, @@ -14,7 +14,7 @@ const PLOT_WIDTH = 400; const PLOT_HEIGHT = 300; const PADDING = 40; -const equations: { name: string; fn: SurfaceFnDef; color: string }[] = [ +const equations: { name: string; fn: EdgeProfile; color: string }[] = [ { name: "convex", fn: convex, color: "#4fc3f7" }, { name: "convexCircle", fn: convexCircle, color: "#81c784" }, { name: "concave", fn: concave, color: "#ff8a65" }, @@ -22,8 +22,8 @@ const equations: { name: string; fn: SurfaceFnDef; color: string }[] = [ ]; type Props = { - glassThickness: number; - bezelWidth: number; + thickness: number; + edgeSize: number; refractiveIndex: number; samples: number; }; @@ -44,7 +44,7 @@ class PlotRenderer { draw( title: string, - getPoints: (fn: SurfaceFnDef) => [number, number][], + getPoints: (fn: EdgeProfile) => [number, number][], centered = false, ) { const w = PLOT_WIDTH + PADDING * 2; @@ -127,8 +127,8 @@ class PlotRenderer { } const SurfaceEquationsVis = ({ - glassThickness, - bezelWidth, + thickness, + edgeSize, refractiveIndex, samples, }: Props) => { @@ -155,8 +155,8 @@ const SurfaceEquationsVis = ({ "Displacement Radius (px)", (fn) => { const map = calculateDisplacementMapRadius( - glassThickness, - bezelWidth, + thickness, + edgeSize, fn, refractiveIndex, samples, @@ -168,7 +168,7 @@ const SurfaceEquationsVis = ({ }, true, ); - }, [glassThickness, bezelWidth, refractiveIndex, samples]); + }, [thickness, edgeSize, refractiveIndex, samples]); return (
( From eb961375deebad10cbec1c089b2272ebe3036c68 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 17:36:38 +0200 Subject: [PATCH 12/19] FE-43: Move ResizeObserver into CompositeParts and add compositing control CompositeParts now owns the ResizeObserver, receiving an elementRef to track dimensions directly. This removes sizing state from the HOC and FilterShell, keeping the concern co-located with the component that needs it. Also adds a compositing radio control to the Playground story. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/composite/parts.tsx | 43 ++++++++++++++++--- .../src/components/filter-shell.tsx | 14 +++--- .../refractive/src/hoc/refractive.tsx | 41 ++---------------- .../refractive/stories/playground.stories.tsx | 31 +++++++++---- 4 files changed, 67 insertions(+), 62 deletions(-) diff --git a/libs/@hashintel/refractive/src/components/composite/parts.tsx b/libs/@hashintel/refractive/src/components/composite/parts.tsx index 2aff5db0d7d..f9cb7707cfc 100644 --- a/libs/@hashintel/refractive/src/components/composite/parts.tsx +++ b/libs/@hashintel/refractive/src/components/composite/parts.tsx @@ -1,10 +1,12 @@ +import { useEffect, useState } from "react"; + import type { Parts } from "../../helpers/split-imagedata-to-parts"; type CompositePartsProps = { parts: Parts; cornerWidth: number; - width: number; - height: number; + /** Ref to the element whose dimensions drive the filter layout. */ + elementRef: React.RefObject; result: string; hideTop?: boolean; hideBottom?: boolean; @@ -16,20 +18,49 @@ type CompositePartsProps = { * @private * Renders pre-split 9-patch parts as feImage primitives and composites them together. * - * Unlike the "image" compositing strategy (which builds a single SVG data URL), - * this uses explicit pixel positions and requires width/height from a ResizeObserver. + * Observes the referenced element's size via ResizeObserver to position + * parts at the correct pixel coordinates. */ export const CompositeParts: React.FC = ({ parts, cornerWidth, - width, - height, + elementRef, result, hideTop, hideBottom, hideLeft, hideRight, }) => { + 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/filter-shell.tsx b/libs/@hashintel/refractive/src/components/filter-shell.tsx index fa5af4ed86a..1e914109ffb 100644 --- a/libs/@hashintel/refractive/src/components/filter-shell.tsx +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -17,13 +17,11 @@ type FilterShellProps = { * - `"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. - * Requires explicit width/height (needs ResizeObserver in the HOC). + * Observes the element via `elementRef` to get pixel dimensions. */ compositing?: CompositeMode; - /** Required when compositing is "parts". */ - width?: number; - /** Required when compositing is "parts". */ - height?: number; + /** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */ + elementRef?: React.RefObject; hideTop?: boolean; hideBottom?: boolean; hideLeft?: boolean; @@ -45,8 +43,7 @@ export const FilterShell: React.FC = ({ parts, cornerWidth, compositing = "image", - width, - height, + elementRef, hideTop, hideBottom, hideLeft, @@ -83,8 +80,7 @@ export const FilterShell: React.FC = ({ ) : ( }; const filterId = useId(); const internalRef = useRef(null); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); const elementRef = externalRef ?? internalRef; - const compositing = refraction.compositing ?? "image"; - - useEffect(() => { - if (compositing === "image") { - return; - } - - 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, compositing]); const edgeSize = refraction.edgeSize ?? 0; const radius = refraction.radius; @@ -138,9 +104,8 @@ function createRefractiveComponent< magnitudeTable={magnitudeTable} parts={hiResParts} cornerWidth={radius} - compositing={compositing} - width={width} - height={height} + compositing={refraction.compositing} + elementRef={elementRef} /> {/* @ts-expect-error Need to fix types in this file */} diff --git a/libs/@hashintel/refractive/stories/playground.stories.tsx b/libs/@hashintel/refractive/stories/playground.stories.tsx index 94575466745..3009ea1e282 100644 --- a/libs/@hashintel/refractive/stories/playground.stories.tsx +++ b/libs/@hashintel/refractive/stories/playground.stories.tsx @@ -1,19 +1,15 @@ 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, - edgeSize: 30, - thickness: 70, - refractiveIndex: 1.5, - edgeProfile: convex, +type Props = { + compositing: CompositeMode; }; -const GlassOverArticle = () => ( +const GlassOverArticle = ({ compositing }: Props) => (
( justifyContent: "center", zIndex: 1, }} - refraction={refraction} + refraction={{ + blur: 2, + radius: 20, + edgeSize: 30, + thickness: 70, + refractiveIndex: 1.5, + edgeProfile: convex, + compositing, + }} > Refractive Glass @@ -42,6 +46,15 @@ const GlassOverArticle = () => ( const meta = { title: "Playground", component: GlassOverArticle, + argTypes: { + compositing: { + control: { type: "inline-radio" as const }, + options: ["image", "parts"], + }, + }, + args: { + compositing: "image", + }, } satisfies Meta; export default meta; From e05aa39e0a45cebdfe99cd138296dfd1836b84bc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 19:08:54 +0200 Subject: [PATCH 13/19] FE-43: Remove surface equations story Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internals/surface-equations.stories.tsx | 232 ------------------ 1 file changed, 232 deletions(-) delete mode 100644 libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx diff --git a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx b/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx deleted file mode 100644 index 1237ebc1860..00000000000 --- a/libs/@hashintel/refractive/stories/internals/surface-equations.stories.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useEffect, useRef } from "react"; - -import type { EdgeProfile } from "../../src/helpers/surface-equations"; -import { - concave, - convex, - convexCircle, - lip, -} from "../../src/helpers/surface-equations"; -import { calculateDisplacementMapRadius } from "../../src/maps/displacement-radius"; - -const PLOT_WIDTH = 400; -const PLOT_HEIGHT = 300; -const PADDING = 40; - -const equations: { name: string; fn: EdgeProfile; color: string }[] = [ - { name: "convex", fn: convex, color: "#4fc3f7" }, - { name: "convexCircle", fn: convexCircle, color: "#81c784" }, - { name: "concave", fn: concave, color: "#ff8a65" }, - { name: "lip", fn: lip, color: "#ce93d8" }, -]; - -type Props = { - thickness: number; - edgeSize: number; - refractiveIndex: number; - samples: number; -}; - -class PlotRenderer { - private ctx: CanvasRenderingContext2D; - - constructor(canvas: HTMLCanvasElement) { - const w = (PLOT_WIDTH + PADDING * 2) * 2; - const h = (PLOT_HEIGHT + PADDING * 2) * 2; - canvas.width = w; // eslint-disable-line no-param-reassign - canvas.height = h; // eslint-disable-line no-param-reassign - canvas.style.width = `${w / 2}px`; // eslint-disable-line no-param-reassign - canvas.style.height = `${h / 2}px`; // eslint-disable-line no-param-reassign - this.ctx = canvas.getContext("2d")!; - this.ctx.scale(2, 2); - } - - draw( - title: string, - getPoints: (fn: EdgeProfile) => [number, number][], - centered = false, - ) { - const w = PLOT_WIDTH + PADDING * 2; - const h = PLOT_HEIGHT + PADDING * 2; - this.ctx.clearRect(0, 0, w, h); - - this.ctx.fillStyle = "#16213e"; - this.ctx.fillRect(PADDING, PADDING, PLOT_WIDTH, PLOT_HEIGHT); - - // Grid - this.ctx.strokeStyle = "#2a2a4a"; - this.ctx.lineWidth = 0.5; - for (let i = 0; i <= 10; i++) { - const x = PADDING + (PLOT_WIDTH * i) / 10; - const y = PADDING + (PLOT_HEIGHT * i) / 10; - this.ctx.beginPath(); - this.ctx.moveTo(x, PADDING); - this.ctx.lineTo(x, PADDING + PLOT_HEIGHT); - this.ctx.stroke(); - this.ctx.beginPath(); - this.ctx.moveTo(PADDING, y); - this.ctx.lineTo(PADDING + PLOT_WIDTH, y); - this.ctx.stroke(); - } - - if (centered) { - this.ctx.strokeStyle = "#555"; - this.ctx.lineWidth = 1; - this.ctx.beginPath(); - const zeroY = PADDING + PLOT_HEIGHT / 2; - this.ctx.moveTo(PADDING, zeroY); - this.ctx.lineTo(PADDING + PLOT_WIDTH, zeroY); - this.ctx.stroke(); - } - - for (const eq of equations) { - const points = getPoints(eq.fn); - if (points.length === 0) { - continue; - } - - this.ctx.strokeStyle = eq.color; - this.ctx.lineWidth = 2; - this.ctx.beginPath(); - - for (let i = 0; i < points.length; i++) { - const [px, py] = points[i]!; - const x = PADDING + px * PLOT_WIDTH; - const y = centered - ? PADDING + PLOT_HEIGHT / 2 - (py * PLOT_HEIGHT) / 2 - : PADDING + PLOT_HEIGHT - py * PLOT_HEIGHT; - - if (i === 0) { - this.ctx.moveTo(x, y); - } else { - this.ctx.lineTo(x, y); - } - } - this.ctx.stroke(); - } - - // Title - this.ctx.fillStyle = "#aaa"; - this.ctx.font = "13px monospace"; - this.ctx.textAlign = "center"; - this.ctx.fillText(title, w / 2, PADDING - 10); - - // Axis labels - this.ctx.fillStyle = "#666"; - this.ctx.font = "11px monospace"; - this.ctx.textAlign = "left"; - this.ctx.fillText("0", PADDING - 2, PADDING + PLOT_HEIGHT + 14); - this.ctx.textAlign = "right"; - this.ctx.fillText( - "1", - PADDING + PLOT_WIDTH + 2, - PADDING + PLOT_HEIGHT + 14, - ); - } -} - -const SurfaceEquationsVis = ({ - thickness, - edgeSize, - refractiveIndex, - samples, -}: Props) => { - const surfaceCanvasRef = useRef(null); - const displacementCanvasRef = useRef(null); - - useEffect(() => { - const surfaceCanvas = surfaceCanvasRef.current; - const displacementCanvas = displacementCanvasRef.current; - if (!surfaceCanvas || !displacementCanvas) { - return; - } - - new PlotRenderer(surfaceCanvas).draw("Surface Shape: f(x)", (fn) => { - const points: [number, number][] = []; - for (let i = 0; i <= samples; i++) { - const x = i / samples; - points.push([x, fn(x)]); - } - return points; - }); - - new PlotRenderer(displacementCanvas).draw( - "Displacement Radius (px)", - (fn) => { - const map = calculateDisplacementMapRadius( - thickness, - edgeSize, - fn, - refractiveIndex, - samples, - ); - const maxVal = Math.max(...map.map(Math.abs), 1); - return map.map( - (v, i) => [i / map.length, v / maxVal] as [number, number], - ); - }, - true, - ); - }, [thickness, edgeSize, refractiveIndex, samples]); - - return ( -
-
- {equations.map((eq) => ( - - {eq.name} - - ))} -
- - -
- ); -}; - -const meta = { - title: "Internals/Surface Equations", - component: SurfaceEquationsVis, - argTypes: { - 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 }, - }, - samples: { - control: { type: "range" as const, min: 16, max: 512, step: 16 }, - }, - }, - args: { - thickness: 70, - edgeSize: 30, - refractiveIndex: 1.5, - samples: 128, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const AllCurves: Story = {}; From 0b55bef96f14900dc2cc7387c34d4a4ef024bc5f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 19:23:00 +0200 Subject: [PATCH 14/19] FE-43: Rename geometric-polar-map and distanceFromBorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - geometric-polar-map → polar-distance-to-border-map - calculateGeometricPolarMap → calculatePolarDistanceToBorderMap - distanceFromBorder → distanceToBorder (across all map files) Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/@hashintel/refractive/src/hoc/refractive.tsx | 4 ++-- .../refractive/src/maps/calculate-circle-map.ts | 10 +++++----- .../src/maps/calculate-rounded-square-map.ts | 12 ++++++------ ...-polar-map.ts => polar-distance-to-border-map.ts} | 8 ++++---- .../refractive/src/maps/process-pixel.type.ts | 4 ++-- .../stories/internals/polar-distance-map.stories.tsx | 4 ++-- 6 files changed, 21 insertions(+), 21 deletions(-) rename libs/@hashintel/refractive/src/maps/{geometric-polar-map.ts => polar-distance-to-border-map.ts} (89%) diff --git a/libs/@hashintel/refractive/src/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index 8389a796ab4..06770a72ec0 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -8,7 +8,7 @@ import { generateMagnitudeTable } 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 { calculateGeometricPolarMap } from "../maps/geometric-polar-map"; +import { calculatePolarDistanceToBorderMap } from "../maps/polar-distance-to-border-map"; /** * Reference radius used to generate the hi-res polar field. @@ -22,7 +22,7 @@ const REFERENCE_RADIUS = 256; * Since the map encodes normalized values (border distance ratio + angle), * the same image works for any actual radius. */ -const hiResPolarMap = calculateGeometricPolarMap(REFERENCE_RADIUS); +const hiResPolarMap = calculatePolarDistanceToBorderMap(REFERENCE_RADIUS); /** * Pre-split 9-patch parts from the hi-res polar map. 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/geometric-polar-map.ts b/libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts similarity index 89% rename from libs/@hashintel/refractive/src/maps/geometric-polar-map.ts rename to libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts index a7445a59646..8f8607aa4d0 100644 --- a/libs/@hashintel/refractive/src/maps/geometric-polar-map.ts +++ b/libs/@hashintel/refractive/src/maps/polar-distance-to-border-map.ts @@ -19,7 +19,7 @@ import { calculateRoundedSquareMap } from "./calculate-rounded-square-map"; * - B: 0 * - A: 255 */ -export function calculateGeometricPolarMap(radius: number) { +export function calculatePolarDistanceToBorderMap(radius: number) { const side = radius * 2 + 1; return calculateRoundedSquareMap({ @@ -35,15 +35,15 @@ export function calculateGeometricPolarMap(radius: number) { buffer, offset, _distanceFromCenter, - _distanceFromBorder, - distanceFromBorderRatio, + _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(distanceFromBorderRatio * 255 * opacity); + buffer[offset] = Math.round(distanceToBorderRatio * 255 * opacity); // G: angle toward center (displacement direction) const displacementAngle = (angle + Math.PI) % (2 * Math.PI); 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/stories/internals/polar-distance-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx index ee9d2c8ba99..4e493276c76 100644 --- a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useEffect, useRef } from "react"; -import { calculateGeometricPolarMap } from "../../src/maps/geometric-polar-map"; +import { calculatePolarDistanceToBorderMap } from "../../src/maps/polar-distance-to-border-map"; type Props = { radius: number; @@ -16,7 +16,7 @@ const GeometricPolarMapVis = ({ radius }: Props) => { return; } - const imageData = calculateGeometricPolarMap(radius); + const imageData = calculatePolarDistanceToBorderMap(radius); canvas.width = imageData.width; canvas.height = imageData.height; From 3a339295dc6af52606e606d5efe9208af2698833 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 19:39:19 +0200 Subject: [PATCH 15/19] Adjust prop names in @hashintel/petrinaut --- .../src/notifications/notifications-provider.tsx | 2 +- .../src/views/Editor/components/BottomBar/bottom-bar.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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, }} >
From 873a3fca06932410cd7ddbaa110a730f41ec8c61 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 30 Mar 2026 20:04:07 +0200 Subject: [PATCH 16/19] FE-43: Rename Geometric Polar Map story to Polar DistanceToBorder Map Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stories/internals/polar-distance-map.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx index 4e493276c76..8f8586452c9 100644 --- a/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx +++ b/libs/@hashintel/refractive/stories/internals/polar-distance-map.stories.tsx @@ -7,7 +7,7 @@ type Props = { radius: number; }; -const GeometricPolarMapVis = ({ radius }: Props) => { +const PolarDistanceToBorderMapVis = ({ radius }: Props) => { const canvasRef = useRef(null); useEffect(() => { @@ -43,15 +43,15 @@ const GeometricPolarMapVis = ({ radius }: Props) => { }; const meta = { - title: "Internals/Geometric Polar Map", - component: GeometricPolarMapVis, + title: "Internals/Polar DistanceToBorder Map", + component: PolarDistanceToBorderMapVis, argTypes: { radius: { control: { type: "range", min: 5, max: 100, step: 1 } }, }, args: { radius: 30, }, -} satisfies Meta; +} satisfies Meta; export default meta; From 2ec4dbe7deea432fdfcf5a04ea38c7b20e2b1609 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 31 Mar 2026 02:15:13 +0200 Subject: [PATCH 17/19] FE-43: Rename PolarToCartesian to Refraction and absorb feDisplacementMap The Refraction component is now a self-contained effect: polar-to-cartesian conversion + displacement map application. This prepares FilterShell for composing multiple effects (specular rim, gloss, diffuse) that each consume the shared polar map independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/filter-shell.tsx | 25 +++++++--------- ...{polar-to-cartesian.tsx => refraction.tsx} | 30 ++++++++++++++----- 2 files changed, 33 insertions(+), 22 deletions(-) rename libs/@hashintel/refractive/src/components/{polar-to-cartesian.tsx => refraction.tsx} (76%) diff --git a/libs/@hashintel/refractive/src/components/filter-shell.tsx b/libs/@hashintel/refractive/src/components/filter-shell.tsx index 1e914109ffb..bc69d5ba2b0 100644 --- a/libs/@hashintel/refractive/src/components/filter-shell.tsx +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -1,7 +1,7 @@ import type { Parts } from "../helpers/split-imagedata-to-parts"; import { buildCompositeSvgUrl } from "./composite/image"; import { CompositeParts } from "./composite/parts"; -import { PolarToCartesian } from "./polar-to-cartesian"; +import { Refraction } from "./refraction"; export type CompositeMode = "image" | "parts"; @@ -30,10 +30,11 @@ type FilterShellProps = { /** * @private - * Full SVG filter pipeline: blur → polar map compositing → polar-to-cartesian → displacement. + * Full SVG filter pipeline: blur → polar map compositing → refraction effect. * * The `compositing` prop controls how the 9-patch polar map is assembled - * inside the SVG filter graph. + * inside the SVG filter graph. The polar map is then consumed by the + * Refraction effect (and in the future, other effects like specular). */ export const FilterShell: React.FC = ({ id, @@ -74,34 +75,28 @@ export const FilterShell: React.FC = ({ hideLeft, hideRight, )} - result="polar_map" preserveAspectRatio="none" + result="polar_map" /> ) : ( )} - - - diff --git a/libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx b/libs/@hashintel/refractive/src/components/refraction.tsx similarity index 76% rename from libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx rename to libs/@hashintel/refractive/src/components/refraction.tsx index 9f0ea6172c6..d743223e439 100644 --- a/libs/@hashintel/refractive/src/components/polar-to-cartesian.tsx +++ b/libs/@hashintel/refractive/src/components/refraction.tsx @@ -11,32 +11,38 @@ const sinTable = generateTableValues(256, (i) => { return (Math.sin(angle) + 1) / 2; }); -type PolarToCartesianProps = { +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; - /** Output result name for the cartesian displacement map. */ + /** Input result name for the source to be displaced. */ + source: string; + /** Output result name for the displaced image. */ result: string; }; /** * @private - * SVG filter primitives that convert a polar distance map (R = border distance - * ratio, G = displacement angle) into a cartesian displacement map (R = dx, - * G = dy, centered at 0.5). + * 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 + * 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 PolarToCartesian: React.FC = ({ +export const Refraction: React.FC = ({ magnitudeTable, + scale, in: inResult, + source, result, }) => ( <> @@ -77,6 +83,16 @@ export const PolarToCartesian: React.FC = ({ k2={-1} k3={-1} k4={1} + result={`${result}_displacement`} + /> + + {/* Apply displacement to source */} + From 5cc60ddcdf311b2bac4375946e40ffd11597f336 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 31 Mar 2026 02:34:52 +0200 Subject: [PATCH 18/19] FE-43: Add SpecularRim effect and move effects to components/effects/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create composable effects folder with: - refraction.tsx (moved from components/) - specular-rim.tsx (new) — 2px directional edge highlight computed entirely in SVG filter primitives from the polar map The rim is always ~2px wide regardless of radius, using an exponential falloff table scaled by 2/radius. A directional cosine lobe controls the light angle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/{ => effects}/refraction.tsx | 2 +- .../src/components/effects/specular-rim.tsx | 130 ++++++++++++++++++ .../src/components/filter-shell.tsx | 22 ++- .../refractive/src/hoc/refractive.tsx | 3 + .../refractive/stories/playground.stories.tsx | 19 ++- 5 files changed, 169 insertions(+), 7 deletions(-) rename libs/@hashintel/refractive/src/components/{ => effects}/refraction.tsx (97%) create mode 100644 libs/@hashintel/refractive/src/components/effects/specular-rim.tsx diff --git a/libs/@hashintel/refractive/src/components/refraction.tsx b/libs/@hashintel/refractive/src/components/effects/refraction.tsx similarity index 97% rename from libs/@hashintel/refractive/src/components/refraction.tsx rename to libs/@hashintel/refractive/src/components/effects/refraction.tsx index d743223e439..d1e14edd2b0 100644 --- a/libs/@hashintel/refractive/src/components/refraction.tsx +++ b/libs/@hashintel/refractive/src/components/effects/refraction.tsx @@ -1,4 +1,4 @@ -import { generateTableValues } from "../helpers/generate-table-values"; +import { generateTableValues } from "../../helpers/generate-table-values"; // Trig tables are constant — computed once at module level. const cosTable = generateTableValues(256, (i) => { 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..709b582573a --- /dev/null +++ b/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx @@ -0,0 +1,130 @@ +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 centered on `lightAngle`. The lobe width is controlled by + * `spread`: 1 = normal cosine, higher = tighter highlight. + */ +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); + // Raise to power for tighter highlights, clamp negative + return Math.pow(Math.max(0, 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 index bc69d5ba2b0..3f6d1f220af 100644 --- a/libs/@hashintel/refractive/src/components/filter-shell.tsx +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -1,7 +1,8 @@ import type { Parts } from "../helpers/split-imagedata-to-parts"; import { buildCompositeSvgUrl } from "./composite/image"; import { CompositeParts } from "./composite/parts"; -import { Refraction } from "./refraction"; +import { Refraction } from "./effects/refraction"; +import { SpecularRim } from "./effects/specular-rim"; export type CompositeMode = "image" | "parts"; @@ -22,6 +23,8 @@ type FilterShellProps = { compositing?: CompositeMode; /** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */ elementRef?: React.RefObject; + /** Specular rim light angle in radians. Undefined disables the effect. */ + specularRimAngle?: number; hideTop?: boolean; hideBottom?: boolean; hideLeft?: boolean; @@ -30,11 +33,11 @@ type FilterShellProps = { /** * @private - * Full SVG filter pipeline: blur → polar map compositing → refraction effect. + * Full SVG filter pipeline: blur → polar map compositing → effects. * * The `compositing` prop controls how the 9-patch polar map is assembled - * inside the SVG filter graph. The polar map is then consumed by the - * Refraction effect (and in the future, other effects like specular). + * inside the SVG filter graph. The polar map is then consumed by composable + * effects (refraction, specular rim, etc.). */ export const FilterShell: React.FC = ({ id, @@ -45,6 +48,7 @@ export const FilterShell: React.FC = ({ cornerWidth, compositing = "image", elementRef, + specularRimAngle, hideTop, hideBottom, hideLeft, @@ -98,6 +102,16 @@ export const FilterShell: React.FC = ({ source="blurred_source" result="refracted" /> + + {specularRimAngle !== undefined && ( + + )} diff --git a/libs/@hashintel/refractive/src/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index 06770a72ec0..7cbed2daba3 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -49,6 +49,8 @@ type RefractionProps = { * - `"parts"`: 9-patch feImage primitives, requires explicit sizing. */ compositing?: CompositeMode; + /** Specular rim light angle in radians. Undefined disables the effect. */ + specularRimAngle?: number; }; }; @@ -106,6 +108,7 @@ function createRefractiveComponent< cornerWidth={radius} compositing={refraction.compositing} elementRef={elementRef} + specularRimAngle={refraction.specularRimAngle} /> {/* @ts-expect-error Need to fix types in this file */} diff --git a/libs/@hashintel/refractive/stories/playground.stories.tsx b/libs/@hashintel/refractive/stories/playground.stories.tsx index 3009ea1e282..07f60e223b5 100644 --- a/libs/@hashintel/refractive/stories/playground.stories.tsx +++ b/libs/@hashintel/refractive/stories/playground.stories.tsx @@ -6,10 +6,12 @@ import { refractive } from "../src/hoc/refractive"; import { ExampleArticle } from "./example-article"; type Props = { + radius: number; compositing: CompositeMode; + specularRimAngle: number | undefined; }; -const GlassOverArticle = ({ compositing }: Props) => ( +const GlassOverArticle = ({ radius, compositing, specularRimAngle }: Props) => (
( }} refraction={{ blur: 2, - radius: 20, + radius, edgeSize: 30, thickness: 70, refractiveIndex: 1.5, edgeProfile: convex, compositing, + specularRimAngle, }} > Refractive Glass @@ -47,13 +50,21 @@ 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"], }, + specularRimAngle: { + control: { type: "range", min: 0, max: 6.28, step: 0.01 }, + }, }, args: { + radius: 20, compositing: "image", + specularRimAngle: Math.PI / 4, }, } satisfies Meta; @@ -62,3 +73,7 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +export const NoSpecular: Story = { + args: { specularRimAngle: undefined }, +}; From e896e605d4e27053fbf368d2672d6a952074e8a0 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 31 Mar 2026 16:33:05 +0200 Subject: [PATCH 19/19] FE-43: Add DiffuseReflection effect, shared lightAngle, and Playground controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiffuseReflection: Lambertian shading using surface tilt (from edge profile derivative) × cos(borderAngle - lightAngle). Uses alpha-based white/black overlays instead of blend modes to avoid gray wash on dark backgrounds. - Rename specularRimAngle → lightAngle (shared by diffuse and specular) - SpecularRim: treat lightAngle as axis (|cos| for both sides), revert to 2px - Add generateSurfaceTiltTable helper - Playground: add radius, specular toggle, diffuseIntensity, shadowOpacity controls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/effects/diffuse-reflection.tsx | 125 ++++++++++++++++++ .../src/components/effects/specular-rim.tsx | 9 +- .../src/components/filter-shell.tsx | 55 ++++++-- .../src/helpers/generate-table-values.ts | 28 ++++ .../refractive/src/hoc/refractive.tsx | 21 ++- .../refractive/stories/playground.stories.tsx | 41 ++++-- 6 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 libs/@hashintel/refractive/src/components/effects/diffuse-reflection.tsx 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/specular-rim.tsx b/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx index 709b582573a..183efa5defc 100644 --- a/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx +++ b/libs/@hashintel/refractive/src/components/effects/specular-rim.tsx @@ -22,16 +22,17 @@ function generateRimTable(radius: number): string { * Generates a directional highlight lookup table. * * Maps the polar map angle channel [0,255] → [0,2π] to a cosine - * lobe centered on `lightAngle`. The lobe width is controlled by - * `spread`: 1 = normal cosine, higher = tighter highlight. + * 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); - // Raise to power for tighter highlights, clamp negative - return Math.pow(Math.max(0, dot), spread); + // Use |cos| so both sides of the axis get the highlight + return Math.pow(Math.abs(dot), spread); }); } diff --git a/libs/@hashintel/refractive/src/components/filter-shell.tsx b/libs/@hashintel/refractive/src/components/filter-shell.tsx index 3f6d1f220af..3c4a45c7981 100644 --- a/libs/@hashintel/refractive/src/components/filter-shell.tsx +++ b/libs/@hashintel/refractive/src/components/filter-shell.tsx @@ -1,6 +1,7 @@ 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"; @@ -11,6 +12,7 @@ type FilterShellProps = { blur: number; scale: number; magnitudeTable: string; + surfaceTiltTable: string; parts: Parts; cornerWidth: number; /** @@ -23,8 +25,12 @@ type FilterShellProps = { compositing?: CompositeMode; /** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */ elementRef?: React.RefObject; - /** Specular rim light angle in radians. Undefined disables the effect. */ - specularRimAngle?: number; + /** 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; @@ -35,26 +41,42 @@ type FilterShellProps = { * @private * Full SVG filter pipeline: blur → polar map compositing → effects. * - * The `compositing` prop controls how the 9-patch polar map is assembled - * inside the SVG filter graph. The polar map is then consumed by composable - * effects (refraction, specular rim, etc.). + * 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, - specularRimAngle, + 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 ( @@ -100,16 +122,27 @@ export const FilterShell: React.FC = ({ scale={scale} in="polar_map" source="blurred_source" - result="refracted" + result={refractionResult} /> - {specularRimAngle !== undefined && ( + {hasDiffuse && ( + + )} + + {hasSpecular && ( )} diff --git a/libs/@hashintel/refractive/src/helpers/generate-table-values.ts b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts index d886f465aa6..20a3746d1c2 100644 --- a/libs/@hashintel/refractive/src/helpers/generate-table-values.ts +++ b/libs/@hashintel/refractive/src/helpers/generate-table-values.ts @@ -39,3 +39,31 @@ export function generateMagnitudeTable( ); }); } + +/** + * 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/hoc/refractive.tsx b/libs/@hashintel/refractive/src/hoc/refractive.tsx index 7cbed2daba3..b12e29fc5ba 100644 --- a/libs/@hashintel/refractive/src/hoc/refractive.tsx +++ b/libs/@hashintel/refractive/src/hoc/refractive.tsx @@ -4,7 +4,10 @@ import type { JSX } from "react/jsx-runtime"; import type { CompositeMode } from "../components/filter-shell"; import { FilterShell } from "../components/filter-shell"; -import { generateMagnitudeTable } from "../helpers/generate-table-values"; +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"; @@ -49,8 +52,12 @@ type RefractionProps = { * - `"parts"`: 9-patch feImage primitives, requires explicit sizing. */ compositing?: CompositeMode; - /** Specular rim light angle in radians. Undefined disables the effect. */ - specularRimAngle?: number; + /** 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; }; }; @@ -97,6 +104,9 @@ function createRefractiveComponent< 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/stories/playground.stories.tsx b/libs/@hashintel/refractive/stories/playground.stories.tsx index 07f60e223b5..ea60773a281 100644 --- a/libs/@hashintel/refractive/stories/playground.stories.tsx +++ b/libs/@hashintel/refractive/stories/playground.stories.tsx @@ -8,10 +8,20 @@ import { ExampleArticle } from "./example-article"; type Props = { radius: number; compositing: CompositeMode; - specularRimAngle: number | undefined; + lightAngle: number | undefined; + diffuseIntensity: number; + specular: boolean; + shadowOpacity: number; }; -const GlassOverArticle = ({ radius, compositing, specularRimAngle }: Props) => ( +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, @@ -36,7 +47,9 @@ const GlassOverArticle = ({ radius, compositing, specularRimAngle }: Props) => ( refractiveIndex: 1.5, edgeProfile: convex, compositing, - specularRimAngle, + lightAngle, + diffuseIntensity, + specular, }} > Refractive Glass @@ -57,14 +70,26 @@ const meta = { control: { type: "inline-radio" as const }, options: ["image", "parts"], }, - specularRimAngle: { + 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", - specularRimAngle: Math.PI / 4, + lightAngle: Math.PI / 4, + diffuseIntensity: 0.3, + specular: true, + shadowOpacity: 0.15, }, } satisfies Meta; @@ -74,6 +99,6 @@ type Story = StoryObj; export const Default: Story = {}; -export const NoSpecular: Story = { - args: { specularRimAngle: undefined }, +export const NoLighting: Story = { + args: { lightAngle: undefined, diffuseIntensity: 0 }, };