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:
committed by
GitHub
parent
fda5aba9ec
commit
d9d3648baa
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,
|
||||
|
||||
+78
-6
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user