Halftone studio v3 (glass effect) (#19598)

## Summary
- refine `/halftone` controls for material selection and slower 3D
rotation tuning
- switch the default halftone material to solid for new sessions
- include related halftone rendering, export, and glass material updates
across the website app

## Testing
- `yarn nx run twenty-website-new:typecheck`
- `yarn prettier
packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx
--check`
- `yarn prettier
packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx
packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx
--check`
- `yarn prettier
packages/twenty-website-new/src/app/halftone/_lib/state.ts --check`
This commit is contained in:
Thomas des Francs
2026-04-12 16:40:52 +02:00
committed by GitHub
parent fda5aba9ec
commit d9d3648baa
20 changed files with 3709 additions and 1318 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

@@ -1,9 +1,6 @@
'use client';
import {
IconLayoutSidebarRightCollapse,
IconShare,
} from '@tabler/icons-react';
import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
import { styled } from '@linaria/react';
import type {
HalftoneBackgroundSettings,
@@ -191,6 +188,7 @@ export function ControlsPanel({
{visible && activeTab === 'design' ? (
<DesignTab
imageFileName={imageFileName}
onAnimationSettingsChange={onAnimationSettingsChange}
onBackgroundChange={onBackgroundChange}
onDashColorChange={onDashColorChange}
onHalftoneChange={onHalftoneChange}
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import {
type HalftoneSnapshotFn,
} from '@/app/halftone/_components/HalftoneCanvas';
import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import {
createFallbackGeometry,
disposeGeometryCache,
@@ -354,6 +355,10 @@ export function HalftoneStudio() {
selectedShape,
state.settings.sourceMode,
]);
const exportArtifactNames = useMemo(
() => resolveExportArtifactNames(exportName, defaultExportName),
[defaultExportName, exportName],
);
useEffect(() => {
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
@@ -818,10 +823,8 @@ export function HalftoneStudio() {
}, []);
const handleExportReact = useCallback(() => {
const componentName = exportName || defaultExportName;
const kebabName = componentName
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
const componentName = exportArtifactNames.componentName;
const kebabName = exportArtifactNames.fileBaseName;
const isImageMode = state.settings.sourceMode === 'image';
const importedFile = selectedImportedFile;
const exportBackgroundColor = exportBackground
@@ -857,8 +860,7 @@ export function HalftoneStudio() {
downloadBlob(modelFilename ?? importedFile.name, importedFile);
}
}, [
defaultExportName,
exportName,
exportArtifactNames,
exportBackground,
imageFile,
previewDistance,
@@ -870,10 +872,7 @@ export function HalftoneStudio() {
const handleExportHalftoneImage = useCallback(
async (width: number, height: number) => {
const snapshotFn = snapshotReference.current;
const componentName = exportName || defaultExportName;
const kebabName = componentName
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
const kebabName = exportArtifactNames.fileBaseName;
if (!snapshotFn) {
return;
@@ -891,18 +890,15 @@ export function HalftoneStudio() {
downloadBlob(`${kebabName}-${width}x${height}.png`, blob);
},
[
defaultExportName,
exportArtifactNames.fileBaseName,
exportBackground,
exportName,
state.settings.background.color,
],
);
const handleExportHtml = useCallback(async () => {
const componentName = exportName || defaultExportName;
const kebabName = componentName
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
const componentName = exportArtifactNames.componentName;
const kebabName = exportArtifactNames.fileBaseName;
const isImageMode = state.settings.sourceMode === 'image';
const importedFile = selectedImportedFile;
const exportBackgroundColor = exportBackground
@@ -936,8 +932,7 @@ export function HalftoneStudio() {
downloadBlob(imageExportFilename ?? imageFile.name, imageFile);
}
}, [
defaultExportName,
exportName,
exportArtifactNames,
exportBackground,
imageFile,
previewDistance,
@@ -18,6 +18,9 @@ import {
ToggleControl,
} from './controls-ui';
const MIN_ROTATION_SPEED = 0.01;
const ROTATION_SPEED_STEP = 0.01;
type AnimationsTabProps = {
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
@@ -41,6 +44,75 @@ export function AnimationsTab({
{isImageMode ? (
<>
<Section $first>
<SectionToggleHeader
checked={animation.hoverHalftoneEnabled}
onChange={(event) =>
onAnimationSettingsChange({
hoverHalftoneEnabled: event.target.checked,
})
}
preserveCase
>
{effectLabel(
'Hover Halftone',
'Uses the cursor radius to locally push the halftone power and width, so the bars open or tighten around the mouse instead of only brightening.',
)}
</SectionToggleHeader>
{animation.hoverHalftoneEnabled ? (
<ControlGrid>
<SliderControl
max={1.5}
min={-1.5}
onChange={(event) =>
onAnimationSettingsChange({
hoverHalftonePowerShift: Number(event.target.value),
})
}
step={0.01}
value={animation.hoverHalftonePowerShift}
valueLabel={formatDecimal(
animation.hoverHalftonePowerShift,
2,
)}
>
Power shift
</SliderControl>
<SliderControl
max={1.35}
min={-1.35}
onChange={(event) =>
onAnimationSettingsChange({
hoverHalftoneWidthShift: Number(event.target.value),
})
}
step={0.01}
value={animation.hoverHalftoneWidthShift}
valueLabel={formatDecimal(
animation.hoverHalftoneWidthShift,
2,
)}
>
Width shift
</SliderControl>
<SliderControl
max={0.45}
min={0.06}
onChange={(event) =>
onAnimationSettingsChange({
hoverHalftoneRadius: Number(event.target.value),
})
}
step={0.01}
value={animation.hoverHalftoneRadius}
valueLabel={formatDecimal(animation.hoverHalftoneRadius, 2)}
>
Radius
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionToggleHeader
checked={animation.hoverLightEnabled}
onChange={(event) =>
@@ -110,15 +182,15 @@ export function AnimationsTab({
<>
<SliderControl
max={4}
min={0.05}
min={MIN_ROTATION_SPEED}
onChange={(event) =>
onAnimationSettingsChange({
autoSpeed: Number(event.target.value),
})
}
step={0.05}
step={ROTATION_SPEED_STEP}
value={animation.autoSpeed}
valueLabel={formatDecimal(animation.autoSpeed, 1)}
valueLabel={formatDecimal(animation.autoSpeed, 2)}
>
Speed
</SliderControl>
@@ -195,15 +267,15 @@ export function AnimationsTab({
) : null}
<SliderControl
max={4}
min={0.1}
min={MIN_ROTATION_SPEED}
onChange={(event) =>
onAnimationSettingsChange({
rotateSpeed: Number(event.target.value),
})
}
step={0.1}
step={ROTATION_SPEED_STEP}
value={animation.rotateSpeed}
valueLabel={formatDecimal(animation.rotateSpeed, 1)}
valueLabel={formatDecimal(animation.rotateSpeed, 2)}
>
Speed
</SliderControl>
@@ -1,10 +1,16 @@
'use client';
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
import type {
HalftoneBackgroundSettings,
HalftoneSourceMode,
HalftoneStudioSettings,
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_LIGHTING_SETTINGS,
DEFAULT_GLASS_MATERIAL_SETTINGS,
DEFAULT_SOLID_ANIMATION_SETTINGS,
DEFAULT_SOLID_LIGHTING_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
type HalftoneBackgroundSettings,
type HalftoneSourceMode,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { styled } from '@linaria/react';
import {
@@ -15,6 +21,7 @@ import {
Section,
SectionTitle,
SectionToggleHeader,
SegmentedControl,
SelectInput,
ShapeRow,
SliderControl,
@@ -59,6 +66,9 @@ const ColorSwapButton = styled.button`
`;
type DesignTabProps = {
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
) => void;
imageFileName: string | null;
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
onDashColorChange: (value: string) => void;
@@ -80,7 +90,14 @@ type DesignTabProps = {
shapeOptions: Array<{ label: string; value: string }>;
};
function matchesSettings<T extends object>(value: T, target: T) {
return (Object.keys(target) as Array<keyof T>).every(
(key) => value[key] === target[key],
);
}
export function DesignTab({
onAnimationSettingsChange,
imageFileName,
onBackgroundChange,
onDashColorChange,
@@ -103,6 +120,39 @@ export function DesignTab({
imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME
? DEFAULT_IMAGE_OPTION_LABEL
: imageFileName;
const handleSurfaceChange = (surface: 'glass' | 'solid') => {
const switchingToGlass = surface === 'glass';
onMaterialChange(
switchingToGlass
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS,
);
if (switchingToGlass) {
if (matchesSettings(settings.lighting, DEFAULT_SOLID_LIGHTING_SETTINGS)) {
onLightingChange(DEFAULT_GLASS_LIGHTING_SETTINGS);
}
if (
matchesSettings(settings.animation, DEFAULT_SOLID_ANIMATION_SETTINGS)
) {
onAnimationSettingsChange(DEFAULT_GLASS_ANIMATION_SETTINGS);
}
return;
}
if (matchesSettings(settings.lighting, DEFAULT_GLASS_LIGHTING_SETTINGS)) {
onLightingChange(DEFAULT_SOLID_LIGHTING_SETTINGS);
}
if (matchesSettings(settings.animation, DEFAULT_GLASS_ANIMATION_SETTINGS)) {
onAnimationSettingsChange(DEFAULT_SOLID_ANIMATION_SETTINGS);
}
};
const handleSwapColors = () => {
const nextDashColor = settings.background.color;
const nextBackgroundColor = settings.halftone.dashColor;
@@ -280,6 +330,18 @@ export function DesignTab({
<Section>
<SectionTitle>Material</SectionTitle>
<ControlGrid>
<SegmentedControl
onChange={(value) =>
handleSurfaceChange(value === 'glass' ? 'glass' : 'solid')
}
options={[
{ label: 'Solid', value: 'solid' },
{ label: 'Glass', value: 'glass' },
]}
value={settings.material.surface}
>
Surface
</SegmentedControl>
<SliderControl
max={1}
min={0}
@@ -304,6 +366,55 @@ export function DesignTab({
>
Metalness
</SliderControl>
{settings.material.surface === 'glass' ? (
<>
<SliderControl
max={20}
min={1.1}
onChange={(event) =>
onMaterialChange({
thickness: Number(event.target.value),
})
}
step={0.1}
value={settings.material.thickness}
valueLabel={formatDecimal(settings.material.thickness, 0)}
>
Thickness
</SliderControl>
<SliderControl
max={3}
min={1.1}
onChange={(event) =>
onMaterialChange({
refraction: Number(event.target.value),
})
}
step={0.01}
value={settings.material.refraction}
valueLabel={formatDecimal(settings.material.refraction)}
>
Refraction
</SliderControl>
<SliderControl
max={5}
min={0}
onChange={(event) =>
onMaterialChange({
environmentPower: Number(event.target.value),
})
}
step={0.01}
value={settings.material.environmentPower}
valueLabel={formatDecimal(
settings.material.environmentPower,
2,
)}
>
Power
</SliderControl>
</>
) : null}
</ControlGrid>
</Section>
</>
@@ -1,5 +1,6 @@
'use client';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import type {
HalftoneGeometrySpec,
@@ -72,8 +73,11 @@ export function ExportTab({
? DEFAULT_IMAGE_LABEL
: imageFileName
: (selectedShape?.label ?? settings.shapeKey);
const componentName = exportName || defaultExportName;
const inputName = exportName || defaultExportName;
const { componentName } = resolveExportArtifactNames(
exportName,
defaultExportName,
);
const handleDownloadHalftoneImage = () => {
const [widthStr, heightStr] = resolution.split('x');
@@ -98,7 +102,7 @@ export function ExportTab({
onFocus={(event) => event.currentTarget.select()}
placeholder={defaultExportName}
type="text"
value={componentName}
value={inputName}
/>
<ToggleControl
@@ -134,6 +134,13 @@ export const SelectLabel = styled.label`
grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
`;
export const SegmentedLabel = styled.div`
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr);
`;
export const ControlValue = styled.span`
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
@@ -142,6 +149,47 @@ export const ControlValue = styled.span`
text-align: right;
`;
const SegmentedGroup = styled.div`
align-items: stretch;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
display: grid;
gap: 1px;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
height: 24px;
padding: 1px;
width: 100%;
`;
const SegmentedButton = styled.button<{ $active: boolean }>`
background: ${(props) =>
props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'};
border: none;
border-radius: 6px;
color: ${(props) =>
props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'};
cursor: pointer;
font-family: ${theme.font.family.sans};
font-size: 11px;
font-weight: ${(props) => (props.$active ? 600 : 500)};
height: 100%;
padding: 0 10px;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
color: rgba(255, 255, 255, 0.86);
}
&:focus-visible {
outline: 1px solid rgba(255, 255, 255, 0.35);
outline-offset: 1px;
}
`;
const EditableControlValueButton = styled.button`
background: transparent;
border: none;
@@ -250,8 +298,9 @@ export const SelectInput = styled.select`
cursor: pointer;
font-family: ${theme.font.family.sans};
font-size: 11px;
height: 24px;
outline: none;
padding: 7px 34px 7px 10px;
padding: 0 34px 0 10px;
transition: border-color 0.15s ease;
width: 100%;
@@ -576,10 +625,10 @@ export const UploadButton = styled.button`
display: flex;
flex-shrink: 0;
font-size: 13px;
height: 32px;
height: 24px;
justify-content: center;
transition: all 0.15s ease;
width: 32px;
width: 24px;
&:hover {
background: rgba(255, 255, 255, 0.12);
@@ -874,6 +923,51 @@ export function SelectControl({
);
}
type SegmentedControlProps = {
children: ReactNode;
onChange: (value: string) => void;
value: string;
options: Array<{ label: string; value: string }>;
};
export function SegmentedControl({
children,
onChange,
options,
value,
}: SegmentedControlProps) {
return (
<SegmentedLabel>
<span>{children}</span>
<SegmentedGroup
aria-label={typeof children === 'string' ? children : undefined}
role="radiogroup"
>
{options.map((option) => {
const isActive = option.value === value;
return (
<SegmentedButton
$active={isActive}
aria-checked={isActive}
key={option.value}
onClick={() => {
if (!isActive) {
onChange(option.value);
}
}}
role="radio"
type="button"
>
{option.label}
</SegmentedButton>
);
})}
</SegmentedGroup>
</SegmentedLabel>
);
}
type ToggleControlProps = {
checked: boolean;
label: ReactNode;
@@ -0,0 +1,51 @@
function toPascalCase(value: string) {
const tokens = value
.replace(/\.[^.]+$/, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
const joined = tokens
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
.join('');
if (!joined) {
return 'HalftoneDashes';
}
return /^[A-Za-z_]/.test(joined) ? joined : `Halftone${joined}`;
}
export function normalizeExportComponentName(
value: string | null | undefined,
fallback = 'HalftoneDashes',
) {
return toPascalCase(value?.trim() || fallback);
}
export function toKebabCase(value: string) {
const normalized = value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.replace(/([a-zA-Z])([0-9])/g, '$1-$2')
.replace(/([0-9])([a-zA-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase();
return normalized || 'halftone-dashes';
}
export function resolveExportArtifactNames(
value: string | null | undefined,
fallback = 'HalftoneDashes',
) {
const componentName = normalizeExportComponentName(value, fallback);
return {
componentName,
fileBaseName: toKebabCase(componentName),
};
}
@@ -0,0 +1,37 @@
import {
generateReactComponent,
generateStandaloneHtml,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { DEFAULT_HALFTONE_SETTINGS } from '@/app/halftone/_lib/state';
describe('halftone export naming', () => {
it('normalizes free-form export names into safe component and file names', () => {
expect(resolveExportArtifactNames('hero export 2026')).toEqual({
componentName: 'HeroExport2026',
fileBaseName: 'hero-export-2026',
});
});
it('sanitizes generated React component identifiers', () => {
const output = generateReactComponent(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'hero export 2026',
);
expect(output).toContain('type HeroExport2026Props = {');
expect(output).toContain('export default function HeroExport2026({');
expect(output).not.toContain('type hero export 2026Props = {');
});
it('sanitizes generated standalone HTML titles', async () => {
const output = await generateStandaloneHtml(
DEFAULT_HALFTONE_SETTINGS,
undefined,
'hero export 2026',
);
expect(output).toContain('<title>HeroExport2026</title>');
});
});
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ export function formatAnimationName(animation: {
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverHalftoneEnabled: boolean;
hoverLightEnabled: boolean;
lightSweepEnabled: boolean;
rotateEnabled: boolean;
@@ -27,6 +28,10 @@ export function formatAnimationName(animation: {
const activeModes: string[] = [];
if (sourceMode === 'image') {
if (animation.hoverHalftoneEnabled) {
activeModes.push('hoverHalftone');
}
if (animation.hoverLightEnabled) {
activeModes.push('hoverLight');
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,880 @@
import type { HalftoneMaterialSettings } from '@/app/halftone/_lib/state';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import * as THREE from 'three';
export type HalftoneMaterialAssets = {
glassBackgroundTexture: THREE.Texture;
glassEnvironmentTexture: THREE.Texture;
glassTransmissionScene: THREE.Scene;
solidEnvironmentTexture: THREE.Texture;
};
export class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial {
declare anisotropicBlur: number;
declare attenuationColor: THREE.Color;
declare attenuationDistance: number;
declare buffer: THREE.Texture | null;
declare chromaticAberration: number;
declare distortion: number;
declare distortionScale: number;
declare refractionEnvMap: THREE.Texture | null;
declare temporalDistortion: number;
declare thickness: number;
declare time: number;
declare _transmission: number;
declare useEnvMapRefraction: number;
private readonly halftoneUniforms: Record<string, { value: unknown }>;
public constructor(samples = 10) {
super();
this.halftoneUniforms = {
chromaticAberration: { value: 0.05 },
transmission: { value: 0 },
_transmission: { value: 1 },
transmissionMap: { value: null },
refractionEnvMap: { value: null },
useEnvMapRefraction: { value: 0 },
roughness: { value: 0 },
thickness: { value: 0 },
thicknessMap: { value: null },
attenuationDistance: { value: Infinity },
attenuationColor: { value: new THREE.Color('white') },
anisotropicBlur: { value: 0.1 },
time: { value: 0 },
distortion: { value: 0 },
distortionScale: { value: 0.5 },
temporalDistortion: { value: 0 },
buffer: { value: null },
};
this.customProgramCacheKey = () => `halftone-transmission-${samples}`;
this.onBeforeCompile = (shader) => {
shader.uniforms = {
...shader.uniforms,
...this.halftoneUniforms,
};
shader.defines ??= {};
if (this.anisotropy > 0) {
shader.defines.USE_ANISOTROPY = '';
}
shader.defines.USE_TRANSMISSION = '';
shader.fragmentShader =
`
uniform float chromaticAberration;
uniform float anisotropicBlur;
uniform float time;
uniform float distortion;
uniform float distortionScale;
uniform float temporalDistortion;
uniform sampler2D buffer;
vec3 random3(vec3 c) {
float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
vec3 r;
r.z = fract(512.0 * j);
j *= 0.125;
r.x = fract(512.0 * j);
j *= 0.125;
r.y = fract(512.0 * j);
return r - 0.5;
}
uint hash(uint x) {
x += (x << 10u);
x ^= (x >> 6u);
x += (x << 3u);
x ^= (x >> 11u);
x += (x << 15u);
return x;
}
uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }
uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }
uint hash(uvec4 v) {
return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));
}
float floatConstruct(uint m) {
const uint ieeeMantissa = 0x007FFFFFu;
const uint ieeeOne = 0x3F800000u;
m &= ieeeMantissa;
m |= ieeeOne;
float f = uintBitsToFloat(m);
return f - 1.0;
}
float randomBase(float x) {
return floatConstruct(hash(floatBitsToUint(x)));
}
float randomBase(vec2 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float randomBase(vec3 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float randomBase(vec4 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float rand(float seed) {
return randomBase(vec3(gl_FragCoord.xy, seed));
}
const float F3 = 0.3333333;
const float G3 = 0.1666667;
float snoise(vec3 p) {
vec3 s = floor(p + dot(p, vec3(F3)));
vec3 x = p - s + dot(s, vec3(G3));
vec3 e = step(vec3(0.0), x - x.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
vec3 x1 = x - i1 + G3;
vec3 x2 = x - i2 + 2.0 * G3;
vec3 x3 = x - 1.0 + 3.0 * G3;
vec4 w;
vec4 d;
w.x = dot(x, x);
w.y = dot(x1, x1);
w.z = dot(x2, x2);
w.w = dot(x3, x3);
w = max(0.6 - w, 0.0);
d.x = dot(random3(s), x);
d.y = dot(random3(s + i1), x1);
d.z = dot(random3(s + i2), x2);
d.w = dot(random3(s + 1.0), x3);
w *= w;
w *= w;
d *= w;
return dot(d, vec4(52.0));
}
float snoiseFractal(vec3 m) {
return 0.5333333 * snoise(m)
+ 0.2666667 * snoise(2.0 * m)
+ 0.1333333 * snoise(4.0 * m)
+ 0.0666667 * snoise(8.0 * m);
}
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <transmission_pars_fragment>',
`
#ifdef USE_TRANSMISSION
uniform float _transmission;
uniform float thickness;
uniform float attenuationDistance;
uniform vec3 attenuationColor;
uniform sampler2D refractionEnvMap;
uniform float useEnvMapRefraction;
#ifdef USE_TRANSMISSIONMAP
uniform sampler2D transmissionMap;
#endif
#ifdef USE_THICKNESSMAP
uniform sampler2D thicknessMap;
#endif
uniform vec2 transmissionSamplerSize;
uniform sampler2D transmissionSamplerMap;
uniform mat4 modelMatrix;
uniform mat4 projectionMatrix;
varying vec3 vWorldPosition;
vec3 getVolumeTransmissionRay(
const in vec3 n,
const in vec3 v,
const in float thicknessValue,
const in float ior,
const in mat4 modelMatrix
) {
vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);
vec3 modelScale;
modelScale.x = length(vec3(modelMatrix[0].xyz));
modelScale.y = length(vec3(modelMatrix[1].xyz));
modelScale.z = length(vec3(modelMatrix[2].xyz));
return normalize(refractionVector) * thicknessValue * modelScale;
}
float applyIorToRoughness(
const in float roughnessValue,
const in float ior
) {
return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);
}
vec2 directionToEquirectUv(const in vec3 direction) {
vec3 dir = normalize(direction);
vec2 uv = vec2(
atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,
asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5
);
return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));
}
vec4 getTransmissionSample(
const in vec2 fragCoord,
const in vec3 transmissionDirection,
const in float roughnessValue,
const in float ior
) {
if (useEnvMapRefraction > 0.5) {
return texture2D(
refractionEnvMap,
directionToEquirectUv(transmissionDirection)
);
}
float framebufferLod =
log2(transmissionSamplerSize.x) *
applyIorToRoughness(roughnessValue, ior);
return texture2D(buffer, fragCoord.xy);
}
vec3 applyVolumeAttenuation(
const in vec3 radiance,
const in float transmissionDistance,
const in vec3 attenuationColorValue,
const in float attenuationDistanceValue
) {
if (isinf(attenuationDistanceValue)) {
return radiance;
}
vec3 attenuationCoefficient =
-log(attenuationColorValue) / attenuationDistanceValue;
vec3 transmittance =
exp(-attenuationCoefficient * transmissionDistance);
return transmittance * radiance;
}
vec4 getIBLVolumeRefraction(
const in vec3 n,
const in vec3 v,
const in float roughnessValue,
const in vec3 diffuseColor,
const in vec3 specularColor,
const in float specularF90,
const in vec3 position,
const in mat4 modelMatrix,
const in mat4 viewMatrix,
const in mat4 projMatrix,
const in float ior,
const in float thicknessValue,
const in vec3 attenuationColorValue,
const in float attenuationDistanceValue
) {
vec3 transmissionRay = getVolumeTransmissionRay(
n,
v,
thicknessValue,
ior,
modelMatrix
);
vec3 refractedRayExit = position + transmissionRay;
vec4 ndcPos =
projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);
vec2 refractionCoords = ndcPos.xy / ndcPos.w;
refractionCoords += 1.0;
refractionCoords /= 2.0;
vec3 transmissionDirection = normalize(transmissionRay);
vec4 transmittedLight = getTransmissionSample(
refractionCoords,
transmissionDirection,
roughnessValue,
ior
);
vec3 attenuatedColor = applyVolumeAttenuation(
transmittedLight.rgb,
length(transmissionRay),
attenuationColorValue,
attenuationDistanceValue
);
vec3 F = EnvironmentBRDF(
n,
v,
specularColor,
specularF90,
roughnessValue
);
return vec4(
(1.0 - F) * attenuatedColor * diffuseColor,
transmittedLight.a
);
}
#endif
`,
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <transmission_fragment>',
`
material.transmission = _transmission;
material.transmissionAlpha = 1.0;
material.thickness = thickness;
material.attenuationDistance = attenuationDistance;
material.attenuationColor = attenuationColor;
#ifdef USE_TRANSMISSIONMAP
material.transmission *= texture2D(transmissionMap, vUv).r;
#endif
#ifdef USE_THICKNESSMAP
material.thickness *= texture2D(thicknessMap, vUv).g;
#endif
vec3 pos = vWorldPosition;
float runningSeed = 0.0;
vec3 v = normalize(cameraPosition - pos);
vec3 n = inverseTransformDirection(normal, viewMatrix);
vec3 transmission = vec3(0.0);
float transmissionR;
float transmissionG;
float transmissionB;
float randomCoords = rand(runningSeed++);
float thicknessSmear =
thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);
vec3 distortionNormal = vec3(0.0);
vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;
if (distortion > 0.0) {
distortionNormal = distortion * vec3(
snoiseFractal(vec3(pos * distortionScale + temporalOffset)),
snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),
snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))
);
}
for (float i = 0.0; i < ${samples}.0; i++) {
vec3 sampleNorm = normalize(
n +
roughnessFactor * roughnessFactor * 2.0 *
normalize(
vec3(
rand(runningSeed++) - 0.5,
rand(runningSeed++) - 0.5,
rand(runningSeed++) - 0.5
)
) *
pow(rand(runningSeed++), 0.33) +
distortionNormal
);
transmissionR = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior,
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).r;
transmissionG = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(${samples})),
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).g;
transmissionB = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(${samples})),
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).b;
transmission.r += transmissionR;
transmission.g += transmissionG;
transmission.b += transmissionB;
}
transmission /= ${samples}.0;
totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);
`,
);
};
Object.keys(this.halftoneUniforms).forEach((key) => {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this.halftoneUniforms[key]?.value,
set: (value) => {
this.halftoneUniforms[key]!.value = value;
},
});
});
}
}
const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320;
const GLASS_ATTENUATION_DISTANCE_MIN = 0.12;
const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18;
const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12;
const GLASS_ENVIRONMENT_ZOOM = 1.55;
const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303);
const GLASS_TEXTURE_URLS = {
environment: '/halftone/materials/glass/environment.jpg',
} as const;
const MAX_TEXTURE_ANISOTROPY = 8;
function setTextureSampling(
texture: THREE.Texture,
renderer: THREE.WebGLRenderer,
) {
texture.generateMipmaps = true;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = Math.min(
renderer.capabilities.getMaxAnisotropy(),
MAX_TEXTURE_ANISOTROPY,
);
}
function disposeEnvironmentScene(scene: THREE.Scene) {
scene.traverse((object) => {
const mesh = object as THREE.Mesh;
if (mesh.geometry) {
mesh.geometry.dispose();
}
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose());
return;
}
mesh.material?.dispose?.();
});
}
function createSolidEnvironmentTexture(renderer: THREE.WebGLRenderer) {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = pmremGenerator.fromScene(
new RoomEnvironment(),
0.04,
).texture;
pmremGenerator.dispose();
return environmentTexture;
}
function getTextureImageSize(texture: THREE.Texture) {
const image = texture.image as
| {
height?: number;
naturalHeight?: number;
naturalWidth?: number;
videoHeight?: number;
videoWidth?: number;
width?: number;
}
| undefined;
const width =
image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined;
const height =
image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined;
return {
height,
width,
};
}
function createZoomedGlassTexture(
sourceTexture: THREE.Texture,
renderer: THREE.WebGLRenderer,
zoom: number,
) {
if (zoom <= 1) {
return sourceTexture;
}
const { width, height } = getTextureImageSize(sourceTexture);
if (!width || !height) {
return sourceTexture;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return sourceTexture;
}
const cropWidth = width / zoom;
const cropHeight = height / zoom;
const sourceX = (width - cropWidth) / 2;
const sourceY = (height - cropHeight) / 2;
context.drawImage(
sourceTexture.image as CanvasImageSource,
sourceX,
sourceY,
cropWidth,
cropHeight,
0,
0,
width,
height,
);
const zoomedTexture = new THREE.CanvasTexture(canvas);
zoomedTexture.colorSpace = sourceTexture.colorSpace;
zoomedTexture.wrapS = THREE.ClampToEdgeWrapping;
zoomedTexture.wrapT = THREE.ClampToEdgeWrapping;
setTextureSampling(zoomedTexture, renderer);
zoomedTexture.needsUpdate = true;
return zoomedTexture;
}
function createStudioGlassEnvironmentScene(backdropTexture?: THREE.Texture) {
const studioScene = new THREE.Scene();
studioScene.background = backdropTexture ?? GLASS_TRANSMISSION_BACKGROUND;
studioScene.backgroundIntensity = backdropTexture ? 1 : 0.4;
return studioScene;
}
function createStudioGlassEnvironmentTexture(
renderer: THREE.WebGLRenderer,
backdropTexture?: THREE.Texture,
) {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = backdropTexture
? pmremGenerator.fromEquirectangular(backdropTexture).texture
: pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
pmremGenerator.dispose();
return environmentTexture;
}
function createFallbackGlassBackdropTexture(renderer: THREE.WebGLRenderer) {
const texture = new THREE.DataTexture(
new Uint8Array([3, 3, 3, 255]),
1,
1,
THREE.RGBAFormat,
);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.mapping = THREE.EquirectangularReflectionMapping;
setTextureSampling(texture, renderer);
texture.needsUpdate = true;
return texture;
}
function loadTexture(
url: string,
renderer: THREE.WebGLRenderer,
colorSpace: THREE.ColorSpace,
) {
const loader = new THREE.TextureLoader();
return new Promise<THREE.Texture>((resolve, reject) => {
loader.load(
url,
(texture) => {
texture.colorSpace = colorSpace;
setTextureSampling(texture, renderer);
resolve(texture);
},
undefined,
reject,
);
});
}
async function loadGlassEnvironmentAssets(renderer: THREE.WebGLRenderer) {
const sourceBackgroundTexture = await loadTexture(
GLASS_TEXTURE_URLS.environment,
renderer,
THREE.SRGBColorSpace,
);
const backgroundTexture = createZoomedGlassTexture(
sourceBackgroundTexture,
renderer,
GLASS_ENVIRONMENT_ZOOM,
);
if (backgroundTexture !== sourceBackgroundTexture) {
sourceBackgroundTexture.dispose();
}
backgroundTexture.mapping = THREE.EquirectangularReflectionMapping;
backgroundTexture.wrapS = THREE.ClampToEdgeWrapping;
backgroundTexture.wrapT = THREE.ClampToEdgeWrapping;
backgroundTexture.needsUpdate = true;
const transmissionScene =
createStudioGlassEnvironmentScene(backgroundTexture);
const environmentTexture = createStudioGlassEnvironmentTexture(
renderer,
backgroundTexture,
);
return {
backgroundTexture,
environmentTexture,
glassTransmissionScene: transmissionScene,
};
}
function getGlassEnvironmentIntensity(power: number) {
return (
GLASS_ENVIRONMENT_INTENSITY_BASE +
power * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER
);
}
export async function createHalftoneMaterialAssets(
renderer: THREE.WebGLRenderer,
): Promise<HalftoneMaterialAssets> {
const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer);
try {
const glassEnvironmentAssets = await loadGlassEnvironmentAssets(renderer);
return {
glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture,
glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture,
glassTransmissionScene: glassEnvironmentAssets.glassTransmissionScene,
solidEnvironmentTexture,
};
} catch {
const transmissionScene = createStudioGlassEnvironmentScene();
const fallbackGlassBackdropTexture =
createFallbackGlassBackdropTexture(renderer);
const fallbackGlassEnvironmentTexture =
createStudioGlassEnvironmentTexture(renderer);
return {
glassBackgroundTexture: fallbackGlassBackdropTexture,
glassEnvironmentTexture: fallbackGlassEnvironmentTexture,
glassTransmissionScene: transmissionScene,
solidEnvironmentTexture,
};
}
}
export function createHalftoneMaterial() {
return new HalftoneTransmissionMaterial();
}
export function applyHalftoneMaterialSettings(
material: HalftoneTransmissionMaterial,
settings: HalftoneMaterialSettings,
assets: HalftoneMaterialAssets,
) {
const isGlass = settings.surface === 'glass';
const glassThickness = settings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS;
const glassEnvironmentIntensity = getGlassEnvironmentIntensity(
settings.environmentPower,
);
const glassAttenuationDistance = Math.max(
glassThickness * 4,
GLASS_ATTENUATION_DISTANCE_MIN,
);
material.color.set(isGlass ? '#ffffff' : settings.color);
material.roughness = settings.roughness;
material.metalness = settings.metalness;
material.envMap = isGlass
? assets.glassEnvironmentTexture
: assets.solidEnvironmentTexture;
material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25;
material.clearcoat = isGlass ? 1 : 0;
material.clearcoatRoughness = isGlass
? Math.max(settings.roughness * 0.25, 0.01)
: 0.08;
material.reflectivity = isGlass ? 0.98 : 0.5;
material.transmission = 0;
material._transmission = isGlass ? 1 : 0;
material.refractionEnvMap = isGlass ? assets.glassBackgroundTexture : null;
material.useEnvMapRefraction = isGlass ? 1 : 0;
material.thickness = isGlass ? glassThickness : 0;
material.ior = isGlass ? settings.refraction : 1.5;
material.buffer = null;
material.bumpMap = null;
material.bumpScale = 0;
material.roughnessMap = null;
material.side = THREE.FrontSide;
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.attenuationColor.set(isGlass ? settings.color : 'white');
material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity;
material.anisotropicBlur = isGlass
? THREE.MathUtils.lerp(0.03, 0.12, settings.roughness)
: 0.1;
material.chromaticAberration = isGlass ? 0 : 0.05;
material.distortion = 0;
material.distortionScale = 0.5;
material.temporalDistortion = 0;
material.userData.halftoneIsGlass = isGlass;
material.userData.halftoneGlassBacksideThickness = isGlass
? glassThickness * 2
: 0;
material.userData.halftoneGlassBacksideEnvIntensity = isGlass
? glassEnvironmentIntensity * 2.8
: 0;
material.userData.halftoneUseEnvironmentRefraction = isGlass;
material.needsUpdate = true;
}
export function renderHalftoneMaterialScene(options: {
camera: THREE.Camera;
elapsedTime: number;
material: HalftoneTransmissionMaterial;
mesh: THREE.Mesh;
outputTarget: THREE.WebGLRenderTarget | null;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
transmissionBackground?: THREE.Color | THREE.Texture | null;
transmissionBackgroundIntensity?: number;
transmissionScene?: THREE.Scene;
transmissionBacksideTarget: THREE.WebGLRenderTarget;
transmissionTarget: THREE.WebGLRenderTarget;
}) {
const {
camera,
elapsedTime,
material,
mesh,
outputTarget,
renderer,
scene,
transmissionBackground,
transmissionBackgroundIntensity,
transmissionScene,
transmissionBacksideTarget,
transmissionTarget,
} = options;
const isGlass = material.userData.halftoneIsGlass === true;
material.time = elapsedTime;
if (!isGlass) {
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
return;
}
const useEnvironmentRefraction =
material.userData.halftoneUseEnvironmentRefraction === true;
if (useEnvironmentRefraction) {
material.buffer = null;
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
return;
}
const previousToneMapping = renderer.toneMapping;
const previousVisibility = mesh.visible;
const previousBackground = scene.background;
const previousBackgroundIntensity = scene.backgroundIntensity;
const previousSide = material.side;
const previousThickness = material.thickness;
const previousEnvMapIntensity = material.envMapIntensity;
const backsideThickness =
(material.userData.halftoneGlassBacksideThickness as number | undefined) ??
previousThickness;
const backsideEnvMapIntensity =
(material.userData.halftoneGlassBacksideEnvIntensity as
| number
| undefined) ?? previousEnvMapIntensity;
const backgroundIntensity =
transmissionBackgroundIntensity ?? previousEnvMapIntensity;
renderer.toneMapping = THREE.NoToneMapping;
if (transmissionScene) {
renderer.setRenderTarget(transmissionBacksideTarget);
renderer.clear();
renderer.render(transmissionScene, camera);
} else {
scene.background = transmissionBackground ?? GLASS_TRANSMISSION_BACKGROUND;
scene.backgroundIntensity = transmissionBackground
? backgroundIntensity
: 1;
mesh.visible = false;
renderer.setRenderTarget(transmissionBacksideTarget);
renderer.clear();
renderer.render(scene, camera);
mesh.visible = previousVisibility;
}
material.buffer = transmissionBacksideTarget.texture;
material.thickness = backsideThickness;
material.side = THREE.BackSide;
material.envMapIntensity = backsideEnvMapIntensity;
renderer.setRenderTarget(transmissionTarget);
renderer.clear();
renderer.render(scene, camera);
material.buffer = transmissionTarget.texture;
material.thickness = previousThickness;
material.side = previousSide;
material.envMapIntensity = previousEnvMapIntensity;
if (!transmissionScene) {
scene.background = previousBackground;
scene.backgroundIntensity = previousBackgroundIntensity;
}
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
renderer.toneMapping = previousToneMapping;
}
export function disposeHalftoneMaterialAssets(assets: HalftoneMaterialAssets) {
assets.glassBackgroundTexture.dispose();
if (assets.glassEnvironmentTexture !== assets.glassBackgroundTexture) {
assets.glassEnvironmentTexture.dispose();
}
disposeEnvironmentScene(assets.glassTransmissionScene);
assets.solidEnvironmentTexture.dispose();
}
@@ -0,0 +1,59 @@
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_LIGHTING_SETTINGS,
DEFAULT_HALFTONE_SETTINGS,
DEFAULT_SOLID_ANIMATION_SETTINGS,
DEFAULT_SOLID_LIGHTING_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
describe('halftone studio state defaults', () => {
it('keeps the default scene aligned with the solid material preset', () => {
expect(DEFAULT_HALFTONE_SETTINGS.material.surface).toBe('solid');
expect(DEFAULT_HALFTONE_SETTINGS.lighting).toEqual(
DEFAULT_SOLID_LIGHTING_SETTINGS,
);
expect(DEFAULT_HALFTONE_SETTINGS.animation).toEqual(
DEFAULT_SOLID_ANIMATION_SETTINGS,
);
});
it('fills missing nested fields from the selected solid surface defaults', () => {
const normalized = normalizeHalftoneStudioSettings(
JSON.parse(`{
"material": { "surface": "solid" },
"lighting": { "height": 4 },
"animation": { "hoverReturn": false }
}`) as Partial<HalftoneStudioSettings>,
);
expect(normalized.lighting).toEqual({
...DEFAULT_SOLID_LIGHTING_SETTINGS,
height: 4,
});
expect(normalized.animation).toEqual({
...DEFAULT_SOLID_ANIMATION_SETTINGS,
hoverReturn: false,
});
});
it('fills missing nested fields from the selected glass surface defaults', () => {
const normalized = normalizeHalftoneStudioSettings(
JSON.parse(`{
"material": { "surface": "glass" },
"lighting": { "height": 4 },
"animation": { "hoverReturn": false }
}`) as Partial<HalftoneStudioSettings>,
);
expect(normalized.lighting).toEqual({
...DEFAULT_GLASS_LIGHTING_SETTINGS,
height: 4,
});
expect(normalized.animation).toEqual({
...DEFAULT_GLASS_ANIMATION_SETTINGS,
hoverReturn: false,
});
});
});
@@ -1,5 +1,6 @@
export type HalftoneTabId = 'design' | 'animations' | 'export';
export type HalftoneSourceMode = 'shape' | 'image';
export type HalftoneMaterialSurface = 'solid' | 'glass';
export type HalftoneRotateAxis =
| 'x'
| 'y'
@@ -21,8 +22,13 @@ export interface HalftoneLightingSettings {
}
export interface HalftoneMaterialSettings {
surface: HalftoneMaterialSurface;
color: string;
roughness: number;
metalness: number;
thickness: number;
refraction: number;
environmentPower: number;
}
export interface HalftoneEffectSettings {
@@ -46,6 +52,7 @@ export interface HalftoneAnimationSettings {
followHoverEnabled: boolean;
followDragEnabled: boolean;
floatEnabled: boolean;
hoverHalftoneEnabled: boolean;
hoverLightEnabled: boolean;
dragFlowEnabled: boolean;
lightSweepEnabled: boolean;
@@ -75,6 +82,9 @@ export interface HalftoneAnimationSettings {
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverHalftonePowerShift: number;
hoverHalftoneRadius: number;
hoverHalftoneWidthShift: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
@@ -202,6 +212,182 @@ export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
dashColor: '#4A38F5',
};
export const DEFAULT_SOLID_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'solid',
color: '#d4d0c8',
roughness: 0.42,
metalness: 0.16,
thickness: 150,
refraction: 2,
environmentPower: 5,
};
export const DEFAULT_GLASS_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'glass',
color: '#7d7d7d',
roughness: 0,
metalness: 0,
thickness: 15.58,
refraction: 2,
environmentPower: 5,
};
export const DEFAULT_SOLID_LIGHTING_SETTINGS: HalftoneLightingSettings = {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
};
export const DEFAULT_GLASS_LIGHTING_SETTINGS: HalftoneLightingSettings = {
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0.3,
angleDegrees: 53,
height: 2,
};
export const DEFAULT_SOLID_BACKGROUND_SETTINGS: HalftoneBackgroundSettings = {
transparent: true,
color: '#000000',
};
export const DEFAULT_GLASS_BACKGROUND_SETTINGS: HalftoneBackgroundSettings = {
transparent: true,
color: '#000000',
};
function getDefaultLightingSettings(
surface: HalftoneMaterialSurface,
): HalftoneLightingSettings {
return surface === 'glass'
? DEFAULT_GLASS_LIGHTING_SETTINGS
: DEFAULT_SOLID_LIGHTING_SETTINGS;
}
function getDefaultBackgroundSettings(
surface: HalftoneMaterialSurface,
): HalftoneBackgroundSettings {
return surface === 'glass'
? DEFAULT_GLASS_BACKGROUND_SETTINGS
: DEFAULT_SOLID_BACKGROUND_SETTINGS;
}
export const DEFAULT_SOLID_ANIMATION_SETTINGS: HalftoneAnimationSettings = {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverHalftoneEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 4,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.2,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
};
export const DEFAULT_GLASS_ANIMATION_SETTINGS: HalftoneAnimationSettings = {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverHalftoneEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
};
function getDefaultAnimationSettings(
surface: HalftoneMaterialSurface,
): HalftoneAnimationSettings {
return surface === 'glass'
? DEFAULT_GLASS_ANIMATION_SETTINGS
: DEFAULT_SOLID_ANIMATION_SETTINGS;
}
export const LEGACY_HALFTONE_SETTING_KEYS = [
'numRows',
'contrast',
@@ -255,103 +441,113 @@ function normalizeHalftoneEffectSettings(
};
}
function normalizeMaterialSettings(
settings?: Partial<HalftoneMaterialSettings>,
): HalftoneMaterialSettings {
const surface = settings?.surface === 'glass' ? 'glass' : 'solid';
const defaults =
surface === 'glass'
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS;
return {
surface,
color:
typeof settings?.color === 'string' ? settings.color : defaults.color,
roughness:
typeof settings?.roughness === 'number'
? settings.roughness
: defaults.roughness,
metalness:
typeof settings?.metalness === 'number'
? settings.metalness
: defaults.metalness,
thickness:
typeof settings?.thickness === 'number'
? settings.thickness
: defaults.thickness,
refraction:
typeof settings?.refraction === 'number'
? settings.refraction
: defaults.refraction,
environmentPower:
typeof settings?.environmentPower === 'number'
? settings.environmentPower
: defaults.environmentPower,
};
}
export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'shape' as HalftoneSourceMode,
shapeKey: 'torusKnot',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
},
lighting: { ...DEFAULT_SOLID_LIGHTING_SETTINGS },
material: {
roughness: 0.42,
metalness: 0.16,
...DEFAULT_SOLID_MATERIAL_SETTINGS,
},
halftone: DEFAULT_SHAPE_HALFTONE_SETTINGS,
background: {
transparent: true,
color: '#ffffff',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 4,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.2,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
background: { ...DEFAULT_SOLID_BACKGROUND_SETTINGS },
animation: { ...DEFAULT_SOLID_ANIMATION_SETTINGS },
};
const LEGACY_GLASS_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'glass',
color: '#7d7d7d',
roughness: 0.1,
metalness: 0.1,
thickness: 150,
refraction: 2,
environmentPower: 5,
};
function materialMatches(
value: Partial<HalftoneMaterialSettings> | undefined,
target: HalftoneMaterialSettings,
) {
return (
value?.surface === target.surface &&
value?.color === target.color &&
value?.roughness === target.roughness &&
value?.metalness === target.metalness &&
value?.thickness === target.thickness &&
value?.refraction === target.refraction &&
value?.environmentPower === target.environmentPower
);
}
export function normalizeHalftoneStudioSettings(
settings?: Partial<HalftoneStudioSettings>,
): HalftoneStudioSettings {
const sourceMode =
settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode;
const mergedMaterial = normalizeMaterialSettings(settings?.material);
const material =
mergedMaterial.surface === 'glass' &&
materialMatches(settings?.material, LEGACY_GLASS_MATERIAL_SETTINGS)
? { ...DEFAULT_GLASS_MATERIAL_SETTINGS }
: mergedMaterial;
const lightingDefaults = getDefaultLightingSettings(material.surface);
const backgroundDefaults = getDefaultBackgroundSettings(material.surface);
const animationDefaults = getDefaultAnimationSettings(material.surface);
return {
...DEFAULT_HALFTONE_SETTINGS,
...settings,
sourceMode,
lighting: {
...DEFAULT_HALFTONE_SETTINGS.lighting,
...lightingDefaults,
...settings?.lighting,
},
material: {
...DEFAULT_HALFTONE_SETTINGS.material,
...settings?.material,
},
material,
halftone: normalizeHalftoneEffectSettings(
getDefaultHalftoneSettings(sourceMode),
settings?.halftone,
),
background: {
...DEFAULT_HALFTONE_SETTINGS.background,
...backgroundDefaults,
...settings?.background,
},
animation: {
...DEFAULT_HALFTONE_SETTINGS.animation,
...animationDefaults,
...settings?.animation,
},
};
@@ -1,6 +1,6 @@
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
export const HERO_DATA = {
heading: [
{ text: 'Become a ', fontFamily: 'serif' },
{ text: 'Twenty Partner', fontFamily: 'sans' },
@@ -8,6 +8,4 @@ export const HERO_DATA: HeroIllustrationDataType = {
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
illustration:
'https://app.endlesstools.io/embed/1c6c8259-3276-4cf2-84d8-6b7e87e7ec95',
};
} satisfies HeroBaseDataType;
@@ -1,6 +1,6 @@
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
export const HERO_DATA = {
heading: [
{ text: 'The CRM that moves ', fontFamily: 'serif' },
{ text: 'as fast as you do', fontFamily: 'sans' },
@@ -8,5 +8,4 @@ export const HERO_DATA: HeroIllustrationDataType = {
body: {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
},
illustration: 'heroProduct',
};
} satisfies HeroBaseDataType;
@@ -1,5 +0,0 @@
import { type HeroBaseDataType } from '@/sections/Hero/types/HeroBaseData';
export type HeroIllustrationDataType = HeroBaseDataType & {
illustration: string;
};
@@ -31,5 +31,4 @@ export type {
HeroTablePageDefinition,
HeroVisualType,
} from './HeroHomeData';
export type { HeroIllustrationDataType } from './HeroIllustrationData';
export type { HeroWhyTwentyDataType } from './HeroWhyTwentyData';