Skip to content

Commit e896e60

Browse files
kubeclaude
andcommitted
FE-43: Add DiffuseReflection effect, shared lightAngle, and Playground controls
- 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) <noreply@anthropic.com>
1 parent 5cc60dd commit e896e60

6 files changed

Lines changed: 252 additions & 27 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { generateTableValues } from "../../helpers/generate-table-values";
2+
3+
/**
4+
* Generates a directional cosine table centered at 0.5 (signed encoding).
5+
*
6+
* Maps the polar map angle channel [0,255] → [0,2π] to
7+
* `(cos(angle - lightAngle) + 1) / 2`, where 0.5 = perpendicular,
8+
* 1 = facing light, 0 = facing away.
9+
*/
10+
function generateCosAngleTable(lightAngle: number): string {
11+
return generateTableValues(256, (i) => {
12+
const angle = (i / 255) * 2 * Math.PI;
13+
return (Math.cos(angle - lightAngle) + 1) / 2;
14+
});
15+
}
16+
17+
type DiffuseReflectionProps = {
18+
/** Input result name containing the polar map (R=distance ratio, G=angle). */
19+
in: string;
20+
/** Input source image to apply diffuse shading to. */
21+
source: string;
22+
/** Light angle in radians (0 = right, π/2 = down, π = left, 3π/2 = up). */
23+
lightAngle: number;
24+
/**
25+
* Pre-computed surface tilt lookup table (from generateSurfaceTiltTable).
26+
* Maps distance ratio → normalized tilt [0,1].
27+
*/
28+
surfaceTiltTable: string;
29+
/** Strength of the diffuse shading [0,1]. */
30+
intensity?: number;
31+
/** Output result name. */
32+
result: string;
33+
};
34+
35+
/**
36+
* @private
37+
* Diffuse reflection effect: applies Lambertian-style shading based on the
38+
* surface normal (derived from the edge profile) and light direction.
39+
*
40+
* Surfaces facing the light are brightened (white overlay), surfaces facing
41+
* away are darkened (black overlay). Neutral areas have alpha=0 and don't
42+
* affect the source at all — no gray wash on dark backgrounds.
43+
*
44+
* Pipeline:
45+
* 1. Extract angle (G) → cosine of angle relative to light direction (signed, centered at 0.5)
46+
* 2. Extract distance ratio (R) → surface tilt from edge profile (unsigned [0,1])
47+
* 3. Signed × unsigned multiply → diffuse value centered at 0.5
48+
* 4. Light pass: white with alpha = max(0, diffuse - 0.5) × 2 × intensity
49+
* 5. Dark pass: black with alpha = max(0, 0.5 - diffuse) × 2 × intensity
50+
*/
51+
export const DiffuseReflection: React.FC<DiffuseReflectionProps> = ({
52+
in: inResult,
53+
source,
54+
lightAngle,
55+
surfaceTiltTable,
56+
intensity = 0.3,
57+
result,
58+
}) => {
59+
const cosAngleTable = generateCosAngleTable(lightAngle);
60+
const lightAlphaScale = 2 * intensity;
61+
62+
return (
63+
<>
64+
{/* 1. Copy angle (G) into R, apply signed cosine table */}
65+
<feColorMatrix
66+
in={inResult}
67+
type="matrix"
68+
values="0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
69+
result={`${result}_cos_in`}
70+
/>
71+
<feComponentTransfer in={`${result}_cos_in`} result={`${result}_cos`}>
72+
<feFuncR type="table" tableValues={cosAngleTable} />
73+
</feComponentTransfer>
74+
75+
{/* 2. Apply surface tilt table to R channel (distance ratio → tilt) */}
76+
<feComponentTransfer in={inResult} result={`${result}_tilt`}>
77+
<feFuncR type="table" tableValues={surfaceTiltTable} />
78+
</feComponentTransfer>
79+
80+
{/* 3. Signed × unsigned multiply: cos(centered 0.5) × tilt(unsigned)
81+
result = A·B - 0.5·B + 0.5
82+
Maps to [0,1] centered at 0.5: >0.5 = lit, <0.5 = shadow */}
83+
<feComposite
84+
in={`${result}_cos`}
85+
in2={`${result}_tilt`}
86+
operator="arithmetic"
87+
k1={1}
88+
k2={0}
89+
k3={-0.5}
90+
k4={0.5}
91+
result={`${result}_diffuse`}
92+
/>
93+
94+
{/* 4. Light pass: white overlay where diffuse > 0.5
95+
A = intensity × 2 × (R - 0.5), clamped to 0 when R < 0.5 */}
96+
<feColorMatrix
97+
in={`${result}_diffuse`}
98+
type="matrix"
99+
values={`0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 ${lightAlphaScale} 0 0 0 ${-intensity}`}
100+
result={`${result}_light`}
101+
/>
102+
<feComposite
103+
in={`${result}_light`}
104+
in2={source}
105+
operator="over"
106+
result={`${result}_lit`}
107+
/>
108+
109+
{/* 5. Dark pass: black overlay where diffuse < 0.5
110+
A = intensity × 2 × (0.5 - R), clamped to 0 when R > 0.5 */}
111+
<feColorMatrix
112+
in={`${result}_diffuse`}
113+
type="matrix"
114+
values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${-lightAlphaScale} 0 0 0 ${intensity}`}
115+
result={`${result}_dark`}
116+
/>
117+
<feComposite
118+
in={`${result}_dark`}
119+
in2={`${result}_lit`}
120+
operator="over"
121+
result={result}
122+
/>
123+
</>
124+
);
125+
};

libs/@hashintel/refractive/src/components/effects/specular-rim.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ function generateRimTable(radius: number): string {
2222
* Generates a directional highlight lookup table.
2323
*
2424
* 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.
25+
* lobe along the `lightAngle` axis. The light direction is treated
26+
* as an axis — both the light-facing and opposite sides receive
27+
* the highlight. The lobe width is controlled by `spread`.
2728
*/
2829
function generateDirectionalTable(lightAngle: number, spread: number): string {
2930
return generateTableValues(256, (i) => {
3031
// The polar map G channel encodes the displacement angle (toward center).
3132
const angle = (i / 255) * 2 * Math.PI;
3233
const dot = Math.cos(angle - lightAngle);
33-
// Raise to power for tighter highlights, clamp negative
34-
return Math.pow(Math.max(0, dot), spread);
34+
// Use |cos| so both sides of the axis get the highlight
35+
return Math.pow(Math.abs(dot), spread);
3536
});
3637
}
3738

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

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

@@ -11,6 +12,7 @@ type FilterShellProps = {
1112
blur: number;
1213
scale: number;
1314
magnitudeTable: string;
15+
surfaceTiltTable: string;
1416
parts: Parts;
1517
cornerWidth: number;
1618
/**
@@ -23,8 +25,12 @@ type FilterShellProps = {
2325
compositing?: CompositeMode;
2426
/** Ref to the element whose dimensions drive the "parts" layout. Required when compositing is "parts". */
2527
elementRef?: React.RefObject<HTMLElement | null>;
26-
/** Specular rim light angle in radians. Undefined disables the effect. */
27-
specularRimAngle?: number;
28+
/** Light direction in radians. Enables diffuse and specular effects. */
29+
lightAngle?: number;
30+
/** Strength of diffuse reflection shading [0,1]. 0 or undefined disables. */
31+
diffuseIntensity?: number;
32+
/** Whether to enable the specular rim highlight. Requires lightAngle. */
33+
specular?: boolean;
2834
hideTop?: boolean;
2935
hideBottom?: boolean;
3036
hideLeft?: boolean;
@@ -35,26 +41,42 @@ type FilterShellProps = {
3541
* @private
3642
* Full SVG filter pipeline: blur → polar map compositing → effects.
3743
*
38-
* The `compositing` prop controls how the 9-patch polar map is assembled
39-
* inside the SVG filter graph. The polar map is then consumed by composable
40-
* effects (refraction, specular rim, etc.).
44+
* Effect chain: refraction → diffuse reflection → specular rim.
45+
* Each effect consumes the shared polar map independently.
4146
*/
4247
export const FilterShell: React.FC<FilterShellProps> = ({
4348
id,
4449
blur,
4550
scale,
4651
magnitudeTable,
52+
surfaceTiltTable,
4753
parts,
4854
cornerWidth,
4955
compositing = "image",
5056
elementRef,
51-
specularRimAngle,
57+
lightAngle,
58+
diffuseIntensity,
59+
specular,
5260
hideTop,
5361
hideBottom,
5462
hideLeft,
5563
hideRight,
5664
}) => {
5765
const isImage = compositing === "image";
66+
const hasDiffuse =
67+
lightAngle !== undefined &&
68+
diffuseIntensity !== undefined &&
69+
diffuseIntensity > 0;
70+
const hasSpecular = specular !== false && lightAngle !== undefined;
71+
72+
// Build the effect chain: refraction → diffuse → specular
73+
// Each effect reads "polar_map" and takes the previous result as source.
74+
const refractionResult = "refracted";
75+
const diffuseResult = hasDiffuse ? "with_diffuse" : refractionResult;
76+
const specularResult = hasSpecular ? "with_specular" : diffuseResult;
77+
// The last enabled effect's result is used as the filter output.
78+
// SVG uses the last result in the filter chain automatically.
79+
void specularResult; // referenced implicitly by the filter
5880

5981
return (
6082
<svg colorInterpolationFilters="sRGB" style={{ display: "none" }}>
@@ -100,16 +122,27 @@ export const FilterShell: React.FC<FilterShellProps> = ({
100122
scale={scale}
101123
in="polar_map"
102124
source="blurred_source"
103-
result="refracted"
125+
result={refractionResult}
104126
/>
105127

106-
{specularRimAngle !== undefined && (
128+
{hasDiffuse && (
129+
<DiffuseReflection
130+
in="polar_map"
131+
source={refractionResult}
132+
lightAngle={lightAngle}
133+
surfaceTiltTable={surfaceTiltTable}
134+
intensity={diffuseIntensity}
135+
result={diffuseResult}
136+
/>
137+
)}
138+
139+
{hasSpecular && (
107140
<SpecularRim
108141
in="polar_map"
109-
source="refracted"
142+
source={diffuseResult}
110143
radius={cornerWidth}
111-
lightAngle={specularRimAngle}
112-
result="with_specular"
144+
lightAngle={lightAngle}
145+
result={specularResult}
113146
/>
114147
)}
115148
</filter>

libs/@hashintel/refractive/src/helpers/generate-table-values.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,31 @@ export function generateMagnitudeTable(
3939
);
4040
});
4141
}
42+
43+
/**
44+
* Generate a surface tilt lookup table from an edge profile function.
45+
*
46+
* Maps border distance ratio [0,1] to normalized surface tilt [0,1],
47+
* where 0 = flat surface (no diffuse contribution) and 1 = maximum tilt.
48+
*
49+
* The tilt is `sin(atan(derivative))` = `|d| / sqrt(1 + d²)` where d is
50+
* the edge profile's derivative at the given ratio.
51+
*
52+
* @param edgeProfile - Surface equation function (e.g. convex, concave).
53+
* @param ratioScale - Multiplier to remap the input ratio (e.g. radius/edgeSize).
54+
*/
55+
export function generateSurfaceTiltTable(
56+
edgeProfile: (x: number) => number,
57+
ratioScale: number = 1,
58+
): string {
59+
return generateTableValues(256, (i) => {
60+
if (i === 0) return 0; // border/outside → no effect
61+
const ratio = Math.min(1, (i / 255) * ratioScale);
62+
if (ratio >= 1) return 0; // past the edge → flat
63+
const dx = 0.0001;
64+
const derivative =
65+
(edgeProfile(Math.min(1, ratio + dx)) - edgeProfile(ratio)) / dx;
66+
// sin(atan(d)) = |d| / sqrt(1 + d²)
67+
return Math.abs(derivative) / Math.sqrt(1 + derivative * derivative);
68+
});
69+
}

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import type { JSX } from "react/jsx-runtime";
44

55
import type { CompositeMode } from "../components/filter-shell";
66
import { FilterShell } from "../components/filter-shell";
7-
import { generateMagnitudeTable } from "../helpers/generate-table-values";
7+
import {
8+
generateMagnitudeTable,
9+
generateSurfaceTiltTable,
10+
} from "../helpers/generate-table-values";
811
import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts";
912
import { convex } from "../helpers/surface-equations";
1013
import { calculateDisplacementMapRadius } from "../maps/displacement-radius";
@@ -49,8 +52,12 @@ type RefractionProps = {
4952
* - `"parts"`: 9-patch feImage primitives, requires explicit sizing.
5053
*/
5154
compositing?: CompositeMode;
52-
/** Specular rim light angle in radians. Undefined disables the effect. */
53-
specularRimAngle?: number;
55+
/** Light direction in radians (0 = right, π/2 = down). Used by diffuse and specular effects. */
56+
lightAngle?: number;
57+
/** Strength of diffuse reflection shading [0,1]. 0 disables. */
58+
diffuseIntensity?: number;
59+
/** Whether to enable the specular rim highlight. Default true when lightAngle is set. */
60+
specular?: boolean;
5461
};
5562
};
5663

@@ -97,18 +104,24 @@ function createRefractiveComponent<
97104
ratioScale,
98105
);
99106

107+
const edgeProfile = refraction.edgeProfile ?? convex;
108+
const surfaceTiltTable = generateSurfaceTiltTable(edgeProfile, ratioScale);
109+
100110
return (
101111
<>
102112
<FilterShell
103113
id={filterId}
104114
blur={refraction.blur ?? 0}
105115
scale={2 * maximumDisplacement}
106116
magnitudeTable={magnitudeTable}
117+
surfaceTiltTable={surfaceTiltTable}
107118
parts={hiResParts}
108119
cornerWidth={radius}
109120
compositing={refraction.compositing}
110121
elementRef={elementRef}
111-
specularRimAngle={refraction.specularRimAngle}
122+
lightAngle={refraction.lightAngle}
123+
diffuseIntensity={refraction.diffuseIntensity}
124+
specular={refraction.specular}
112125
/>
113126

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

0 commit comments

Comments
 (0)