Skip to content

Commit 5cc60dd

Browse files
kubeclaude
andcommitted
FE-43: Add SpecularRim effect and move effects to components/effects/
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) <noreply@anthropic.com>
1 parent 2ec4dbe commit 5cc60dd

5 files changed

Lines changed: 169 additions & 7 deletions

File tree

libs/@hashintel/refractive/src/components/refraction.tsx renamed to libs/@hashintel/refractive/src/components/effects/refraction.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { generateTableValues } from "../helpers/generate-table-values";
1+
import { generateTableValues } from "../../helpers/generate-table-values";
22

33
// Trig tables are constant — computed once at module level.
44
const cosTable = generateTableValues(256, (i) => {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { generateTableValues } from "../../helpers/generate-table-values";
2+
3+
/**
4+
* Generates a rim falloff lookup table.
5+
*
6+
* Maps distance-to-border ratio [0,1] to rim intensity [0,1].
7+
* The rim is always ~2px wide: `ratio = 2/radius` corresponds to 2px.
8+
* A subtle exponential tail extends a few pixels beyond.
9+
*/
10+
function generateRimTable(radius: number): string {
11+
// 2px expressed as a ratio of the total edge
12+
const onePixelRatio = radius > 0 ? 2 / radius : 1;
13+
14+
return generateTableValues(256, (i) => {
15+
const ratio = i / 255;
16+
// Sharp peak at the border (first pixel), exponential decay after
17+
return Math.exp((-ratio / onePixelRatio) * 2);
18+
});
19+
}
20+
21+
/**
22+
* Generates a directional highlight lookup table.
23+
*
24+
* Maps the polar map angle channel [0,255] → [0,2π] to a cosine
25+
* lobe centered on `lightAngle`. The lobe width is controlled by
26+
* `spread`: 1 = normal cosine, higher = tighter highlight.
27+
*/
28+
function generateDirectionalTable(lightAngle: number, spread: number): string {
29+
return generateTableValues(256, (i) => {
30+
// The polar map G channel encodes the displacement angle (toward center).
31+
const angle = (i / 255) * 2 * Math.PI;
32+
const dot = Math.cos(angle - lightAngle);
33+
// Raise to power for tighter highlights, clamp negative
34+
return Math.pow(Math.max(0, dot), spread);
35+
});
36+
}
37+
38+
type SpecularRimProps = {
39+
/** Input result name containing the polar map (R=distance ratio, G=angle). */
40+
in: string;
41+
/** Input source image to overlay the specular rim onto. */
42+
source: string;
43+
/** Corner radius in pixels — used to scale the rim to always be ~1px wide. */
44+
radius: number;
45+
/** Light angle in radians (0 = right, π/2 = down, π = left, 3π/2 = up). */
46+
lightAngle?: number;
47+
/** Controls the tightness of the directional highlight (1 = broad, higher = tighter). */
48+
spread?: number;
49+
/** Brightness multiplier for the specular highlight [0,1]. */
50+
intensity?: number;
51+
/** Output result name. */
52+
result: string;
53+
};
54+
55+
/**
56+
* @private
57+
* Specular rim effect: produces a directional edge highlight from the polar map
58+
* and composites it over a source image.
59+
*
60+
* Pipeline:
61+
* 1. Apply rim falloff table to R channel (distance-to-border → rim intensity)
62+
* 2. Copy G channel (angle) to R, apply directional cosine table
63+
* 3. Multiply rim × directional → combined specular intensity
64+
* 4. Convert to white highlight with alpha = intensity
65+
* 5. Composite over source
66+
*/
67+
export const SpecularRim: React.FC<SpecularRimProps> = ({
68+
in: inResult,
69+
source,
70+
radius,
71+
lightAngle = Math.PI / 4,
72+
spread = 2,
73+
intensity = 0.6,
74+
result,
75+
}) => {
76+
const rimTable = generateRimTable(radius);
77+
const directionalTable = generateDirectionalTable(lightAngle, spread);
78+
79+
return (
80+
<>
81+
{/* 1. Distance-based rim falloff: R channel → rim intensity */}
82+
<feComponentTransfer in={inResult} result={`${result}_rim`}>
83+
<feFuncR type="table" tableValues={rimTable} />
84+
<feFuncG type="discrete" tableValues="0" />
85+
<feFuncB type="discrete" tableValues="0" />
86+
</feComponentTransfer>
87+
88+
{/* 2. Copy angle (G) into R for directional lookup */}
89+
<feColorMatrix
90+
in={inResult}
91+
type="matrix"
92+
values="0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
93+
result={`${result}_angle`}
94+
/>
95+
96+
{/* 3. Apply directional cosine table to R channel */}
97+
<feComponentTransfer in={`${result}_angle`} result={`${result}_dir`}>
98+
<feFuncR type="table" tableValues={directionalTable} />
99+
</feComponentTransfer>
100+
101+
{/* 4. Multiply rim × directional (both in R channel) */}
102+
<feComposite
103+
in={`${result}_rim`}
104+
in2={`${result}_dir`}
105+
operator="arithmetic"
106+
k1={1}
107+
k2={0}
108+
k3={0}
109+
k4={0}
110+
result={`${result}_combined`}
111+
/>
112+
113+
{/* 5. Convert to white highlight: RGB=1, A=R×intensity */}
114+
<feColorMatrix
115+
in={`${result}_combined`}
116+
type="matrix"
117+
values={`0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 ${intensity} 0 0 0 0`}
118+
result={`${result}_highlight`}
119+
/>
120+
121+
{/* 6. Composite highlight over source */}
122+
<feComposite
123+
in={`${result}_highlight`}
124+
in2={source}
125+
operator="over"
126+
result={result}
127+
/>
128+
</>
129+
);
130+
};

libs/@hashintel/refractive/src/components/filter-shell.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Parts } from "../helpers/split-imagedata-to-parts";
22
import { buildCompositeSvgUrl } from "./composite/image";
33
import { CompositeParts } from "./composite/parts";
4-
import { Refraction } from "./refraction";
4+
import { Refraction } from "./effects/refraction";
5+
import { SpecularRim } from "./effects/specular-rim";
56

67
export type CompositeMode = "image" | "parts";
78

@@ -22,6 +23,8 @@ type FilterShellProps = {
2223
compositing?: CompositeMode;
2324
/** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */
2425
elementRef?: React.RefObject<HTMLElement | null>;
26+
/** Specular rim light angle in radians. Undefined disables the effect. */
27+
specularRimAngle?: number;
2528
hideTop?: boolean;
2629
hideBottom?: boolean;
2730
hideLeft?: boolean;
@@ -30,11 +33,11 @@ type FilterShellProps = {
3033

3134
/**
3235
* @private
33-
* Full SVG filter pipeline: blur → polar map compositing → refraction effect.
36+
* Full SVG filter pipeline: blur → polar map compositing → effects.
3437
*
3538
* The `compositing` prop controls how the 9-patch polar map is assembled
36-
* inside the SVG filter graph. The polar map is then consumed by the
37-
* Refraction effect (and in the future, other effects like specular).
39+
* inside the SVG filter graph. The polar map is then consumed by composable
40+
* effects (refraction, specular rim, etc.).
3841
*/
3942
export const FilterShell: React.FC<FilterShellProps> = ({
4043
id,
@@ -45,6 +48,7 @@ export const FilterShell: React.FC<FilterShellProps> = ({
4548
cornerWidth,
4649
compositing = "image",
4750
elementRef,
51+
specularRimAngle,
4852
hideTop,
4953
hideBottom,
5054
hideLeft,
@@ -98,6 +102,16 @@ export const FilterShell: React.FC<FilterShellProps> = ({
98102
source="blurred_source"
99103
result="refracted"
100104
/>
105+
106+
{specularRimAngle !== undefined && (
107+
<SpecularRim
108+
in="polar_map"
109+
source="refracted"
110+
radius={cornerWidth}
111+
lightAngle={specularRimAngle}
112+
result="with_specular"
113+
/>
114+
)}
101115
</filter>
102116
</defs>
103117
</svg>

libs/@hashintel/refractive/src/hoc/refractive.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ type RefractionProps = {
4949
* - `"parts"`: 9-patch feImage primitives, requires explicit sizing.
5050
*/
5151
compositing?: CompositeMode;
52+
/** Specular rim light angle in radians. Undefined disables the effect. */
53+
specularRimAngle?: number;
5254
};
5355
};
5456

@@ -106,6 +108,7 @@ function createRefractiveComponent<
106108
cornerWidth={radius}
107109
compositing={refraction.compositing}
108110
elementRef={elementRef}
111+
specularRimAngle={refraction.specularRimAngle}
109112
/>
110113

111114
{/* @ts-expect-error Need to fix types in this file */}

libs/@hashintel/refractive/stories/playground.stories.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { refractive } from "../src/hoc/refractive";
66
import { ExampleArticle } from "./example-article";
77

88
type Props = {
9+
radius: number;
910
compositing: CompositeMode;
11+
specularRimAngle: number | undefined;
1012
};
1113

12-
const GlassOverArticle = ({ compositing }: Props) => (
14+
const GlassOverArticle = ({ radius, compositing, specularRimAngle }: Props) => (
1315
<div style={{ position: "relative" }}>
1416
<refractive.div
1517
style={{
@@ -28,12 +30,13 @@ const GlassOverArticle = ({ compositing }: Props) => (
2830
}}
2931
refraction={{
3032
blur: 2,
31-
radius: 20,
33+
radius,
3234
edgeSize: 30,
3335
thickness: 70,
3436
refractiveIndex: 1.5,
3537
edgeProfile: convex,
3638
compositing,
39+
specularRimAngle,
3740
}}
3841
>
3942
Refractive Glass
@@ -47,13 +50,21 @@ const meta = {
4750
title: "Playground",
4851
component: GlassOverArticle,
4952
argTypes: {
53+
radius: {
54+
control: { type: "range", min: 0, max: 100, step: 1 },
55+
},
5056
compositing: {
5157
control: { type: "inline-radio" as const },
5258
options: ["image", "parts"],
5359
},
60+
specularRimAngle: {
61+
control: { type: "range", min: 0, max: 6.28, step: 0.01 },
62+
},
5463
},
5564
args: {
65+
radius: 20,
5666
compositing: "image",
67+
specularRimAngle: Math.PI / 4,
5768
},
5869
} satisfies Meta<typeof GlassOverArticle>;
5970

@@ -62,3 +73,7 @@ export default meta;
6273
type Story = StoryObj<typeof meta>;
6374

6475
export const Default: Story = {};
76+
77+
export const NoSpecular: Story = {
78+
args: { specularRimAngle: undefined },
79+
};

0 commit comments

Comments
 (0)