mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
halftone generator v1 + new 3d shapes effect start (#19539)
## Summary - refresh the website halftone studio with new rendering controls, UI updates, and export/runtime plumbing - improve halftone output quality by opening highlights, grouping shadows, and reducing visible row artifacts - update home/problem illustration assets, 3D model references, and related illustration components to use the new visuals - adjust footer and layout-related website wiring to support the updated illustration experience ## Testing - Not run (not requested) --------- Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
parent
63c407e2f7
commit
e03ac126ab
32 changed files with 16458 additions and 1197 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 533 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 970 B |
|
|
@ -46,7 +46,7 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
|
|||
role: { text: 'Head of Engineering' },
|
||||
company: { text: 'Mid-Market Fintech' },
|
||||
},
|
||||
illustration: 'lock',
|
||||
illustration: 'wheelx',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type FooterVisibilityGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function FooterVisibilityGate({
|
||||
children,
|
||||
}: FooterVisibilityGateProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === '/halftone') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -27,6 +27,11 @@ export const FOOTER_DATA: FooterDataType = {
|
|||
{ label: 'FAQ', href: '#', external: false },
|
||||
{ label: 'Support', href: '#', external: false },
|
||||
{ label: 'Release Notes', href: '/release-notes', external: false },
|
||||
{
|
||||
label: 'Halftone generator',
|
||||
href: '/halftone',
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
'use client';
|
||||
|
||||
import { AnimationsTab } from './controls/AnimationsTab';
|
||||
import { DesignTab } from './controls/DesignTab';
|
||||
import { ExportTab } from './controls/ExportTab';
|
||||
import { PanelShell, TabButton, TabsBar } from './controls/controls-ui';
|
||||
|
||||
type HalftoneTabId = 'design' | 'animations' | 'export';
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
type HalftoneModelLoader = 'fbx' | 'glb';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
interface HalftoneGeometrySpec {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'builtin' | 'imported';
|
||||
loader?: HalftoneModelLoader;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
extensions?: readonly string[];
|
||||
userProvided?: boolean;
|
||||
}
|
||||
|
||||
type ControlsPanelProps = {
|
||||
activeTab: HalftoneTabId;
|
||||
defaultExportName: string;
|
||||
exportName: string;
|
||||
imageFileName: string | null;
|
||||
onAnimationSettingsChange: (
|
||||
value: Partial<HalftoneStudioSettings['animation']>,
|
||||
) => void;
|
||||
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
|
||||
onDashColorChange: (value: string) => void;
|
||||
onExportHalftoneImage: (width: number, height: number) => void;
|
||||
onExportHtml: () => void;
|
||||
onExportNameChange: (value: string) => void;
|
||||
onExportReact: () => void;
|
||||
onImportPreset: () => void;
|
||||
onHalftoneChange: (
|
||||
value: Partial<HalftoneStudioSettings['halftone']>,
|
||||
) => void;
|
||||
onLightingChange: (
|
||||
value: Partial<HalftoneStudioSettings['lighting']>,
|
||||
) => void;
|
||||
onMaterialChange: (
|
||||
value: Partial<HalftoneStudioSettings['material']>,
|
||||
) => void;
|
||||
onPreviewDistanceChange: (value: number) => void;
|
||||
onShapeChange: (value: string) => void;
|
||||
onSourceModeChange: (value: HalftoneSourceMode) => void;
|
||||
onTabChange: (value: HalftoneTabId) => void;
|
||||
onUploadImage: () => void;
|
||||
onUploadModel: () => void;
|
||||
previewDistance: number;
|
||||
selectedShape: HalftoneGeometrySpec | undefined;
|
||||
settings: HalftoneStudioSettings;
|
||||
shapeOptions: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
const TABS: HalftoneTabId[] = ['design', 'animations', 'export'];
|
||||
|
||||
const TAB_LABELS: Record<HalftoneTabId, string> = {
|
||||
design: 'Design',
|
||||
animations: 'Animations',
|
||||
export: 'Export',
|
||||
};
|
||||
|
||||
export function ControlsPanel({
|
||||
activeTab,
|
||||
defaultExportName,
|
||||
exportName,
|
||||
imageFileName,
|
||||
onAnimationSettingsChange,
|
||||
onBackgroundChange,
|
||||
onDashColorChange,
|
||||
onExportHalftoneImage,
|
||||
onExportHtml,
|
||||
onExportNameChange,
|
||||
onExportReact,
|
||||
onImportPreset,
|
||||
onHalftoneChange,
|
||||
onLightingChange,
|
||||
onMaterialChange,
|
||||
onPreviewDistanceChange,
|
||||
onShapeChange,
|
||||
onSourceModeChange,
|
||||
onTabChange,
|
||||
onUploadImage,
|
||||
onUploadModel,
|
||||
previewDistance,
|
||||
selectedShape,
|
||||
settings,
|
||||
shapeOptions,
|
||||
}: ControlsPanelProps) {
|
||||
return (
|
||||
<PanelShell>
|
||||
<TabsBar>
|
||||
{TABS.map((tab) => (
|
||||
<TabButton
|
||||
$active={tab === activeTab}
|
||||
key={tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
type="button"
|
||||
>
|
||||
{TAB_LABELS[tab]}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabsBar>
|
||||
|
||||
{activeTab === 'design' ? (
|
||||
<DesignTab
|
||||
imageFileName={imageFileName}
|
||||
onBackgroundChange={onBackgroundChange}
|
||||
onDashColorChange={onDashColorChange}
|
||||
onHalftoneChange={onHalftoneChange}
|
||||
onLightingChange={onLightingChange}
|
||||
onMaterialChange={onMaterialChange}
|
||||
onPreviewDistanceChange={onPreviewDistanceChange}
|
||||
onShapeChange={onShapeChange}
|
||||
onSourceModeChange={onSourceModeChange}
|
||||
onUploadImage={onUploadImage}
|
||||
onUploadModel={onUploadModel}
|
||||
previewDistance={previewDistance}
|
||||
settings={settings}
|
||||
shapeOptions={shapeOptions}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'animations' ? (
|
||||
<AnimationsTab
|
||||
onAnimationSettingsChange={onAnimationSettingsChange}
|
||||
settings={settings}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'export' ? (
|
||||
<ExportTab
|
||||
defaultExportName={defaultExportName}
|
||||
exportName={exportName}
|
||||
onExportHalftoneImage={onExportHalftoneImage}
|
||||
onExportHtml={onExportHtml}
|
||||
onExportNameChange={onExportNameChange}
|
||||
onExportReact={onExportReact}
|
||||
onImportPreset={onImportPreset}
|
||||
selectedShape={selectedShape}
|
||||
settings={settings}
|
||||
/>
|
||||
) : null}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,824 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
formatAngle,
|
||||
formatDecimal,
|
||||
formatPercent,
|
||||
} from '@/app/halftone/_lib/formatters';
|
||||
import {
|
||||
ControlGrid,
|
||||
LabelWithTooltip,
|
||||
Section,
|
||||
SectionTitle,
|
||||
SectionToggleHeader,
|
||||
SelectControl,
|
||||
SliderControl,
|
||||
TabContent,
|
||||
ToggleControl,
|
||||
} from './controls-ui';
|
||||
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
type AnimationsTabProps = {
|
||||
onAnimationSettingsChange: (
|
||||
value: Partial<HalftoneStudioSettings['animation']>,
|
||||
) => void;
|
||||
settings: HalftoneStudioSettings;
|
||||
};
|
||||
|
||||
function effectLabel(label: string, description: string) {
|
||||
return <LabelWithTooltip description={description} label={label} />;
|
||||
}
|
||||
|
||||
export function AnimationsTab({
|
||||
onAnimationSettingsChange,
|
||||
settings,
|
||||
}: AnimationsTabProps) {
|
||||
const animation = settings.animation;
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
{isImageMode ? (
|
||||
<>
|
||||
<Section $first>
|
||||
<SectionToggleHeader
|
||||
checked={animation.hoverLightEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverLightEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Hover Light',
|
||||
'Turns the cursor into a moving light source so the halftone bars open up, tighten, and re-balance as you hover.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.hoverLightEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={2}
|
||||
min={0.1}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverLightIntensity: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.hoverLightIntensity}
|
||||
valueLabel={formatDecimal(animation.hoverLightIntensity, 2)}
|
||||
>
|
||||
Intensity
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.45}
|
||||
min={0.06}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverLightRadius: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.hoverLightRadius}
|
||||
valueLabel={formatDecimal(animation.hoverLightRadius, 2)}
|
||||
>
|
||||
Radius
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.dragFlowEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragFlowEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Drag Smear',
|
||||
'Click and drag to pull the halftone pattern through the pointer. The distortion trails your motion instead of sitting above the image.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.dragFlowEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={4}
|
||||
min={0.5}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragFlowStrength: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.1}
|
||||
value={animation.dragFlowStrength}
|
||||
valueLabel={formatDecimal(animation.dragFlowStrength, 1)}
|
||||
>
|
||||
Strength
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.5}
|
||||
min={0.08}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragFlowRadius: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.dragFlowRadius}
|
||||
valueLabel={formatDecimal(animation.dragFlowRadius, 2)}
|
||||
>
|
||||
Radius
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.25}
|
||||
min={0.02}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragFlowDecay: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.dragFlowDecay}
|
||||
valueLabel={formatDecimal(animation.dragFlowDecay, 2)}
|
||||
>
|
||||
Decay
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Section $first>
|
||||
<SectionTitle>Rotation</SectionTitle>
|
||||
<ControlGrid>
|
||||
<ToggleControl
|
||||
checked={animation.autoRotateEnabled}
|
||||
label={effectLabel(
|
||||
'Idle auto-rotate',
|
||||
'Continuously rotates the model on its own, with optional wobble layered on top.',
|
||||
)}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
autoRotateEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{animation.autoRotateEnabled ? (
|
||||
<>
|
||||
<SliderControl
|
||||
max={1.5}
|
||||
min={0.05}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
autoSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.autoSpeed}
|
||||
valueLabel={formatDecimal(animation.autoSpeed, 1)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={1.5}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
autoWobble: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.autoWobble}
|
||||
valueLabel={formatDecimal(animation.autoWobble, 1)}
|
||||
>
|
||||
Wobble
|
||||
</SliderControl>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ToggleControl
|
||||
checked={animation.rotateEnabled}
|
||||
label={effectLabel(
|
||||
'Rotation preset',
|
||||
'Applies a scripted motion path. Axis is simple spin, while the other presets combine multiple frequencies for more complex motion.',
|
||||
)}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
rotateEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{animation.rotateEnabled ? (
|
||||
<>
|
||||
<SelectControl
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
rotatePreset: event.target
|
||||
.value as HalftoneStudioSettings['animation']['rotatePreset'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ label: 'Axis rotate', value: 'axis' },
|
||||
{ label: 'Lissajous', value: 'lissajous' },
|
||||
{ label: 'Orbit', value: 'orbit' },
|
||||
{ label: 'Tumble', value: 'tumble' },
|
||||
]}
|
||||
value={animation.rotatePreset}
|
||||
>
|
||||
Pattern
|
||||
</SelectControl>
|
||||
{animation.rotatePreset === 'axis' ? (
|
||||
<SelectControl
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
rotateAxis: event.target
|
||||
.value as HalftoneStudioSettings['animation']['rotateAxis'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ label: 'Y (horizontal)', value: 'y' },
|
||||
{ label: '-Y (horizontal)', value: '-y' },
|
||||
{ label: 'X (vertical)', value: 'x' },
|
||||
{ label: '-X (vertical)', value: '-x' },
|
||||
{ label: 'Z (roll)', value: 'z' },
|
||||
{ label: '-Z (roll)', value: '-z' },
|
||||
{ label: 'X + Y (diagonal)', value: 'xy' },
|
||||
{ label: '-X + -Y (diagonal)', value: '-xy' },
|
||||
]}
|
||||
value={animation.rotateAxis}
|
||||
>
|
||||
Axis
|
||||
</SelectControl>
|
||||
) : null}
|
||||
<SliderControl
|
||||
max={4}
|
||||
min={0.1}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
rotateSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.1}
|
||||
value={animation.rotateSpeed}
|
||||
valueLabel={formatDecimal(animation.rotateSpeed, 1)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<ToggleControl
|
||||
checked={animation.rotatePingPong}
|
||||
label="Ping-pong"
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
rotatePingPong: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.floatEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
floatEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Float + Drift',
|
||||
'Adds a gentle vertical bob plus a small rotational drift so the object feels suspended instead of locked in place.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.floatEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={2}
|
||||
min={0.2}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
floatSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.floatSpeed}
|
||||
valueLabel={formatDecimal(animation.floatSpeed, 2)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.4}
|
||||
min={0.02}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
floatAmplitude: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.floatAmplitude}
|
||||
valueLabel={formatDecimal(animation.floatAmplitude, 2)}
|
||||
>
|
||||
Height
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={24}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
driftAmount: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={1}
|
||||
value={animation.driftAmount}
|
||||
valueLabel={formatAngle(animation.driftAmount)}
|
||||
>
|
||||
Drift
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.breatheEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
breatheEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Breathing Scale',
|
||||
'Pulses the model scale in and out by a small amount to make static shapes feel alive.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.breatheEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={2}
|
||||
min={0.2}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
breatheSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.breatheSpeed}
|
||||
valueLabel={formatDecimal(animation.breatheSpeed, 2)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.12}
|
||||
min={0.01}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
breatheAmount: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.005}
|
||||
value={animation.breatheAmount}
|
||||
valueLabel={formatPercent(animation.breatheAmount)}
|
||||
>
|
||||
Amount
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.lightSweepEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
lightSweepEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Light Sweep',
|
||||
'Animates the primary light angle and height so highlights travel across the halftone surface.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.lightSweepEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={2}
|
||||
min={0.2}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
lightSweepSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.lightSweepSpeed}
|
||||
valueLabel={formatDecimal(animation.lightSweepSpeed, 2)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={60}
|
||||
min={5}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
lightSweepRange: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={1}
|
||||
value={animation.lightSweepRange}
|
||||
valueLabel={formatAngle(animation.lightSweepRange)}
|
||||
>
|
||||
Angle
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={1.5}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
lightSweepHeightRange: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.05}
|
||||
value={animation.lightSweepHeightRange}
|
||||
valueLabel={formatDecimal(animation.lightSweepHeightRange, 2)}
|
||||
>
|
||||
Height
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.cameraParallaxEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
cameraParallaxEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Camera Parallax',
|
||||
'Moves the camera viewpoint with the cursor to create depth without directly rotating the object.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.cameraParallaxEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={0.8}
|
||||
min={0.05}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
cameraParallaxAmount: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.cameraParallaxAmount}
|
||||
valueLabel={formatDecimal(animation.cameraParallaxAmount, 2)}
|
||||
>
|
||||
Amount
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.2}
|
||||
min={0.02}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
cameraParallaxEase: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.cameraParallaxEase}
|
||||
valueLabel={formatDecimal(animation.cameraParallaxEase, 2)}
|
||||
>
|
||||
Easing
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.springReturnEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
springReturnEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Spring Return',
|
||||
'Changes the settling behavior so hover and drag interactions rebound and ease back like a spring instead of stopping flat.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.springReturnEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={0.35}
|
||||
min={0.05}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
springStrength: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.springStrength}
|
||||
valueLabel={formatDecimal(animation.springStrength, 2)}
|
||||
>
|
||||
Strength
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.92}
|
||||
min={0.4}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
springDamping: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.02}
|
||||
value={animation.springDamping}
|
||||
valueLabel={formatDecimal(animation.springDamping, 2)}
|
||||
>
|
||||
Damping
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.followHoverEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
followHoverEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Follow Mouse (Hover)',
|
||||
'Tilts the object toward the pointer while the cursor is over the canvas.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.followHoverEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={60}
|
||||
min={5}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverRange: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
value={animation.hoverRange}
|
||||
valueLabel={formatAngle(animation.hoverRange)}
|
||||
>
|
||||
Range
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.2}
|
||||
min={0.01}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverEase: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.hoverEase}
|
||||
valueLabel={formatDecimal(animation.hoverEase, 2)}
|
||||
>
|
||||
Easing
|
||||
</SliderControl>
|
||||
<ToggleControl
|
||||
checked={animation.hoverReturn}
|
||||
label="Return to center"
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
hoverReturn: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.followDragEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
followDragEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Follow Mouse (Drag)',
|
||||
'Lets you rotate the object directly by dragging, optionally continuing with momentum after release.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.followDragEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={0.02}
|
||||
min={0.002}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragSens: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.001}
|
||||
value={animation.dragSens}
|
||||
valueLabel={formatDecimal(animation.dragSens, 3)}
|
||||
>
|
||||
Sensitivity
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.2}
|
||||
min={0.01}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragFriction: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={animation.dragFriction}
|
||||
valueLabel={formatDecimal(animation.dragFriction, 2)}
|
||||
>
|
||||
Friction
|
||||
</SliderControl>
|
||||
<ToggleControl
|
||||
checked={animation.dragMomentum}
|
||||
label="Momentum"
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
dragMomentum: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={animation.waveEnabled}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
waveEnabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
preserveCase
|
||||
>
|
||||
{effectLabel(
|
||||
'Wave',
|
||||
'Makes the halftone rows undulate horizontally for a liquid, organic feel.',
|
||||
)}
|
||||
</SectionToggleHeader>
|
||||
{animation.waveEnabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={3}
|
||||
min={0.1}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
waveSpeed: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.1}
|
||||
value={animation.waveSpeed}
|
||||
valueLabel={formatDecimal(animation.waveSpeed, 1)}
|
||||
>
|
||||
Speed
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={8}
|
||||
min={0.5}
|
||||
onChange={(event) =>
|
||||
onAnimationSettingsChange({
|
||||
waveAmount: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.1}
|
||||
value={animation.waveAmount}
|
||||
valueLabel={formatDecimal(animation.waveAmount, 1)}
|
||||
>
|
||||
Amount
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
'use client';
|
||||
|
||||
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
|
||||
import {
|
||||
ColorControlLabel,
|
||||
ColorControlRow,
|
||||
ColorField,
|
||||
ControlGrid,
|
||||
Section,
|
||||
SectionTitle,
|
||||
SectionToggleHeader,
|
||||
SelectControl,
|
||||
SelectInput,
|
||||
ShapeRow,
|
||||
SliderControl,
|
||||
TabContent,
|
||||
UploadButton,
|
||||
ValueDisplay,
|
||||
} from './controls-ui';
|
||||
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
type DesignTabProps = {
|
||||
imageFileName: string | null;
|
||||
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
|
||||
onDashColorChange: (value: string) => void;
|
||||
onHalftoneChange: (
|
||||
value: Partial<HalftoneStudioSettings['halftone']>,
|
||||
) => void;
|
||||
onLightingChange: (
|
||||
value: Partial<HalftoneStudioSettings['lighting']>,
|
||||
) => void;
|
||||
onMaterialChange: (
|
||||
value: Partial<HalftoneStudioSettings['material']>,
|
||||
) => void;
|
||||
onPreviewDistanceChange: (value: number) => void;
|
||||
onShapeChange: (value: string) => void;
|
||||
onSourceModeChange: (value: HalftoneSourceMode) => void;
|
||||
onUploadImage: () => void;
|
||||
onUploadModel: () => void;
|
||||
previewDistance: number;
|
||||
settings: HalftoneStudioSettings;
|
||||
shapeOptions: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
export function DesignTab({
|
||||
imageFileName,
|
||||
onBackgroundChange,
|
||||
onDashColorChange,
|
||||
onHalftoneChange,
|
||||
onLightingChange,
|
||||
onMaterialChange,
|
||||
onPreviewDistanceChange,
|
||||
onShapeChange,
|
||||
onSourceModeChange,
|
||||
onUploadImage,
|
||||
onUploadModel,
|
||||
previewDistance,
|
||||
settings,
|
||||
shapeOptions,
|
||||
}: DesignTabProps) {
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
<Section $first>
|
||||
<SectionTitle>Source</SectionTitle>
|
||||
<ControlGrid>
|
||||
<SelectControl
|
||||
onChange={(event) =>
|
||||
onSourceModeChange(event.target.value as HalftoneSourceMode)
|
||||
}
|
||||
options={[
|
||||
{ label: '3D Shape', value: 'shape' },
|
||||
{ label: 'Image', value: 'image' },
|
||||
]}
|
||||
value={settings.sourceMode}
|
||||
>
|
||||
Mode
|
||||
</SelectControl>
|
||||
|
||||
{isImageMode ? (
|
||||
<ShapeRow>
|
||||
<span>Image</span>
|
||||
<ValueDisplay title={imageFileName ?? 'twenty-logo.svg'}>
|
||||
{imageFileName ?? 'twenty-logo.svg'}
|
||||
</ValueDisplay>
|
||||
<UploadButton
|
||||
onClick={onUploadImage}
|
||||
title="Upload image (.png / .jpg / .webp)"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M7 9l5 -5l5 5" />
|
||||
<path d="M12 4l0 12" />
|
||||
</svg>
|
||||
</UploadButton>
|
||||
</ShapeRow>
|
||||
) : (
|
||||
<ShapeRow>
|
||||
<span>Shape</span>
|
||||
<SelectInput
|
||||
onChange={(event) => onShapeChange(event.target.value)}
|
||||
value={settings.shapeKey}
|
||||
>
|
||||
{shapeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</SelectInput>
|
||||
<UploadButton
|
||||
onClick={onUploadModel}
|
||||
title="Upload model (.fbx / .glb)"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M7 9l5 -5l5 5" />
|
||||
<path d="M12 4l0 12" />
|
||||
</svg>
|
||||
</UploadButton>
|
||||
</ShapeRow>
|
||||
)}
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
|
||||
{isImageMode ? (
|
||||
<>
|
||||
<Section>
|
||||
<SectionTitle>Visualization</SectionTitle>
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={8}
|
||||
min={4}
|
||||
onChange={(event) =>
|
||||
onPreviewDistanceChange(Number(event.target.value))
|
||||
}
|
||||
step={0.1}
|
||||
value={previewDistance}
|
||||
valueLabel={formatDecimal(previewDistance, 1)}
|
||||
>
|
||||
Distance
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Section>
|
||||
<SectionTitle>Visualization</SectionTitle>
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={8}
|
||||
min={4}
|
||||
onChange={(event) =>
|
||||
onPreviewDistanceChange(Number(event.target.value))
|
||||
}
|
||||
step={0.1}
|
||||
value={previewDistance}
|
||||
valueLabel={formatDecimal(previewDistance, 1)}
|
||||
>
|
||||
Distance
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Lighting</SectionTitle>
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={4}
|
||||
min={0.5}
|
||||
onChange={(event) =>
|
||||
onLightingChange({ intensity: Number(event.target.value) })
|
||||
}
|
||||
step={0.1}
|
||||
value={settings.lighting.intensity}
|
||||
valueLabel={formatDecimal(settings.lighting.intensity, 1)}
|
||||
>
|
||||
Light
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={1.5}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onLightingChange({
|
||||
fillIntensity: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.lighting.fillIntensity}
|
||||
valueLabel={formatDecimal(settings.lighting.fillIntensity)}
|
||||
>
|
||||
Fill
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.3}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onLightingChange({
|
||||
ambientIntensity: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.lighting.ambientIntensity}
|
||||
valueLabel={formatDecimal(settings.lighting.ambientIntensity)}
|
||||
>
|
||||
Ambient
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={360}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onLightingChange({ angleDegrees: Number(event.target.value) })
|
||||
}
|
||||
value={settings.lighting.angleDegrees}
|
||||
valueLabel={formatAngle(settings.lighting.angleDegrees)}
|
||||
>
|
||||
Angle
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={8}
|
||||
min={-4}
|
||||
onChange={(event) =>
|
||||
onLightingChange({ height: Number(event.target.value) })
|
||||
}
|
||||
step={0.1}
|
||||
value={settings.lighting.height}
|
||||
valueLabel={formatDecimal(settings.lighting.height, 1)}
|
||||
>
|
||||
Height
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Material</SectionTitle>
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onMaterialChange({ roughness: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.material.roughness}
|
||||
valueLabel={formatDecimal(settings.material.roughness)}
|
||||
>
|
||||
Roughness
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onMaterialChange({ metalness: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.material.metalness}
|
||||
valueLabel={formatDecimal(settings.material.metalness)}
|
||||
>
|
||||
Metalness
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<SectionToggleHeader
|
||||
checked={settings.halftone.enabled}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ enabled: event.target.checked })
|
||||
}
|
||||
>
|
||||
Halftone
|
||||
</SectionToggleHeader>
|
||||
{settings.halftone.enabled ? (
|
||||
<ControlGrid>
|
||||
<SliderControl
|
||||
max={150}
|
||||
min={30}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ numRows: Number(event.target.value) })
|
||||
}
|
||||
value={settings.halftone.numRows}
|
||||
valueLabel={String(settings.halftone.numRows)}
|
||||
>
|
||||
Rows
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={3}
|
||||
min={0.5}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ contrast: Number(event.target.value) })
|
||||
}
|
||||
step={0.1}
|
||||
value={settings.halftone.contrast}
|
||||
valueLabel={formatDecimal(settings.halftone.contrast, 1)}
|
||||
>
|
||||
Contrast
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={2.5}
|
||||
min={0.5}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ power: Number(event.target.value) })
|
||||
}
|
||||
step={0.1}
|
||||
value={settings.halftone.power}
|
||||
valueLabel={formatDecimal(settings.halftone.power, 1)}
|
||||
>
|
||||
Power
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={3}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ shading: Number(event.target.value) })
|
||||
}
|
||||
step={0.1}
|
||||
value={settings.halftone.shading}
|
||||
valueLabel={formatDecimal(settings.halftone.shading, 1)}
|
||||
>
|
||||
Shading
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.4}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ baseInk: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.halftone.baseInk}
|
||||
valueLabel={formatDecimal(settings.halftone.baseInk)}
|
||||
>
|
||||
Base Density
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.4}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ highlightOpen: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.halftone.highlightOpen}
|
||||
valueLabel={formatDecimal(settings.halftone.highlightOpen)}
|
||||
>
|
||||
Highlight Open
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({
|
||||
shadowGrouping: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.halftone.shadowGrouping}
|
||||
valueLabel={formatDecimal(settings.halftone.shadowGrouping)}
|
||||
>
|
||||
Shadow Group
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.48}
|
||||
min={0.1}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ maxBar: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.halftone.maxBar}
|
||||
valueLabel={formatDecimal(settings.halftone.maxBar)}
|
||||
>
|
||||
Thickness
|
||||
</SliderControl>
|
||||
<SliderControl
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(event) =>
|
||||
onHalftoneChange({ rowMerge: Number(event.target.value) })
|
||||
}
|
||||
step={0.01}
|
||||
value={settings.halftone.rowMerge}
|
||||
valueLabel={formatDecimal(settings.halftone.rowMerge)}
|
||||
>
|
||||
Row Merge
|
||||
</SliderControl>
|
||||
</ControlGrid>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Colors</SectionTitle>
|
||||
<ControlGrid>
|
||||
<ColorControlRow>
|
||||
<ColorControlLabel>Dash color</ColorControlLabel>
|
||||
<ColorField
|
||||
ariaLabel="Dash color"
|
||||
onChange={onDashColorChange}
|
||||
value={settings.halftone.dashColor}
|
||||
/>
|
||||
</ColorControlRow>
|
||||
<ColorControlRow>
|
||||
<ColorControlLabel>Background</ColorControlLabel>
|
||||
<ColorField
|
||||
ariaLabel="Background color"
|
||||
onChange={(value) => onBackgroundChange({ color: value })}
|
||||
value={settings.background.color}
|
||||
/>
|
||||
</ColorControlRow>
|
||||
</ControlGrid>
|
||||
</Section>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
'use client';
|
||||
|
||||
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ExportButton,
|
||||
ExportNameInput,
|
||||
ExportNote,
|
||||
ExportPreview,
|
||||
Section,
|
||||
SectionTitle,
|
||||
SelectControl,
|
||||
SmallBody,
|
||||
TabContent,
|
||||
} from './controls-ui';
|
||||
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
type HalftoneModelLoader = 'fbx' | 'glb';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
interface HalftoneGeometrySpec {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'builtin' | 'imported';
|
||||
loader?: HalftoneModelLoader;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
extensions?: readonly string[];
|
||||
userProvided?: boolean;
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ label: '720p (1280 × 720)', value: '1280x720' },
|
||||
{ label: '1080p (1920 × 1080)', value: '1920x1080' },
|
||||
{ label: '2K (2560 × 1440)', value: '2560x1440' },
|
||||
{ label: '4K (3840 × 2160)', value: '3840x2160' },
|
||||
];
|
||||
|
||||
type ExportTabProps = {
|
||||
defaultExportName: string;
|
||||
exportName: string;
|
||||
onExportHalftoneImage: (width: number, height: number) => void;
|
||||
onExportHtml: () => void;
|
||||
onExportNameChange: (value: string) => void;
|
||||
onExportReact: () => void;
|
||||
onImportPreset: () => void;
|
||||
selectedShape: HalftoneGeometrySpec | undefined;
|
||||
settings: HalftoneStudioSettings;
|
||||
};
|
||||
|
||||
export function ExportTab({
|
||||
defaultExportName,
|
||||
exportName,
|
||||
onExportHalftoneImage,
|
||||
onExportHtml,
|
||||
onExportNameChange,
|
||||
onExportReact,
|
||||
onImportPreset,
|
||||
selectedShape,
|
||||
settings,
|
||||
}: ExportTabProps) {
|
||||
const [resolution, setResolution] = useState('1920x1080');
|
||||
const isImageMode = settings.sourceMode === 'image';
|
||||
const animationLabel = formatAnimationName(
|
||||
settings.animation,
|
||||
settings.sourceMode,
|
||||
);
|
||||
|
||||
const componentName = exportName || defaultExportName;
|
||||
|
||||
const handleDownloadHalftoneImage = () => {
|
||||
const [widthStr, heightStr] = resolution.split('x');
|
||||
const width = parseInt(widthStr, 10);
|
||||
const height = parseInt(heightStr, 10);
|
||||
onExportHalftoneImage(width, height);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
<Section $first>
|
||||
<SectionTitle>Download Image</SectionTitle>
|
||||
<SmallBody>
|
||||
Downloads a PNG snapshot of the current halftone effect at the selected
|
||||
resolution.
|
||||
</SmallBody>
|
||||
|
||||
<SelectControl
|
||||
onChange={(event) => setResolution(event.target.value)}
|
||||
options={RESOLUTION_OPTIONS}
|
||||
value={resolution}
|
||||
>
|
||||
Resolution
|
||||
</SelectControl>
|
||||
|
||||
<ExportButton
|
||||
onClick={handleDownloadHalftoneImage}
|
||||
style={{ marginTop: 12 }}
|
||||
type="button"
|
||||
>
|
||||
Download Halftone Image
|
||||
</ExportButton>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Export React Component</SectionTitle>
|
||||
<SmallBody>
|
||||
Downloads a self-contained React component with the current design and
|
||||
animation settings baked in. Requires <code>three</code>.
|
||||
</SmallBody>
|
||||
|
||||
<ExportNameInput
|
||||
onChange={(event) => onExportNameChange(event.target.value)}
|
||||
placeholder={defaultExportName}
|
||||
type="text"
|
||||
value={exportName}
|
||||
/>
|
||||
|
||||
<ExportPreview>
|
||||
<div>
|
||||
<span
|
||||
style={{ color: '#9d90fa' }}
|
||||
>{`// ${componentName}.tsx`}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{'// Auto-generated from current settings'}
|
||||
</span>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<span style={{ color: '#e0a856' }}>import</span>{' '}
|
||||
<span style={{ color: '#8bc58b' }}>{componentName}</span>{' '}
|
||||
<span style={{ color: '#e0a856' }}>from</span>{' '}
|
||||
<span style={{ color: '#8bc58b' }}>{`'./${componentName}'`}</span>
|
||||
</div>
|
||||
<br />
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Shape: ${selectedShape?.key ?? settings.shapeKey}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Animation: ${animationLabel}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Dash color: ${settings.halftone.dashColor}`}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
|
||||
{`// Rows: ${settings.halftone.numRows}`}
|
||||
</div>
|
||||
</ExportPreview>
|
||||
|
||||
<ExportButton onClick={onExportReact} type="button">
|
||||
Download React Component
|
||||
</ExportButton>
|
||||
<ExportButton onClick={onExportHtml} type="button">
|
||||
Download Standalone HTML
|
||||
</ExportButton>
|
||||
|
||||
<ExportNote>
|
||||
{isImageMode
|
||||
? 'The original image is downloaded alongside the export so the halftone effect can be rendered on top of it.'
|
||||
: selectedShape?.kind === 'imported'
|
||||
? 'The standalone HTML export includes the current lighting, material, halftone, and animation settings, and embeds the uploaded model directly in the HTML file.'
|
||||
: 'The standalone HTML export includes the current lighting, material, halftone, and animation settings.'}
|
||||
</ExportNote>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>Import Preset</SectionTitle>
|
||||
<SmallBody>
|
||||
Loads a previously exported <code>.tsx</code> or <code>.html</code>{' '}
|
||||
halftone preset. If that preset depends on a separate image or model
|
||||
file, select that asset in the picker too.
|
||||
</SmallBody>
|
||||
|
||||
<ExportButton onClick={onImportPreset} type="button">
|
||||
Import Exported Preset
|
||||
</ExportButton>
|
||||
</Section>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
2598
packages/twenty-website-new/src/app/halftone/_lib/exporters.ts
Normal file
2598
packages/twenty-website-new/src/app/halftone/_lib/exporters.ts
Normal file
File diff suppressed because it is too large
Load diff
108
packages/twenty-website-new/src/app/halftone/_lib/formatters.ts
Normal file
108
packages/twenty-website-new/src/app/halftone/_lib/formatters.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
export function formatAngle(value: number) {
|
||||
return `${value}°`;
|
||||
}
|
||||
|
||||
export function formatDecimal(value: number, digits = 2) {
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, digits = 0) {
|
||||
return `${(value * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function formatAnimationName(animation: {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
rotatePreset: string;
|
||||
springReturnEnabled: boolean;
|
||||
}, sourceMode: 'shape' | 'image') {
|
||||
const activeModes: string[] = [];
|
||||
|
||||
if (sourceMode === 'image') {
|
||||
if (animation.hoverLightEnabled) {
|
||||
activeModes.push('hoverLight');
|
||||
}
|
||||
|
||||
if (animation.dragFlowEnabled) {
|
||||
activeModes.push('dragSmear');
|
||||
}
|
||||
|
||||
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
|
||||
}
|
||||
|
||||
if (animation.autoRotateEnabled) {
|
||||
activeModes.push('autoRotate');
|
||||
}
|
||||
|
||||
if (animation.floatEnabled) {
|
||||
activeModes.push('float');
|
||||
}
|
||||
|
||||
if (animation.breatheEnabled) {
|
||||
activeModes.push('breathe');
|
||||
}
|
||||
|
||||
if (animation.followHoverEnabled) {
|
||||
activeModes.push('followHover');
|
||||
}
|
||||
|
||||
if (animation.followDragEnabled) {
|
||||
activeModes.push('followDrag');
|
||||
}
|
||||
|
||||
if (animation.rotateEnabled) {
|
||||
activeModes.push(
|
||||
animation.rotatePreset === 'axis'
|
||||
? 'rotate'
|
||||
: `rotate(${animation.rotatePreset})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (animation.lightSweepEnabled) {
|
||||
activeModes.push('lightSweep');
|
||||
}
|
||||
|
||||
if (animation.cameraParallaxEnabled) {
|
||||
activeModes.push('cameraParallax');
|
||||
}
|
||||
|
||||
if (animation.springReturnEnabled) {
|
||||
activeModes.push('spring');
|
||||
}
|
||||
|
||||
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
|
||||
}
|
||||
|
||||
export function getBackgroundTone(background: {
|
||||
color: string;
|
||||
transparent: boolean;
|
||||
}) {
|
||||
if (background.transparent) {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
const normalized = background.color.replace('#', '');
|
||||
const expanded =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((character) => `${character}${character}`)
|
||||
.join('')
|
||||
: normalized;
|
||||
|
||||
const value = Number.parseInt(expanded, 16);
|
||||
const red = (value >> 16) & 255;
|
||||
const green = (value >> 8) & 255;
|
||||
const blue = value & 255;
|
||||
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
|
||||
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
|
|
@ -0,0 +1,737 @@
|
|||
import * as THREE from 'three';
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
type HalftoneModelLoader = 'fbx' | 'glb';
|
||||
|
||||
interface HalftoneGeometrySpec {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'builtin' | 'imported';
|
||||
loader?: HalftoneModelLoader;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
extensions?: readonly string[];
|
||||
userProvided?: boolean;
|
||||
}
|
||||
|
||||
type GeometryCacheEntry = THREE.BufferGeometry | Promise<THREE.BufferGeometry>;
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position;
|
||||
const normal = geometry.attributes.normal;
|
||||
const uv = geometry.attributes.uv ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 1.6 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
|
||||
root.updateMatrixWorld(true);
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
function parseFbxGeometry(
|
||||
buffer: ArrayBuffer,
|
||||
resourcePath: string,
|
||||
label: string,
|
||||
) {
|
||||
const originalWarn = console.warn;
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalWarn(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
const root = new FBXLoader(createLoadingManager()).parse(
|
||||
buffer,
|
||||
resourcePath,
|
||||
);
|
||||
|
||||
return extractMergedGeometry(
|
||||
root,
|
||||
`${label} did not contain any mesh geometry.`,
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGlbGeometry(
|
||||
buffer: ArrayBuffer,
|
||||
resourcePath: string,
|
||||
label: string,
|
||||
) {
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
resourcePath,
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(
|
||||
extractMergedGeometry(
|
||||
gltf.scene,
|
||||
`${label} did not contain any mesh geometry.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
}).finally(() => {
|
||||
dracoLoader.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImportedGeometry(
|
||||
loader: HalftoneModelLoader,
|
||||
file: File,
|
||||
label: string,
|
||||
) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
function makePolarShape(
|
||||
radiusFunction: (angle: number) => number,
|
||||
segments = 320,
|
||||
) {
|
||||
const shape = new THREE.Shape();
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
|
||||
const angle = (segmentIndex / segments) * Math.PI * 2;
|
||||
const radius = radiusFunction(angle);
|
||||
const x = Math.cos(angle) * radius;
|
||||
const y = Math.sin(angle) * radius;
|
||||
|
||||
if (segmentIndex === 0) {
|
||||
shape.moveTo(x, y);
|
||||
} else {
|
||||
shape.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
function makeReliefGeometry(
|
||||
shape: THREE.Shape,
|
||||
options: {
|
||||
depth?: number;
|
||||
bevelSize?: number;
|
||||
bevelThickness?: number;
|
||||
bevelSegments?: number;
|
||||
waves?: number;
|
||||
waveDepth?: number;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
bevelSegments = 8,
|
||||
bevelSize = 0.08,
|
||||
bevelThickness = 0.1,
|
||||
depth = 0.58,
|
||||
waveDepth = 0.016,
|
||||
waves = 8,
|
||||
} = options;
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth,
|
||||
steps: 2,
|
||||
bevelEnabled: true,
|
||||
bevelThickness,
|
||||
bevelSize,
|
||||
bevelSegments,
|
||||
curveSegments: 96,
|
||||
});
|
||||
|
||||
geometry.center();
|
||||
|
||||
const position = geometry.attributes.position;
|
||||
let maxRadius = 0;
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
|
||||
maxRadius = Math.max(
|
||||
maxRadius,
|
||||
Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)),
|
||||
);
|
||||
}
|
||||
|
||||
const fullDepth = depth + bevelThickness * 2;
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) {
|
||||
const x = position.getX(vertexIndex);
|
||||
const y = position.getY(vertexIndex);
|
||||
const z = position.getZ(vertexIndex);
|
||||
const radius = Math.hypot(x, y) / maxRadius;
|
||||
const angle = Math.atan2(y, x);
|
||||
const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5));
|
||||
const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2));
|
||||
const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2));
|
||||
const wave =
|
||||
Math.cos(angle * waves) *
|
||||
Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) *
|
||||
waveDepth;
|
||||
const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave);
|
||||
|
||||
position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief);
|
||||
}
|
||||
|
||||
position.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function makeArrowTarget() {
|
||||
const targetParts: THREE.BufferGeometry[] = [];
|
||||
const arrowParts: THREE.BufferGeometry[] = [];
|
||||
const baseRadius = 1.35;
|
||||
const baseDepth = 0.32;
|
||||
const bevel = 0.12;
|
||||
const points: THREE.Vector2[] = [];
|
||||
const segments = 16;
|
||||
|
||||
points.push(new THREE.Vector2(0, -baseDepth / 2));
|
||||
points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
|
||||
const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2);
|
||||
points.push(
|
||||
new THREE.Vector2(
|
||||
baseRadius - bevel + Math.cos(angle) * bevel,
|
||||
-baseDepth / 2 + bevel + Math.sin(angle) * bevel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel));
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
|
||||
const angle = (segmentIndex / segments) * (Math.PI / 2);
|
||||
points.push(
|
||||
new THREE.Vector2(
|
||||
baseRadius - bevel + Math.cos(angle) * bevel,
|
||||
baseDepth / 2 - bevel + Math.sin(angle) * bevel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
points.push(new THREE.Vector2(0, baseDepth / 2));
|
||||
|
||||
const disc = new THREE.LatheGeometry(points, 64);
|
||||
disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
targetParts.push(disc);
|
||||
|
||||
for (const radius of [0.45, 0.85, 1.22]) {
|
||||
const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64);
|
||||
ring.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04),
|
||||
);
|
||||
targetParts.push(ring);
|
||||
}
|
||||
|
||||
const bump = new THREE.SphereGeometry(
|
||||
0.32,
|
||||
32,
|
||||
24,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
0,
|
||||
Math.PI / 2,
|
||||
);
|
||||
bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));
|
||||
bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2));
|
||||
targetParts.push(bump);
|
||||
|
||||
const shaftLength = 1.5;
|
||||
const shaftRadius = 0.05;
|
||||
const shaft = new THREE.CylinderGeometry(
|
||||
shaftRadius,
|
||||
shaftRadius,
|
||||
shaftLength,
|
||||
10,
|
||||
1,
|
||||
);
|
||||
shaft.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0),
|
||||
);
|
||||
arrowParts.push(shaft);
|
||||
|
||||
const head = new THREE.ConeGeometry(0.12, 0.35, 10);
|
||||
head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0));
|
||||
arrowParts.push(head);
|
||||
|
||||
for (let finIndex = 0; finIndex < 3; finIndex += 1) {
|
||||
const finShape = new THREE.Shape();
|
||||
finShape.moveTo(0, 0);
|
||||
finShape.lineTo(0.22, 0.25);
|
||||
finShape.lineTo(0, 0.5);
|
||||
finShape.lineTo(0, 0);
|
||||
|
||||
const finGeometry = new THREE.ExtrudeGeometry(finShape, {
|
||||
depth: 0.012,
|
||||
bevelEnabled: false,
|
||||
});
|
||||
|
||||
finGeometry.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0.05, 0, -0.006),
|
||||
);
|
||||
finGeometry.applyMatrix4(
|
||||
new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3),
|
||||
);
|
||||
finGeometry.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0),
|
||||
);
|
||||
arrowParts.push(finGeometry);
|
||||
}
|
||||
|
||||
const nock = new THREE.SphereGeometry(0.065, 8, 8);
|
||||
nock.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0),
|
||||
);
|
||||
arrowParts.push(nock);
|
||||
|
||||
const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15);
|
||||
const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5);
|
||||
const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12);
|
||||
|
||||
for (const geometry of arrowParts) {
|
||||
geometry.applyMatrix4(aim);
|
||||
geometry.applyMatrix4(tilt);
|
||||
geometry.applyMatrix4(shift);
|
||||
}
|
||||
|
||||
const merged = mergeGeometries([...targetParts, ...arrowParts]);
|
||||
merged.computeVertexNormals();
|
||||
merged.computeBoundingSphere();
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function makeDollarCoin() {
|
||||
const parts: THREE.BufferGeometry[] = [];
|
||||
const baseRadius = 1.3;
|
||||
const baseDepth = 0.45;
|
||||
const bevel = 0.18;
|
||||
const points: THREE.Vector2[] = [];
|
||||
const segments = 20;
|
||||
|
||||
points.push(new THREE.Vector2(0, -baseDepth / 2));
|
||||
points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2));
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) {
|
||||
const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI;
|
||||
points.push(
|
||||
new THREE.Vector2(
|
||||
baseRadius - bevel + Math.cos(angle) * bevel,
|
||||
Math.sin(angle) * (baseDepth / 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2));
|
||||
points.push(new THREE.Vector2(0, baseDepth / 2));
|
||||
|
||||
const disc = new THREE.LatheGeometry(points, 64);
|
||||
disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
parts.push(disc);
|
||||
|
||||
const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
|
||||
frontRim.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01),
|
||||
);
|
||||
parts.push(frontRim);
|
||||
|
||||
const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64);
|
||||
backRim.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)),
|
||||
);
|
||||
parts.push(backRim);
|
||||
|
||||
const createDollarSign = () => {
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
const tubeRadius = 0.1;
|
||||
const curveRadius = 0.28;
|
||||
const verticalOffset = 0.22;
|
||||
|
||||
const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12);
|
||||
geometries.push(bar);
|
||||
|
||||
const topArc = new THREE.TorusGeometry(
|
||||
curveRadius,
|
||||
tubeRadius,
|
||||
16,
|
||||
32,
|
||||
Math.PI,
|
||||
);
|
||||
topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
|
||||
topArc.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0),
|
||||
);
|
||||
geometries.push(topArc);
|
||||
|
||||
const bottomArc = new THREE.TorusGeometry(
|
||||
curveRadius,
|
||||
tubeRadius,
|
||||
16,
|
||||
32,
|
||||
Math.PI,
|
||||
);
|
||||
bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2));
|
||||
bottomArc.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0),
|
||||
);
|
||||
geometries.push(bottomArc);
|
||||
|
||||
const topSerif = new THREE.CylinderGeometry(
|
||||
tubeRadius,
|
||||
tubeRadius,
|
||||
0.22,
|
||||
12,
|
||||
);
|
||||
topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
|
||||
topSerif.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(
|
||||
0.16,
|
||||
verticalOffset + curveRadius,
|
||||
0,
|
||||
),
|
||||
);
|
||||
geometries.push(topSerif);
|
||||
|
||||
const bottomSerif = new THREE.CylinderGeometry(
|
||||
tubeRadius,
|
||||
tubeRadius,
|
||||
0.22,
|
||||
12,
|
||||
);
|
||||
bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2));
|
||||
bottomSerif.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(
|
||||
-0.16,
|
||||
-verticalOffset - curveRadius,
|
||||
0,
|
||||
),
|
||||
);
|
||||
geometries.push(bottomSerif);
|
||||
|
||||
const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2);
|
||||
const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1);
|
||||
const diagonal = new THREE.CylinderGeometry(
|
||||
tubeRadius,
|
||||
tubeRadius,
|
||||
diagonalLength + 0.12,
|
||||
12,
|
||||
);
|
||||
diagonal.applyMatrix4(
|
||||
new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2),
|
||||
);
|
||||
geometries.push(diagonal);
|
||||
|
||||
return geometries;
|
||||
};
|
||||
|
||||
for (const geometry of createDollarSign()) {
|
||||
geometry.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01),
|
||||
);
|
||||
parts.push(geometry);
|
||||
}
|
||||
|
||||
for (const geometry of createDollarSign()) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI));
|
||||
geometry.applyMatrix4(
|
||||
new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)),
|
||||
);
|
||||
parts.push(geometry);
|
||||
}
|
||||
|
||||
const merged = mergeGeometries(parts);
|
||||
merged.computeVertexNormals();
|
||||
merged.computeBoundingSphere();
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
const BUILTIN_GEOMETRIES: Record<string, () => THREE.BufferGeometry> = {
|
||||
torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32),
|
||||
sphere: () => new THREE.SphereGeometry(1.4, 64, 64),
|
||||
torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100),
|
||||
icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4),
|
||||
box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6),
|
||||
cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10),
|
||||
cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10),
|
||||
octahedron: () => new THREE.OctahedronGeometry(1.5, 2),
|
||||
dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1),
|
||||
tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1),
|
||||
sunCoin: () =>
|
||||
makeReliefGeometry(
|
||||
makePolarShape(
|
||||
(angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5),
|
||||
),
|
||||
{ depth: 0.62, waves: 12, waveDepth: 0.018 },
|
||||
),
|
||||
lotusCoin: () =>
|
||||
makeReliefGeometry(
|
||||
makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)),
|
||||
{ depth: 0.64, waves: 8, waveDepth: 0.014 },
|
||||
),
|
||||
arrowTarget: () => makeArrowTarget(),
|
||||
dollarCoin: () => makeDollarCoin(),
|
||||
};
|
||||
|
||||
async function createGeometry(
|
||||
spec: HalftoneGeometrySpec,
|
||||
file: File | undefined,
|
||||
) {
|
||||
if (spec.kind === 'imported') {
|
||||
if (!file || !spec.loader) {
|
||||
throw new Error(`No file is available for ${spec.label}.`);
|
||||
}
|
||||
|
||||
return loadImportedGeometry(
|
||||
spec.loader as HalftoneModelLoader,
|
||||
file,
|
||||
spec.label,
|
||||
);
|
||||
}
|
||||
|
||||
const factory = BUILTIN_GEOMETRIES[spec.key];
|
||||
|
||||
if (!factory) {
|
||||
throw new Error(`Unsupported geometry: ${spec.key}.`);
|
||||
}
|
||||
|
||||
return factory();
|
||||
}
|
||||
|
||||
export function createFallbackGeometry() {
|
||||
return BUILTIN_GEOMETRIES.torusKnot();
|
||||
}
|
||||
|
||||
export async function getGeometryForSpec(
|
||||
spec: HalftoneGeometrySpec,
|
||||
file: File | undefined,
|
||||
cache: Map<string, GeometryCacheEntry>,
|
||||
) {
|
||||
const cached = cache.get(spec.key);
|
||||
|
||||
if (cached) {
|
||||
return cached instanceof Promise ? cached : Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const created = createGeometry(spec, file);
|
||||
|
||||
if (created instanceof Promise) {
|
||||
const pending = created
|
||||
.then((geometry) => {
|
||||
cache.set(spec.key, geometry);
|
||||
return geometry;
|
||||
})
|
||||
.catch((error) => {
|
||||
cache.delete(spec.key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
cache.set(spec.key, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
cache.set(spec.key, created);
|
||||
|
||||
return Promise.resolve(created);
|
||||
}
|
||||
|
||||
export function disposeGeometryCache(cache: Map<string, GeometryCacheEntry>) {
|
||||
for (const value of cache.values()) {
|
||||
if (!(value instanceof Promise)) {
|
||||
value.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
cache.clear();
|
||||
}
|
||||
482
packages/twenty-website-new/src/app/halftone/_lib/state.ts
Normal file
482
packages/twenty-website-new/src/app/halftone/_lib/state.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
type HalftoneTabId = 'design' | 'animations' | 'export';
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
type HalftoneModelLoader = 'fbx' | 'glb';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
interface HalftoneGeometrySpec {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'builtin' | 'imported';
|
||||
loader?: HalftoneModelLoader;
|
||||
filename?: string;
|
||||
description?: string;
|
||||
extensions?: readonly string[];
|
||||
userProvided?: boolean;
|
||||
}
|
||||
|
||||
interface HalftoneStudioState {
|
||||
activeTab: HalftoneTabId;
|
||||
geometrySpecs: HalftoneGeometrySpec[];
|
||||
importedFiles: Record<string, File>;
|
||||
settings: HalftoneStudioSettings;
|
||||
showHint: boolean;
|
||||
statusMessage: string;
|
||||
statusIsError: boolean;
|
||||
}
|
||||
|
||||
type HalftoneStudioAction =
|
||||
| { type: 'setTab'; value: HalftoneTabId }
|
||||
| { type: 'setSourceMode'; value: HalftoneSourceMode }
|
||||
| { type: 'setShapeKey'; value: string }
|
||||
| { type: 'replaceSettings'; value: HalftoneStudioSettings }
|
||||
| { type: 'patchLighting'; value: Partial<HalftoneLightingSettings> }
|
||||
| { type: 'patchMaterial'; value: Partial<HalftoneMaterialSettings> }
|
||||
| { type: 'patchHalftone'; value: Partial<HalftoneEffectSettings> }
|
||||
| { type: 'patchBackground'; value: Partial<HalftoneBackgroundSettings> }
|
||||
| { type: 'patchAnimation'; value: Partial<HalftoneAnimationSettings> }
|
||||
| {
|
||||
type: 'registerImportedFile';
|
||||
spec: HalftoneGeometrySpec;
|
||||
file: File;
|
||||
activate: boolean;
|
||||
}
|
||||
| { type: 'setImportedFile'; key: string; file: File }
|
||||
| { type: 'setStatus'; message: string; isError?: boolean }
|
||||
| { type: 'clearStatus' }
|
||||
| { type: 'hideHint' };
|
||||
|
||||
function upsertGeometrySpec(
|
||||
geometrySpecs: HalftoneGeometrySpec[],
|
||||
spec: HalftoneGeometrySpec,
|
||||
) {
|
||||
const existingIndex = geometrySpecs.findIndex(
|
||||
(geometrySpec) => geometrySpec.key === spec.key,
|
||||
);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return [...geometrySpecs, spec];
|
||||
}
|
||||
|
||||
return geometrySpecs.map((geometrySpec, index) =>
|
||||
index === existingIndex ? spec : geometrySpec,
|
||||
);
|
||||
}
|
||||
|
||||
export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [
|
||||
{ key: 'torusKnot', label: 'Torus Knot', kind: 'builtin' },
|
||||
{ key: 'sphere', label: 'Sphere', kind: 'builtin' },
|
||||
{ key: 'torus', label: 'Torus', kind: 'builtin' },
|
||||
{ key: 'icosahedron', label: 'Icosahedron', kind: 'builtin' },
|
||||
{ key: 'box', label: 'Box', kind: 'builtin' },
|
||||
{ key: 'cone', label: 'Cone', kind: 'builtin' },
|
||||
{ key: 'cylinder', label: 'Cylinder', kind: 'builtin' },
|
||||
{ key: 'octahedron', label: 'Octahedron', kind: 'builtin' },
|
||||
{ key: 'dodecahedron', label: 'Dodecahedron', kind: 'builtin' },
|
||||
{ key: 'tetrahedron', label: 'Tetrahedron', kind: 'builtin' },
|
||||
{ key: 'sunCoin', label: 'Sun Coin', kind: 'builtin' },
|
||||
{ key: 'lotusCoin', label: 'Lotus Coin', kind: 'builtin' },
|
||||
{ key: 'arrowTarget', label: 'Arrow Target', kind: 'builtin' },
|
||||
{ key: 'dollarCoin', label: 'Dollar Coin', kind: 'builtin' },
|
||||
{
|
||||
key: 'wheel',
|
||||
label: 'Wheel.fbx',
|
||||
kind: 'imported',
|
||||
loader: 'fbx',
|
||||
filename: 'Wheel.fbx',
|
||||
description: 'FBX model',
|
||||
extensions: ['.fbx'],
|
||||
},
|
||||
{
|
||||
key: 'twoGlb',
|
||||
label: 'two.glb',
|
||||
kind: 'imported',
|
||||
loader: 'glb',
|
||||
filename: 'two.glb',
|
||||
description: 'GLB model',
|
||||
extensions: ['.glb'],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SHAPE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
|
||||
enabled: true,
|
||||
numRows: 45,
|
||||
contrast: 1.3,
|
||||
power: 1.1,
|
||||
shading: 1.6,
|
||||
baseInk: 0.12,
|
||||
maxBar: 0.24,
|
||||
rowMerge: 0.06,
|
||||
cellRatio: 2.2,
|
||||
cutoff: 0.02,
|
||||
highlightOpen: 0.05,
|
||||
shadowGrouping: 0.18,
|
||||
shadowCrush: 0.14,
|
||||
dashColor: '#4A38F5',
|
||||
};
|
||||
|
||||
export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = {
|
||||
enabled: true,
|
||||
numRows: 80,
|
||||
contrast: 1.5,
|
||||
power: 1.2,
|
||||
shading: 0.8,
|
||||
baseInk: 0.06,
|
||||
maxBar: 0.32,
|
||||
rowMerge: 0.18,
|
||||
cellRatio: 2.0,
|
||||
cutoff: 0.02,
|
||||
highlightOpen: 0.14,
|
||||
shadowGrouping: 0.38,
|
||||
shadowCrush: 0.24,
|
||||
dashColor: '#4A38F5',
|
||||
};
|
||||
|
||||
export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) {
|
||||
return sourceMode === 'image'
|
||||
? DEFAULT_IMAGE_HALFTONE_SETTINGS
|
||||
: DEFAULT_SHAPE_HALFTONE_SETTINGS;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
material: {
|
||||
roughness: 0.42,
|
||||
metalness: 0.16,
|
||||
},
|
||||
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: 0.3,
|
||||
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: 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,
|
||||
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 function normalizeHalftoneStudioSettings(
|
||||
settings?: Partial<HalftoneStudioSettings>,
|
||||
): HalftoneStudioSettings {
|
||||
const sourceMode =
|
||||
settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode;
|
||||
|
||||
return {
|
||||
...DEFAULT_HALFTONE_SETTINGS,
|
||||
...settings,
|
||||
sourceMode,
|
||||
lighting: {
|
||||
...DEFAULT_HALFTONE_SETTINGS.lighting,
|
||||
...settings?.lighting,
|
||||
},
|
||||
material: {
|
||||
...DEFAULT_HALFTONE_SETTINGS.material,
|
||||
...settings?.material,
|
||||
},
|
||||
halftone: {
|
||||
...getDefaultHalftoneSettings(sourceMode),
|
||||
...settings?.halftone,
|
||||
},
|
||||
background: {
|
||||
...DEFAULT_HALFTONE_SETTINGS.background,
|
||||
...settings?.background,
|
||||
},
|
||||
animation: {
|
||||
...DEFAULT_HALFTONE_SETTINGS.animation,
|
||||
...settings?.animation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialHalftoneStudioState(): HalftoneStudioState {
|
||||
return {
|
||||
activeTab: 'design',
|
||||
geometrySpecs: [...DEFAULT_GEOMETRY_SPECS],
|
||||
importedFiles: {},
|
||||
settings: normalizeHalftoneStudioSettings(DEFAULT_HALFTONE_SETTINGS),
|
||||
showHint: true,
|
||||
statusMessage: '',
|
||||
statusIsError: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function halftoneStudioReducer(
|
||||
state: HalftoneStudioState,
|
||||
action: HalftoneStudioAction,
|
||||
): HalftoneStudioState {
|
||||
switch (action.type) {
|
||||
case 'setTab':
|
||||
return {
|
||||
...state,
|
||||
activeTab: action.value,
|
||||
};
|
||||
case 'setSourceMode':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
sourceMode: action.value,
|
||||
},
|
||||
};
|
||||
case 'setShapeKey':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
shapeKey: action.value,
|
||||
},
|
||||
};
|
||||
case 'replaceSettings':
|
||||
return {
|
||||
...state,
|
||||
settings: normalizeHalftoneStudioSettings(action.value),
|
||||
};
|
||||
case 'patchLighting':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
lighting: {
|
||||
...state.settings.lighting,
|
||||
...action.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'patchMaterial':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
material: {
|
||||
...state.settings.material,
|
||||
...action.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'patchHalftone':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
halftone: {
|
||||
...state.settings.halftone,
|
||||
...action.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'patchBackground':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
background: {
|
||||
...state.settings.background,
|
||||
...action.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'patchAnimation':
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
animation: {
|
||||
...state.settings.animation,
|
||||
...action.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'registerImportedFile':
|
||||
return {
|
||||
...state,
|
||||
geometrySpecs: action.spec.userProvided
|
||||
? upsertGeometrySpec(state.geometrySpecs, action.spec)
|
||||
: state.geometrySpecs,
|
||||
importedFiles: {
|
||||
...state.importedFiles,
|
||||
[action.spec.key]: action.file,
|
||||
},
|
||||
settings: action.activate
|
||||
? {
|
||||
...state.settings,
|
||||
shapeKey: action.spec.key,
|
||||
}
|
||||
: state.settings,
|
||||
};
|
||||
case 'setImportedFile':
|
||||
return {
|
||||
...state,
|
||||
importedFiles: {
|
||||
...state.importedFiles,
|
||||
[action.key]: action.file,
|
||||
},
|
||||
};
|
||||
case 'setStatus':
|
||||
return {
|
||||
...state,
|
||||
statusMessage: action.message,
|
||||
statusIsError: action.isError ?? false,
|
||||
};
|
||||
case 'clearStatus':
|
||||
return {
|
||||
...state,
|
||||
statusMessage: '',
|
||||
statusIsError: false,
|
||||
};
|
||||
case 'hideHint':
|
||||
return state.showHint
|
||||
? {
|
||||
...state,
|
||||
showHint: false,
|
||||
}
|
||||
: state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
11
packages/twenty-website-new/src/app/halftone/page.tsx
Normal file
11
packages/twenty-website-new/src/app/halftone/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Halftone Generator — Twenty',
|
||||
description: 'Interactive halftone generator exported from Twenty.',
|
||||
};
|
||||
|
||||
export default function HalftonePage() {
|
||||
return <HalftoneStudio />;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { FOOTER_DATA } from '@/app/_constants/footer';
|
||||
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
|
||||
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
|
||||
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
|
||||
import { Footer } from '@/sections/Footer/components';
|
||||
|
|
@ -85,14 +86,16 @@ export default async function RootLayout({
|
|||
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
|
||||
>
|
||||
<StyledMain>{children}</StyledMain>
|
||||
<Footer.Root illustration={FOOTER_DATA.illustration}>
|
||||
<Footer.Logo />
|
||||
<Footer.Nav groups={FOOTER_DATA.navGroups} />
|
||||
<Footer.Bottom
|
||||
copyright={FOOTER_DATA.bottom.copyright}
|
||||
links={footerSocialLinks}
|
||||
/>
|
||||
</Footer.Root>
|
||||
<FooterVisibilityGate>
|
||||
<Footer.Root illustration={FOOTER_DATA.illustration}>
|
||||
<Footer.Logo />
|
||||
<Footer.Nav groups={FOOTER_DATA.navGroups} />
|
||||
<Footer.Bottom
|
||||
copyright={FOOTER_DATA.bottom.copyright}
|
||||
links={footerSocialLinks}
|
||||
/>
|
||||
</Footer.Root>
|
||||
</FooterVisibilityGate>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
|
@ -7,82 +8,306 @@ import * as THREE from 'three';
|
|||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
const GLB_URL = '/illustrations/common/faq/faq.glb';
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
// World spin axis (x, y, z). Camera on +Z: (0,0,1) = spinner in the view plane.
|
||||
// Try (0,1,0) or (1,0,0) if motion or framing looks wrong.
|
||||
const scanlineVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
const passThroughVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const scanlineFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform float uStripeScale;
|
||||
const blurFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.06);
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float y = vWorldPosition.y * uStripeScale;
|
||||
float cell = fract(y);
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
float shadowWeight = mix(1.0, 0.5, ndotl);
|
||||
float lineWidth = 0.58 * shadowWeight;
|
||||
float edge = 0.035;
|
||||
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
float highlight = pow(ndotl, 1.35);
|
||||
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
|
||||
float dashMask = mix(
|
||||
1.0,
|
||||
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
|
||||
highlight
|
||||
);
|
||||
band *= dashMask;
|
||||
|
||||
float speckle = fract(
|
||||
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
|
||||
);
|
||||
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
|
||||
|
||||
if (band < 0.015) {
|
||||
discard;
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
|
||||
gl_FragColor = vec4(lit, band);
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
function createScanlineMaterial(lightDirection: THREE.Vector3) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uStripeScale: { value: 16.0 },
|
||||
},
|
||||
vertexShader: scanlineVertexShader,
|
||||
fragmentShader: scanlineFragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
side: THREE.DoubleSide,
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlow;
|
||||
uniform vec2 resolution;
|
||||
uniform float numRows;
|
||||
uniform float glowStr;
|
||||
uniform float contrast;
|
||||
uniform float power;
|
||||
uniform float shading;
|
||||
uniform float baseInk;
|
||||
uniform float maxBar;
|
||||
uniform float rowMerge;
|
||||
uniform float cellRatio;
|
||||
uniform float cutoff;
|
||||
uniform float highlightOpen;
|
||||
uniform float shadowGrouping;
|
||||
uniform float shadowCrush;
|
||||
uniform vec3 dashColor;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
uniform float distanceScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform vec2 dragOffset;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverFlowStrength;
|
||||
uniform float hoverFlowRadius;
|
||||
uniform float dragFlowStrength;
|
||||
uniform float dragFlowRadius;
|
||||
uniform float cropToBounds;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Crop to image bounds: discard fragments outside source image (image mode only)
|
||||
if (cropToBounds > 0.5) {
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
float baseRowH = resolution.y / (numRows * distanceScale);
|
||||
vec2 pointerPx = interactionUv * resolution;
|
||||
vec2 fragDelta = gl_FragCoord.xy - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * resolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverFlowMask = 0.0;
|
||||
if (hoverFlowStrength > 0.0) {
|
||||
float hoverRadiusPx = hoverFlowRadius * resolution.y;
|
||||
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float dragFlowMask = 0.0;
|
||||
if (dragFlowStrength > 0.0) {
|
||||
float dragRadiusPx = dragFlowRadius * resolution.y;
|
||||
dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 hoverDisplacement =
|
||||
radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
|
||||
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
|
||||
vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
|
||||
vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
|
||||
|
||||
float densityBoost =
|
||||
hoverFlowStrength * hoverFlowMask * 0.22 +
|
||||
dragFlowStrength * dragFlowMask * 0.16;
|
||||
float rowH = baseRowH / (1.0 + densityBoost);
|
||||
|
||||
float offsetY = effectCoord.y;
|
||||
float row = floor(offsetY / rowH);
|
||||
float rowFrac = offsetY / rowH - row;
|
||||
float rowV = (row + 0.5) * rowH / resolution.y;
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
|
||||
float effectiveX = effectCoord.x + waveOffset;
|
||||
|
||||
float localCellRatio = cellRatio * (
|
||||
1.0 +
|
||||
hoverFlowStrength * hoverFlowMask * 0.08 +
|
||||
dragFlowStrength * dragFlowMask * 0.1 * motionBias
|
||||
);
|
||||
float cellW = rowH * localCellRatio;
|
||||
float cellIdx = floor(effectiveX / cellW);
|
||||
float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
|
||||
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
|
||||
|
||||
vec2 sampleUv = vec2(
|
||||
clamp(cellU, 0.0, 1.0),
|
||||
clamp(rowV, 0.0, 1.0)
|
||||
);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
vec4 glowCell = texture2D(tGlow, sampleUv);
|
||||
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float detail = lum - avgLum;
|
||||
|
||||
float litLum = lum + max(detail, 0.0) * shading
|
||||
- max(-detail, 0.0) * shading * 0.55;
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
|
||||
float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
|
||||
litLum = clamp(litLum + lightLift, 0.0, 1.0);
|
||||
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
litLum = pow(litLum, max(contrast - lightFocus, 0.25));
|
||||
|
||||
float darkness = 1.0 - litLum;
|
||||
float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
|
||||
float groupedDarkness = 1.0 - groupedLum;
|
||||
darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
|
||||
darkness = clamp(
|
||||
(darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
|
||||
0.0,
|
||||
1.0
|
||||
);
|
||||
|
||||
float shadowMask = smoothstep(0.42, 0.96, darkness);
|
||||
darkness = mix(
|
||||
darkness,
|
||||
mix(darkness, 1.0, shadowMask),
|
||||
shadowCrush
|
||||
);
|
||||
|
||||
float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
|
||||
float ink = mix(inkBase, 1.0, darkness);
|
||||
float fill = pow(ink, 1.05) * power;
|
||||
fill = clamp(fill, 0.0, 1.0) * mask;
|
||||
|
||||
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
|
||||
float dynamicBarHalfY = min(
|
||||
dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
|
||||
0.78
|
||||
);
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capRX = dynamicBarHalf * rowH;
|
||||
float capRY = dynamicBarHalfY * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalfY - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float ellipseDist = sqrt(
|
||||
(cdx * cdx) / max(capRX * capRX, 0.0001) +
|
||||
(cdy * cdy) / max(capRY * capRY, 0.0001)
|
||||
);
|
||||
inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
|
||||
}
|
||||
|
||||
inDash *= step(0.001, ink) * mask;
|
||||
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
|
||||
|
||||
vec4 glow = texture2D(tGlow, vUv);
|
||||
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
|
||||
float sharp = smoothstep(0.3, 0.5, inDash + halo);
|
||||
vec3 color = dashColor * sharp;
|
||||
|
||||
gl_FragColor = vec4(color, sharp);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const GLB_URL = '/illustrations/common/faq/faq.glb';
|
||||
|
||||
const LIGHT_INTENSITY = 3.1;
|
||||
const FILL_LIGHT_INTENSITY = 0.15;
|
||||
const AMBIENT_LIGHT_INTENSITY = 0.08;
|
||||
const LIGHT_ANGLE_DEGREES = 45;
|
||||
const LIGHT_HEIGHT = 2;
|
||||
|
||||
const MATERIAL_ROUGHNESS = 0.42;
|
||||
const MATERIAL_METALNESS = 0.16;
|
||||
|
||||
const HALFTONE_ROWS = 127;
|
||||
const HALFTONE_CONTRAST = 1.6;
|
||||
const HALFTONE_POWER = 1.2;
|
||||
const HALFTONE_SHADING = 1.6;
|
||||
const HALFTONE_BASE_INK = 0.16;
|
||||
const HALFTONE_MAX_BAR = 0.24;
|
||||
const HALFTONE_CELL_RATIO = 2.2;
|
||||
const HALFTONE_CUTOFF = 0.02;
|
||||
const HALFTONE_DASH_COLOR = '#4A38F5';
|
||||
|
||||
const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z';
|
||||
const ROTATE_SPEED = 0.1;
|
||||
const ROTATE_PING_PONG = false;
|
||||
const INITIAL_ROTATE_ELAPSED = 275.10000000007847;
|
||||
const INITIAL_TIME_ELAPSED = 249.21849999998116;
|
||||
const BASE_CAMERA_DISTANCE = 4;
|
||||
const MODEL_OFFSET_Y = 0.52;
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function createEnvironmentTexture(renderer: THREE.WebGLRenderer) {
|
||||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
const environmentTexture = pmremGenerator.fromScene(
|
||||
new RoomEnvironment(),
|
||||
0.04,
|
||||
).texture;
|
||||
pmremGenerator.dispose();
|
||||
|
||||
return environmentTexture;
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
return;
|
||||
}
|
||||
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObjectSubtree(root: THREE.Object3D) {
|
||||
root.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
|
|
@ -90,26 +315,243 @@ function disposeObjectSubtree(root: THREE.Object3D) {
|
|||
}
|
||||
|
||||
sceneObject.geometry?.dispose();
|
||||
|
||||
const material = sceneObject.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
disposeMaterial(sceneObject.material);
|
||||
});
|
||||
}
|
||||
|
||||
function applyScanlineMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position;
|
||||
const normal = geometry.attributes.normal;
|
||||
const uv = geometry.attributes.uv ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function setPrimaryLightPosition(
|
||||
light: THREE.DirectionalLight,
|
||||
angleDegrees: number,
|
||||
height: number,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
const lightAngle = (angleDegrees * Math.PI) / 180;
|
||||
light.position.set(
|
||||
Math.cos(lightAngle) * 5,
|
||||
height,
|
||||
Math.sin(lightAngle) * 5,
|
||||
);
|
||||
}
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 1.6 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
|
||||
root.updateMatrixWorld(true);
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.material = createScanlineMaterial(lightDirection);
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
async function loadFaqGeometry(modelUrl: string) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load FAQ model from ${modelUrl}.`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return await new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
'',
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(
|
||||
extractMergedGeometry(
|
||||
gltf.scene,
|
||||
'FAQ model did not contain any mesh geometry.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createFaqMaterial(environmentTexture: THREE.Texture) {
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color: 0xd4d0c8,
|
||||
roughness: MATERIAL_ROUGHNESS,
|
||||
metalness: MATERIAL_METALNESS,
|
||||
envMap: environmentTexture,
|
||||
envMapIntensity: 0.25,
|
||||
clearcoat: 0,
|
||||
clearcoatRoughness: 0.08,
|
||||
reflectivity: 0.5,
|
||||
transmission: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,111 +587,301 @@ export function FaqBackground() {
|
|||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
let rotateElapsed = INITIAL_ROTATE_ELAPSED;
|
||||
|
||||
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(
|
||||
getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)),
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
|
||||
camera.position.set(0, 0, 5.05);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const wheel = new THREE.Group();
|
||||
scene.add(wheel);
|
||||
const environmentTexture = createEnvironmentTexture(renderer);
|
||||
const scene3d = new THREE.Scene();
|
||||
scene3d.background = null;
|
||||
|
||||
const spinAxisWorld = new THREE.Vector3(0, 0, 1).normalize();
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
45,
|
||||
getWidth() / getHeight(),
|
||||
0.1,
|
||||
100,
|
||||
);
|
||||
camera.position.z = BASE_CAMERA_DISTANCE;
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY);
|
||||
setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
|
||||
scene3d.add(primaryLight);
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
|
||||
const fillLight = new THREE.DirectionalLight(
|
||||
0xffffff,
|
||||
FILL_LIGHT_INTENSITY,
|
||||
);
|
||||
fillLight.position.set(-3, -1, 1);
|
||||
scene3d.add(fillLight);
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(
|
||||
0xffffff,
|
||||
AMBIENT_LIGHT_INTENSITY,
|
||||
);
|
||||
scene3d.add(ambientLight);
|
||||
|
||||
const material = createFaqMaterial(environmentTexture);
|
||||
const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material);
|
||||
mesh.visible = false;
|
||||
scene3d.add(mesh);
|
||||
|
||||
const sceneTarget = createRenderTarget(
|
||||
getVirtualWidth(),
|
||||
getVirtualHeight(),
|
||||
);
|
||||
const blurTargetA = createRenderTarget(
|
||||
getVirtualWidth(),
|
||||
getVirtualHeight(),
|
||||
);
|
||||
const blurTargetB = createRenderTarget(
|
||||
getVirtualWidth(),
|
||||
getVirtualHeight(),
|
||||
);
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tGlow: { value: blurTargetB.texture },
|
||||
resolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
numRows: { value: HALFTONE_ROWS },
|
||||
glowStr: { value: 0 },
|
||||
contrast: { value: HALFTONE_CONTRAST },
|
||||
power: { value: HALFTONE_POWER },
|
||||
shading: { value: HALFTONE_SHADING },
|
||||
baseInk: { value: HALFTONE_BASE_INK },
|
||||
maxBar: { value: HALFTONE_MAX_BAR },
|
||||
cellRatio: { value: HALFTONE_CELL_RATIO },
|
||||
cutoff: { value: HALFTONE_CUTOFF },
|
||||
dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
|
||||
time: { value: 0 },
|
||||
waveAmount: { value: 0 },
|
||||
waveSpeed: { value: 1 },
|
||||
distanceScale: { value: 1 },
|
||||
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
interactionVelocity: { value: new THREE.Vector2(0, 0) },
|
||||
dragOffset: { value: new THREE.Vector2(0, 0) },
|
||||
hoverLightStrength: { value: 0 },
|
||||
hoverLightRadius: { value: 0.2 },
|
||||
hoverFlowStrength: { value: 0 },
|
||||
hoverFlowRadius: { value: 0.18 },
|
||||
dragFlowStrength: { value: 0 },
|
||||
dragFlowRadius: { value: 0.24 },
|
||||
cropToBounds: { value: 0 },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
});
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
|
||||
);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
|
||||
);
|
||||
|
||||
loader.load(
|
||||
GLB_URL,
|
||||
(gltf) => {
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const syncSize = () => {
|
||||
const width = getWidth();
|
||||
const height = getHeight();
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
blurHorizontalMaterial.uniforms.res.value.set(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
halftoneMaterial.uniforms.resolution.value.set(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
loadFaqGeometry(GLB_URL)
|
||||
.then((geometry) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
geometry.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const modelRoot = gltf.scene;
|
||||
const bounds = new THREE.Box3().setFromObject(modelRoot);
|
||||
const center = bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
|
||||
const scale = 4 / maxAxis;
|
||||
mesh.geometry.dispose();
|
||||
mesh.geometry = geometry;
|
||||
mesh.visible = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
applyScanlineMaterials(modelRoot, lightDirectionWorld);
|
||||
wheel.add(modelRoot);
|
||||
wheel.position.y = 0.52;
|
||||
|
||||
const renderFrame = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
const delta = Math.min(clock.getDelta(), 0.1);
|
||||
wheel.rotateOnWorldAxis(spinAxisWorld, -delta * 0.5);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountReference.current || cancelled) {
|
||||
const renderFrame = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = mountReference.current.clientWidth;
|
||||
const nextHeight = mountReference.current.clientHeight;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
|
||||
const delta = 1 / 60;
|
||||
const elapsedTime = INITIAL_TIME_ELAPSED + clock.getElapsedTime();
|
||||
halftoneMaterial.uniforms.time.value = elapsedTime;
|
||||
|
||||
rotateElapsed += delta;
|
||||
|
||||
const rotateProgress = ROTATE_PING_PONG
|
||||
? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI
|
||||
: rotateElapsed * ROTATE_SPEED;
|
||||
const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1;
|
||||
const axisProgress = rotateProgress * axisDirection;
|
||||
|
||||
let rotationX = 0;
|
||||
let rotationY = 0;
|
||||
let rotationZ = 0;
|
||||
|
||||
if (
|
||||
ROTATE_AXIS === 'x' ||
|
||||
ROTATE_AXIS === 'xy' ||
|
||||
ROTATE_AXIS === '-x' ||
|
||||
ROTATE_AXIS === '-xy'
|
||||
) {
|
||||
rotationX += axisProgress;
|
||||
}
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
|
||||
if (
|
||||
ROTATE_AXIS === 'y' ||
|
||||
ROTATE_AXIS === 'xy' ||
|
||||
ROTATE_AXIS === '-y' ||
|
||||
ROTATE_AXIS === '-xy'
|
||||
) {
|
||||
rotationY += axisProgress;
|
||||
}
|
||||
|
||||
if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') {
|
||||
rotationZ += axisProgress;
|
||||
}
|
||||
|
||||
mesh.rotation.set(rotationX, rotationY, rotationZ);
|
||||
mesh.position.y = MODEL_OFFSET_Y;
|
||||
mesh.scale.setScalar(1);
|
||||
|
||||
camera.position.x += (0 - camera.position.x) * 0.12;
|
||||
camera.position.y += (0 - camera.position.y) * 0.12;
|
||||
camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12;
|
||||
camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0);
|
||||
setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.clear();
|
||||
renderer.render(scene3d, camera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
disposeObjectSubtree(scene);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
mesh.geometry.dispose();
|
||||
material.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
environmentTexture.dispose();
|
||||
renderer.dispose();
|
||||
dracoLoader.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,129 +1,479 @@
|
|||
'use client';
|
||||
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
|
||||
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
type HalftoneSourceMode = 'shape' | 'image';
|
||||
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
|
||||
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
|
||||
|
||||
interface HalftoneLightingSettings {
|
||||
intensity: number;
|
||||
fillIntensity: number;
|
||||
ambientIntensity: number;
|
||||
angleDegrees: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HalftoneMaterialSettings {
|
||||
roughness: number;
|
||||
metalness: number;
|
||||
}
|
||||
|
||||
interface HalftoneEffectSettings {
|
||||
enabled: boolean;
|
||||
numRows: number;
|
||||
contrast: number;
|
||||
power: number;
|
||||
shading: number;
|
||||
baseInk: number;
|
||||
maxBar: number;
|
||||
rowMerge: number;
|
||||
cellRatio: number;
|
||||
cutoff: number;
|
||||
highlightOpen: number;
|
||||
shadowGrouping: number;
|
||||
shadowCrush: number;
|
||||
dashColor: string;
|
||||
}
|
||||
|
||||
interface HalftoneBackgroundSettings {
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface HalftoneAnimationSettings {
|
||||
autoRotateEnabled: boolean;
|
||||
breatheEnabled: boolean;
|
||||
cameraParallaxEnabled: boolean;
|
||||
followHoverEnabled: boolean;
|
||||
followDragEnabled: boolean;
|
||||
floatEnabled: boolean;
|
||||
hoverLightEnabled: boolean;
|
||||
dragFlowEnabled: boolean;
|
||||
lightSweepEnabled: boolean;
|
||||
rotateEnabled: boolean;
|
||||
autoSpeed: number;
|
||||
autoWobble: number;
|
||||
breatheAmount: number;
|
||||
breatheSpeed: number;
|
||||
cameraParallaxAmount: number;
|
||||
cameraParallaxEase: number;
|
||||
driftAmount: number;
|
||||
hoverRange: number;
|
||||
hoverEase: number;
|
||||
hoverReturn: boolean;
|
||||
dragSens: number;
|
||||
dragFriction: number;
|
||||
dragMomentum: boolean;
|
||||
rotateAxis: HalftoneRotateAxis;
|
||||
rotatePreset: HalftoneRotatePreset;
|
||||
rotateSpeed: number;
|
||||
rotatePingPong: boolean;
|
||||
floatAmplitude: number;
|
||||
floatSpeed: number;
|
||||
lightSweepHeightRange: number;
|
||||
lightSweepRange: number;
|
||||
lightSweepSpeed: number;
|
||||
springDamping: number;
|
||||
springReturnEnabled: boolean;
|
||||
springStrength: number;
|
||||
hoverLightIntensity: number;
|
||||
hoverLightRadius: number;
|
||||
dragFlowDecay: number;
|
||||
dragFlowRadius: number;
|
||||
dragFlowStrength: number;
|
||||
hoverWarpStrength: number;
|
||||
hoverWarpRadius: number;
|
||||
dragWarpStrength: number;
|
||||
waveEnabled: boolean;
|
||||
waveSpeed: number;
|
||||
waveAmount: number;
|
||||
}
|
||||
|
||||
interface HalftoneExportPose {
|
||||
autoElapsed: number;
|
||||
rotateElapsed: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
targetRotationX: number;
|
||||
targetRotationY: number;
|
||||
timeElapsed: number;
|
||||
}
|
||||
|
||||
interface HalftoneStudioSettings {
|
||||
sourceMode: HalftoneSourceMode;
|
||||
shapeKey: string;
|
||||
lighting: HalftoneLightingSettings;
|
||||
material: HalftoneMaterialSettings;
|
||||
halftone: HalftoneEffectSettings;
|
||||
background: HalftoneBackgroundSettings;
|
||||
animation: HalftoneAnimationSettings;
|
||||
}
|
||||
|
||||
type HalftoneModelLoader = 'fbx' | 'glb';
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position;
|
||||
const normal = geometry.attributes.normal;
|
||||
const uv = geometry.attributes.uv ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 1.6 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) {
|
||||
root.updateMatrixWorld(true);
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
function parseFbxGeometry(
|
||||
buffer: ArrayBuffer,
|
||||
resourcePath: string,
|
||||
label: string,
|
||||
) {
|
||||
const originalWarn = console.warn;
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalWarn(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
const root = new FBXLoader(createLoadingManager()).parse(
|
||||
buffer,
|
||||
resourcePath,
|
||||
);
|
||||
|
||||
return extractMergedGeometry(
|
||||
root,
|
||||
`${label} did not contain any mesh geometry.`,
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGlbGeometry(
|
||||
buffer: ArrayBuffer,
|
||||
resourcePath: string,
|
||||
label: string,
|
||||
) {
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
resourcePath,
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(
|
||||
extractMergedGeometry(
|
||||
gltf.scene,
|
||||
`${label} did not contain any mesh geometry.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
}).finally(() => {
|
||||
dracoLoader.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImportedGeometry(
|
||||
loader: HalftoneModelLoader,
|
||||
file: File,
|
||||
label: string,
|
||||
) {
|
||||
const buffer = await file.arrayBuffer();
|
||||
|
||||
if (loader === 'fbx') {
|
||||
return parseFbxGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
return parseGlbGeometry(buffer, '', label);
|
||||
}
|
||||
|
||||
const GLB_URL = '/illustrations/home/testimonials/hourglass.glb';
|
||||
const HOURGLASS_PREVIEW_DISTANCE = 4;
|
||||
|
||||
const scanlineVertexShader = /* glsl */ `
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
vWorldNormal = normalize(mat3(modelMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * worldPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const scanlineFragmentShader = /* glsl */ `
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uLightDir;
|
||||
uniform float uStripeScale;
|
||||
|
||||
varying vec3 vWorldPosition;
|
||||
varying vec3 vWorldNormal;
|
||||
|
||||
void main() {
|
||||
vec3 normal = normalize(vWorldNormal);
|
||||
vec3 lightDir = normalize(uLightDir);
|
||||
float ndotl = max(dot(normal, lightDir), 0.06);
|
||||
|
||||
float y = vWorldPosition.y * uStripeScale;
|
||||
float cell = fract(y);
|
||||
|
||||
float shadowWeight = mix(1.0, 0.5, ndotl);
|
||||
float lineWidth = 0.58 * shadowWeight;
|
||||
float edge = 0.035;
|
||||
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
|
||||
|
||||
float highlight = pow(ndotl, 1.35);
|
||||
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
|
||||
float dashMask = mix(
|
||||
1.0,
|
||||
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
|
||||
highlight
|
||||
);
|
||||
band *= dashMask;
|
||||
|
||||
float speckle = fract(
|
||||
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
|
||||
);
|
||||
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
|
||||
|
||||
if (band < 0.015) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
|
||||
gl_FragColor = vec4(lit, band);
|
||||
}
|
||||
`;
|
||||
|
||||
function createScanlineMaterial(lightDirection: THREE.Vector3) {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uColor: { value: new THREE.Color('#1e5bff') },
|
||||
uLightDir: { value: lightDirection.clone() },
|
||||
uStripeScale: { value: 16.0 },
|
||||
},
|
||||
vertexShader: scanlineVertexShader,
|
||||
fragmentShader: scanlineFragmentShader,
|
||||
const HOURGLASS_SETTINGS: HalftoneStudioSettings = {
|
||||
sourceMode: 'shape',
|
||||
shapeKey: 'hourglass',
|
||||
lighting: {
|
||||
intensity: 3.4,
|
||||
fillIntensity: 1.5,
|
||||
ambientIntensity: 0.06,
|
||||
angleDegrees: 260,
|
||||
height: 0.8,
|
||||
},
|
||||
material: {
|
||||
roughness: 0.59,
|
||||
metalness: 0.02,
|
||||
},
|
||||
halftone: {
|
||||
enabled: true,
|
||||
numRows: 150,
|
||||
contrast: 1.2,
|
||||
power: 1.1,
|
||||
shading: 3,
|
||||
baseInk: 0.19,
|
||||
maxBar: 0.35,
|
||||
rowMerge: 0.08,
|
||||
cellRatio: 2.2,
|
||||
cutoff: 0.02,
|
||||
highlightOpen: 0.04,
|
||||
shadowGrouping: 0.18,
|
||||
shadowCrush: 0.14,
|
||||
dashColor: '#4A38F5',
|
||||
},
|
||||
background: {
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
|
||||
function disposeObjectSubtree(root: THREE.Object3D) {
|
||||
root.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneObject.geometry?.dispose();
|
||||
|
||||
const material = sceneObject.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type MeshRestPose = {
|
||||
position: THREE.Vector3;
|
||||
quaternion: THREE.Quaternion;
|
||||
wobblePhase: number;
|
||||
color: 'transparent',
|
||||
},
|
||||
animation: {
|
||||
autoRotateEnabled: true,
|
||||
breatheEnabled: false,
|
||||
cameraParallaxEnabled: false,
|
||||
followHoverEnabled: false,
|
||||
followDragEnabled: true,
|
||||
floatEnabled: false,
|
||||
hoverLightEnabled: false,
|
||||
dragFlowEnabled: false,
|
||||
lightSweepEnabled: false,
|
||||
rotateEnabled: false,
|
||||
autoSpeed: 0.1,
|
||||
autoWobble: 0.05,
|
||||
breatheAmount: 0.04,
|
||||
breatheSpeed: 0.8,
|
||||
cameraParallaxAmount: 0.3,
|
||||
cameraParallaxEase: 0.08,
|
||||
driftAmount: 8,
|
||||
hoverRange: 25,
|
||||
hoverEase: 0.08,
|
||||
hoverReturn: true,
|
||||
dragSens: 0.003,
|
||||
dragFriction: 0.02,
|
||||
dragMomentum: true,
|
||||
rotateAxis: 'y',
|
||||
rotatePreset: 'axis',
|
||||
rotateSpeed: 1,
|
||||
rotatePingPong: false,
|
||||
floatAmplitude: 0.16,
|
||||
floatSpeed: 0.8,
|
||||
lightSweepHeightRange: 0.7,
|
||||
lightSweepRange: 29,
|
||||
lightSweepSpeed: 0.2,
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
function applyScanlineMaterials(
|
||||
modelRoot: THREE.Object3D,
|
||||
lightDirection: THREE.Vector3,
|
||||
) {
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
const HOURGLASS_INITIAL_POSE: HalftoneExportPose = {
|
||||
autoElapsed: 51.68333333333168,
|
||||
rotateElapsed: 0,
|
||||
rotationX: 3.5421542652497933,
|
||||
rotationY: 10.105974316283143,
|
||||
rotationZ: 0,
|
||||
targetRotationX: 3.575782604433917,
|
||||
targetRotationY: 5.019307649616612,
|
||||
timeElapsed: 411.9648000000736,
|
||||
};
|
||||
|
||||
sceneObject.material = createScanlineMaterial(lightDirection);
|
||||
|
||||
const mesh = sceneObject;
|
||||
const rest: MeshRestPose = {
|
||||
position: mesh.position.clone(),
|
||||
quaternion: mesh.quaternion.clone(),
|
||||
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
|
||||
};
|
||||
mesh.userData.hourglassVisualRest = rest;
|
||||
});
|
||||
}
|
||||
const NO_OP = () => {};
|
||||
|
||||
const VisualFrame = styled.div`
|
||||
background-color: transparent;
|
||||
|
|
@ -139,218 +489,61 @@ const VisualFrame = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
const CanvasMount = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Hourglass() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mountReference.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let animationFrameId = 0;
|
||||
let loadedGeometry: THREE.BufferGeometry | null = null;
|
||||
|
||||
const pointer = { x: 0, y: 0, inside: false };
|
||||
const targetRotation = { x: 0, y: 0 };
|
||||
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
|
||||
void fetch(GLB_URL)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not load the hourglass model.');
|
||||
}
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const blob = await response.blob();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
|
||||
camera.position.set(0, 0, 5.05);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'pointer';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const pivot = new THREE.Group();
|
||||
scene.add(pivot);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
|
||||
);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
loader.setDRACOLoader(dracoLoader);
|
||||
|
||||
loader.load(
|
||||
GLB_URL,
|
||||
(gltf) => {
|
||||
return new File([blob], 'hourglass.glb', {
|
||||
type: blob.type || 'model/gltf-binary',
|
||||
});
|
||||
})
|
||||
.then((file) => loadImportedGeometry('glb', file, 'hourglass.glb'))
|
||||
.then((nextGeometry) => {
|
||||
if (cancelled) {
|
||||
disposeObjectSubtree(gltf.scene);
|
||||
nextGeometry.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const modelRoot = gltf.scene;
|
||||
const bounds = new THREE.Box3().setFromObject(modelRoot);
|
||||
const center = bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.getSize(new THREE.Vector3());
|
||||
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
|
||||
const scale = 2.85 / maxAxis;
|
||||
|
||||
modelRoot.position.sub(center);
|
||||
modelRoot.scale.setScalar(scale);
|
||||
|
||||
applyScanlineMaterials(modelRoot, lightDirectionWorld);
|
||||
pivot.add(modelRoot);
|
||||
|
||||
const renderFrame = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
const delta = Math.min(clock.getDelta(), 0.1);
|
||||
|
||||
const rotationDamp = 6.8;
|
||||
const influence = pointer.inside ? 1 : 0.38;
|
||||
targetRotation.y = pointer.x * 0.78 * influence;
|
||||
targetRotation.x = pointer.y * 0.62 * influence;
|
||||
|
||||
pivot.rotation.y = THREE.MathUtils.damp(
|
||||
pivot.rotation.y,
|
||||
targetRotation.y,
|
||||
rotationDamp,
|
||||
delta,
|
||||
);
|
||||
pivot.rotation.x = THREE.MathUtils.damp(
|
||||
pivot.rotation.x,
|
||||
targetRotation.x,
|
||||
rotationDamp,
|
||||
delta,
|
||||
);
|
||||
|
||||
const hoverLift = pointer.inside ? 1 : 0;
|
||||
pivot.scale.setScalar(
|
||||
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
|
||||
);
|
||||
|
||||
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
|
||||
const my = pointer.y * (pointer.inside ? 1 : 0.32);
|
||||
|
||||
modelRoot.traverse((sceneObject) => {
|
||||
if (!(sceneObject instanceof THREE.Mesh)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = sceneObject.userData.hourglassVisualRest as
|
||||
| MeshRestPose
|
||||
| undefined;
|
||||
if (!rest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = rest.wobblePhase;
|
||||
const wobble = pointer.inside ? 1 : 0.36;
|
||||
sceneObject.position.x =
|
||||
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
|
||||
sceneObject.position.z =
|
||||
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
|
||||
sceneObject.position.y =
|
||||
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
|
||||
|
||||
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
|
||||
sceneObject.quaternion.copy(rest.quaternion);
|
||||
sceneObject.rotateY(twist);
|
||||
});
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const setPointerFromEvent = (event: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
|
||||
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
|
||||
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
|
||||
};
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
pointer.inside = true;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
pointer.inside = false;
|
||||
pointer.x = 0;
|
||||
pointer.y = 0;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
setPointerFromEvent(event);
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerenter', handlePointerEnter);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
|
||||
const handleResize = () => {
|
||||
if (!mountReference.current || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = mountReference.current.clientWidth;
|
||||
const nextHeight = mountReference.current.clientHeight;
|
||||
if (nextWidth < 1 || nextHeight < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
camera.aspect = nextWidth / nextHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(nextWidth, nextHeight);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
loadedGeometry = nextGeometry;
|
||||
setGeometry(nextGeometry);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('resize', handleResize);
|
||||
canvas.removeEventListener('pointerenter', handlePointerEnter);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
disposeObjectSubtree(scene);
|
||||
renderer.dispose();
|
||||
dracoLoader.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
if (loadedGeometry) {
|
||||
loadedGeometry.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VisualFrame>
|
||||
<CanvasMount aria-hidden ref={mountReference} />
|
||||
{geometry ? (
|
||||
<HalftoneCanvas
|
||||
geometry={geometry}
|
||||
imageElement={null}
|
||||
initialPose={HOURGLASS_INITIAL_POSE}
|
||||
onFirstInteraction={NO_OP}
|
||||
onPoseChange={NO_OP}
|
||||
previewDistance={HOURGLASS_PREVIEW_DISTANCE}
|
||||
settings={HOURGLASS_SETTINGS}
|
||||
/>
|
||||
) : null}
|
||||
</VisualFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
753
packages/twenty-website-new/src/illustrations/ThreeCards/Sun.tsx
Normal file
753
packages/twenty-website-new/src/illustrations/ThreeCards/Sun.tsx
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
const DRACO_DECODER_PATH =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const GLB_URL = '/illustrations/home/three-cards/sun.glb';
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
const LIGHTING = {
|
||||
intensity: 1.5,
|
||||
fillIntensity: 0.15,
|
||||
ambientIntensity: 0.08,
|
||||
angleDegrees: 45,
|
||||
height: 2,
|
||||
};
|
||||
|
||||
const MATERIAL = {
|
||||
roughness: 0.42,
|
||||
metalness: 0.16,
|
||||
};
|
||||
|
||||
const HALFTONE = {
|
||||
numRows: 99,
|
||||
contrast: 1.3,
|
||||
power: 1.1,
|
||||
shading: 1.6,
|
||||
baseInk: 0.16,
|
||||
maxBar: 0.24,
|
||||
cellRatio: 2.2,
|
||||
cutoff: 0.02,
|
||||
dashColor: '#4A38F5',
|
||||
};
|
||||
|
||||
const AUTO_ROTATE = {
|
||||
speed: 0.3,
|
||||
wobble: 0.3,
|
||||
};
|
||||
|
||||
// Start from the same hero angle used in the exported homepage asset.
|
||||
const INITIAL_POSE = {
|
||||
autoElapsed: 4,
|
||||
rotationX: 0.215,
|
||||
rotationY: 1.2,
|
||||
rotationZ: 0,
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
};
|
||||
|
||||
const passThroughVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlow;
|
||||
uniform vec2 resolution;
|
||||
uniform float numRows;
|
||||
uniform float glowStr;
|
||||
uniform float contrast;
|
||||
uniform float power;
|
||||
uniform float shading;
|
||||
uniform float baseInk;
|
||||
uniform float maxBar;
|
||||
uniform float cellRatio;
|
||||
uniform float cutoff;
|
||||
uniform vec3 dashColor;
|
||||
uniform float time;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float rowH = resolution.y / numRows;
|
||||
float row = floor(gl_FragCoord.y / rowH);
|
||||
float rowFrac = gl_FragCoord.y / rowH - row;
|
||||
float rowV = (row + 0.5) * rowH / resolution.y;
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float cellW = rowH * cellRatio;
|
||||
float cellIdx = floor(gl_FragCoord.x / cellW);
|
||||
float cellFrac = (gl_FragCoord.x - cellIdx * cellW) / cellW;
|
||||
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
|
||||
|
||||
vec2 sampleUv = vec2(
|
||||
clamp(cellU, 0.0, 1.0),
|
||||
clamp(rowV, 0.0, 1.0)
|
||||
);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
vec4 glowCell = texture2D(tGlow, sampleUv);
|
||||
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float detail = lum - avgLum;
|
||||
|
||||
float litLum = lum + max(detail, 0.0) * shading
|
||||
- max(-detail, 0.0) * shading * 0.55;
|
||||
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
litLum = pow(litLum, contrast);
|
||||
|
||||
float ink = mix(baseInk, 1.0, 1.0 - litLum);
|
||||
float fill = pow(ink, 1.05) * power;
|
||||
fill = clamp(fill, 0.0, 1.0) * mask;
|
||||
|
||||
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capR = dynamicBarHalf * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalf - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float d = sqrt(cdx * cdx + cdy * cdy);
|
||||
inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);
|
||||
}
|
||||
|
||||
inDash *= step(0.001, ink) * mask;
|
||||
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
|
||||
|
||||
vec4 glow = texture2D(tGlow, vUv);
|
||||
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
|
||||
float sharp = smoothstep(0.3, 0.5, inDash + halo);
|
||||
vec3 color = dashColor * sharp;
|
||||
|
||||
gl_FragColor = vec4(color, sharp);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const EMPTY_TEXTURE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII=';
|
||||
|
||||
function createLoadingManager() {
|
||||
const loadingManager = new THREE.LoadingManager();
|
||||
loadingManager.setURLModifier((url) =>
|
||||
/\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url,
|
||||
);
|
||||
return loadingManager;
|
||||
}
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 1) {
|
||||
return geometries[0];
|
||||
}
|
||||
|
||||
let totalVertices = 0;
|
||||
let totalIndices = 0;
|
||||
let hasUv = false;
|
||||
|
||||
const geometryInfos = geometries.map((geometry) => {
|
||||
const position = geometry.attributes.position as THREE.BufferAttribute;
|
||||
const normal = geometry.attributes.normal as THREE.BufferAttribute;
|
||||
const uv = (geometry.attributes.uv as THREE.BufferAttribute) ?? null;
|
||||
const index = geometry.index;
|
||||
const indexCount = index ? index.count : position.count;
|
||||
|
||||
totalVertices += position.count;
|
||||
totalIndices += indexCount;
|
||||
hasUv = hasUv || uv !== null;
|
||||
|
||||
return {
|
||||
index,
|
||||
indexCount,
|
||||
normal,
|
||||
position,
|
||||
uv,
|
||||
vertexCount: position.count,
|
||||
};
|
||||
});
|
||||
|
||||
const positions = new Float32Array(totalVertices * 3);
|
||||
const normals = new Float32Array(totalVertices * 3);
|
||||
const uvs = hasUv ? new Float32Array(totalVertices * 2) : null;
|
||||
const indices = new Uint32Array(totalIndices);
|
||||
|
||||
let vertexOffset = 0;
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const geometryInfo of geometryInfos) {
|
||||
for (
|
||||
let vertexIndex = 0;
|
||||
vertexIndex < geometryInfo.vertexCount;
|
||||
vertexIndex += 1
|
||||
) {
|
||||
const positionOffset = (vertexOffset + vertexIndex) * 3;
|
||||
positions[positionOffset] = geometryInfo.position.getX(vertexIndex);
|
||||
positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex);
|
||||
positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex);
|
||||
normals[positionOffset] = geometryInfo.normal.getX(vertexIndex);
|
||||
normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex);
|
||||
normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex);
|
||||
|
||||
if (uvs !== null) {
|
||||
const uvOffset = (vertexOffset + vertexIndex) * 2;
|
||||
uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0;
|
||||
uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (geometryInfo.index) {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] =
|
||||
geometryInfo.index.getX(localIndex) + vertexOffset;
|
||||
}
|
||||
} else {
|
||||
for (
|
||||
let localIndex = 0;
|
||||
localIndex < geometryInfo.indexCount;
|
||||
localIndex += 1
|
||||
) {
|
||||
indices[indexOffset + localIndex] = localIndex + vertexOffset;
|
||||
}
|
||||
}
|
||||
|
||||
vertexOffset += geometryInfo.vertexCount;
|
||||
indexOffset += geometryInfo.indexCount;
|
||||
}
|
||||
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||
|
||||
if (uvs !== null) {
|
||||
merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||
}
|
||||
|
||||
merged.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function normalizeImportedGeometry(geometry: THREE.BufferGeometry) {
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
let boundingBox = geometry.boundingBox;
|
||||
let center = new THREE.Vector3();
|
||||
let size = new THREE.Vector3();
|
||||
|
||||
boundingBox?.getCenter(center);
|
||||
boundingBox?.getSize(size);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
const dimensions = [size.x, size.y, size.z];
|
||||
const thinnestAxis = dimensions.indexOf(Math.min(...dimensions));
|
||||
|
||||
if (thinnestAxis === 0) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2));
|
||||
} else if (thinnestAxis === 1) {
|
||||
geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2));
|
||||
}
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const radius = geometry.boundingSphere?.radius || 1;
|
||||
const scale = 1.6 / radius;
|
||||
geometry.scale(scale, scale, scale);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
boundingBox = geometry.boundingBox;
|
||||
center = new THREE.Vector3();
|
||||
boundingBox?.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingBox();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function extractMergedGeometry(root: THREE.Object3D) {
|
||||
root.updateMatrixWorld(true);
|
||||
const geometries: THREE.BufferGeometry[] = [];
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof THREE.Mesh) || !object.geometry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometry = object.geometry.clone();
|
||||
|
||||
if (!geometry.attributes.normal) {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
geometry.applyMatrix4(object.matrixWorld);
|
||||
geometries.push(geometry);
|
||||
});
|
||||
|
||||
if (geometries.length === 0) {
|
||||
throw new Error('Model did not contain any mesh geometry.');
|
||||
}
|
||||
|
||||
return normalizeImportedGeometry(mergeGeometries(geometries));
|
||||
}
|
||||
|
||||
function parseGlbGeometry(buffer: ArrayBuffer): Promise<THREE.BufferGeometry> {
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
|
||||
|
||||
const gltfLoader = new GLTFLoader(createLoadingManager());
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
|
||||
return new Promise<THREE.BufferGeometry>((resolve, reject) => {
|
||||
gltfLoader.parse(
|
||||
buffer,
|
||||
'',
|
||||
(gltf) => {
|
||||
try {
|
||||
resolve(extractMergedGeometry(gltf.scene));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
}).finally(() => {
|
||||
dracoLoader.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadGeometry(modelUrl: string) {
|
||||
const response = await fetch(modelUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load model from ' + modelUrl);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
return parseGlbGeometry(buffer);
|
||||
}
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
type InteractionState = {
|
||||
autoElapsed: number;
|
||||
dragging: boolean;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
targetRotationX: number;
|
||||
targetRotationY: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
};
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
autoElapsed: INITIAL_POSE.autoElapsed,
|
||||
dragging: false,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
rotationX: INITIAL_POSE.rotationX,
|
||||
rotationY: INITIAL_POSE.rotationY,
|
||||
rotationZ: INITIAL_POSE.rotationZ,
|
||||
targetRotationX: INITIAL_POSE.targetRotationX,
|
||||
targetRotationY: INITIAL_POSE.targetRotationY,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountSunCanvas(container: HTMLDivElement) {
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(
|
||||
getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)),
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
let geometry: THREE.BufferGeometry;
|
||||
|
||||
try {
|
||||
geometry = await loadGeometry(GLB_URL);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
geometry = new THREE.TorusKnotGeometry(1, 0.35, 200, 32);
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'grab';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const pmremGenerator = new THREE.PMREMGenerator(renderer);
|
||||
const environmentTexture = pmremGenerator.fromScene(
|
||||
new RoomEnvironment(),
|
||||
0.04,
|
||||
).texture;
|
||||
pmremGenerator.dispose();
|
||||
|
||||
const scene3d = new THREE.Scene();
|
||||
scene3d.background = null;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
45,
|
||||
getWidth() / getHeight(),
|
||||
0.1,
|
||||
100,
|
||||
);
|
||||
camera.position.z = 4;
|
||||
|
||||
const lightAngle = (LIGHTING.angleDegrees * Math.PI) / 180;
|
||||
|
||||
const primaryLight = new THREE.DirectionalLight(
|
||||
0xffffff,
|
||||
LIGHTING.intensity,
|
||||
);
|
||||
primaryLight.position.set(
|
||||
Math.cos(lightAngle) * 5,
|
||||
LIGHTING.height,
|
||||
Math.sin(lightAngle) * 5,
|
||||
);
|
||||
scene3d.add(primaryLight);
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(
|
||||
0xffffff,
|
||||
LIGHTING.fillIntensity,
|
||||
);
|
||||
fillLight.position.set(-3, -1, 1);
|
||||
scene3d.add(fillLight);
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(
|
||||
0xffffff,
|
||||
LIGHTING.ambientIntensity,
|
||||
);
|
||||
scene3d.add(ambientLight);
|
||||
|
||||
const material = new THREE.MeshPhysicalMaterial({
|
||||
color: 0xd4d0c8,
|
||||
roughness: MATERIAL.roughness,
|
||||
metalness: MATERIAL.metalness,
|
||||
envMap: environmentTexture,
|
||||
envMapIntensity: 0.25,
|
||||
clearcoat: 0,
|
||||
clearcoatRoughness: 0.08,
|
||||
reflectivity: 0.5,
|
||||
transmission: 0,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
scene3d.add(mesh);
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tGlow: { value: blurTargetB.texture },
|
||||
resolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
numRows: { value: HALFTONE.numRows },
|
||||
glowStr: { value: 0 },
|
||||
contrast: { value: HALFTONE.contrast },
|
||||
power: { value: HALFTONE.power },
|
||||
shading: { value: HALFTONE.shading },
|
||||
baseInk: { value: HALFTONE.baseInk },
|
||||
maxBar: { value: HALFTONE.maxBar },
|
||||
cellRatio: { value: HALFTONE.cellRatio },
|
||||
cutoff: { value: HALFTONE.cutoff },
|
||||
dashColor: { value: new THREE.Color(HALFTONE.dashColor) },
|
||||
time: { value: 0 },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
});
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
|
||||
);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
|
||||
);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const interaction = createInteractionState();
|
||||
|
||||
const syncSize = () => {
|
||||
const width = getWidth();
|
||||
const height = getHeight();
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
blurHorizontalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
halftoneMaterial.uniforms.resolution.value.set(
|
||||
virtualWidth,
|
||||
virtualHeight,
|
||||
);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
interaction.dragging = true;
|
||||
interaction.pointerX = event.clientX;
|
||||
interaction.pointerY = event.clientY;
|
||||
interaction.velocityX = 0;
|
||||
interaction.velocityY = 0;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
interaction.mouseX = event.clientX / window.innerWidth;
|
||||
interaction.mouseY = event.clientY / window.innerHeight;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
interaction.dragging = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let animationFrameId = 0;
|
||||
|
||||
const renderFrame = () => {
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
|
||||
const delta = 1 / 60;
|
||||
const elapsedTime = clock.getElapsedTime();
|
||||
halftoneMaterial.uniforms.time.value = elapsedTime;
|
||||
|
||||
if (!interaction.dragging) {
|
||||
interaction.autoElapsed += delta;
|
||||
interaction.targetRotationX += interaction.velocityX;
|
||||
interaction.targetRotationY += interaction.velocityY;
|
||||
interaction.velocityX *= 0.92;
|
||||
interaction.velocityY *= 0.92;
|
||||
}
|
||||
|
||||
const baseRotationY =
|
||||
interaction.autoElapsed * AUTO_ROTATE.speed;
|
||||
const baseRotationX =
|
||||
Math.sin(interaction.autoElapsed * 0.2) * AUTO_ROTATE.wobble;
|
||||
|
||||
const targetX = baseRotationX + interaction.targetRotationX;
|
||||
const targetY = baseRotationY + interaction.targetRotationY;
|
||||
const easing = 0.08;
|
||||
|
||||
interaction.rotationX += (targetX - interaction.rotationX) * easing;
|
||||
interaction.rotationY += (targetY - interaction.rotationY) * easing;
|
||||
|
||||
mesh.rotation.set(
|
||||
interaction.rotationX,
|
||||
interaction.rotationY,
|
||||
interaction.rotationZ,
|
||||
);
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(scene3d, camera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
canvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
material.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
environmentTexture.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const StyledVisualMount = styled.div`
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export function Sun() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unmount = mountSunCanvas(container);
|
||||
|
||||
return () => {
|
||||
void Promise.resolve(unmount).then((dispose) => dispose?.());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <StyledVisualMount aria-hidden ref={mountReference} />;
|
||||
}
|
||||
1103
packages/twenty-website-new/src/illustrations/ThreeCards/Wheelx.tsx
Normal file
1103
packages/twenty-website-new/src/illustrations/ThreeCards/Wheelx.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,8 @@ import { Lock } from './ThreeCards/Lock';
|
|||
import { Programming } from './ThreeCards/Programming';
|
||||
import { SingleScreen } from './ThreeCards/SingleScreen';
|
||||
import { Speed } from './ThreeCards/Speed';
|
||||
import { Sun } from './ThreeCards/Sun';
|
||||
import { Wheelx } from './ThreeCards/Wheelx';
|
||||
import { Logo as WhyTwentyStepperLogo } from './WhyTwentyStepper/Logo';
|
||||
|
||||
export const THREE_CARDS_ILLUSTRATIONS = {
|
||||
|
|
@ -34,6 +36,8 @@ export const THREE_CARDS_ILLUSTRATIONS = {
|
|||
programming: Programming,
|
||||
singleScreen: SingleScreen,
|
||||
speed: Speed,
|
||||
sun: Sun,
|
||||
wheelx: Wheelx,
|
||||
} as const satisfies Record<string, ComponentType>;
|
||||
|
||||
export type ThreeCardsIllustrationId = keyof typeof THREE_CARDS_ILLUSTRATIONS;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,695 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const VIRTUAL_RENDER_HEIGHT = 768;
|
||||
|
||||
const passThroughVertexShader = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const blurFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tInput;
|
||||
uniform vec2 dir;
|
||||
uniform vec2 res;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
vec2 px = dir / res;
|
||||
|
||||
float w[5];
|
||||
w[0] = 0.227027;
|
||||
w[1] = 0.1945946;
|
||||
w[2] = 0.1216216;
|
||||
w[3] = 0.054054;
|
||||
w[4] = 0.016216;
|
||||
|
||||
sum += texture2D(tInput, vUv) * w[0];
|
||||
|
||||
for (int i = 1; i < 5; i++) {
|
||||
float fi = float(i) * 3.0;
|
||||
sum += texture2D(tInput, vUv + px * fi) * w[i];
|
||||
sum += texture2D(tInput, vUv - px * fi) * w[i];
|
||||
}
|
||||
|
||||
gl_FragColor = sum;
|
||||
}
|
||||
`;
|
||||
|
||||
const imagePassthroughFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tImage;
|
||||
uniform vec2 imageSize;
|
||||
uniform vec2 viewportSize;
|
||||
uniform float zoom;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float imageAspect = imageSize.x / imageSize.y;
|
||||
float viewAspect = viewportSize.x / viewportSize.y;
|
||||
|
||||
vec2 uv = vUv;
|
||||
|
||||
// Contain: show full image, letterbox/pillarbox as needed
|
||||
if (imageAspect > viewAspect) {
|
||||
float scale = viewAspect / imageAspect;
|
||||
uv.y = (uv.y - 0.5) / scale + 0.5;
|
||||
} else {
|
||||
float scale = imageAspect / viewAspect;
|
||||
uv.x = (uv.x - 0.5) / scale + 0.5;
|
||||
}
|
||||
|
||||
uv = (uv - 0.5) / zoom + 0.5;
|
||||
|
||||
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
|
||||
* step(0.0, uv.y) * step(uv.y, 1.0);
|
||||
|
||||
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
|
||||
|
||||
gl_FragColor = vec4(color.rgb, inBounds);
|
||||
}
|
||||
`;
|
||||
|
||||
const halftoneFragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlow;
|
||||
uniform vec2 resolution;
|
||||
uniform float numRows;
|
||||
uniform float glowStr;
|
||||
uniform float contrast;
|
||||
uniform float power;
|
||||
uniform float shading;
|
||||
uniform float baseInk;
|
||||
uniform float maxBar;
|
||||
uniform float rowMerge;
|
||||
uniform float cellRatio;
|
||||
uniform float cutoff;
|
||||
uniform float highlightOpen;
|
||||
uniform float shadowGrouping;
|
||||
uniform float shadowCrush;
|
||||
uniform vec3 dashColor;
|
||||
uniform float time;
|
||||
uniform float waveAmount;
|
||||
uniform float waveSpeed;
|
||||
uniform float distanceScale;
|
||||
uniform vec2 interactionUv;
|
||||
uniform vec2 interactionVelocity;
|
||||
uniform vec2 dragOffset;
|
||||
uniform float hoverLightStrength;
|
||||
uniform float hoverLightRadius;
|
||||
uniform float hoverFlowStrength;
|
||||
uniform float hoverFlowRadius;
|
||||
uniform float dragFlowStrength;
|
||||
uniform float dragFlowRadius;
|
||||
uniform float cropToBounds;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Crop to image bounds: discard fragments outside source image (image mode only)
|
||||
if (cropToBounds > 0.5) {
|
||||
vec4 boundsCheck = texture2D(tScene, vUv);
|
||||
if (boundsCheck.a < 0.01) {
|
||||
gl_FragColor = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
float baseRowH = resolution.y / (numRows * distanceScale);
|
||||
vec2 pointerPx = interactionUv * resolution;
|
||||
vec2 fragDelta = gl_FragCoord.xy - pointerPx;
|
||||
float fragDist = length(fragDelta);
|
||||
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
|
||||
float velocityMagnitude = length(interactionVelocity);
|
||||
vec2 motionDir = velocityMagnitude > 0.001
|
||||
? interactionVelocity / velocityMagnitude
|
||||
: vec2(0.0, 0.0);
|
||||
float motionBias = velocityMagnitude > 0.001
|
||||
? dot(-radialDir, motionDir) * 0.5 + 0.5
|
||||
: 0.5;
|
||||
|
||||
float hoverLightMask = 0.0;
|
||||
if (hoverLightStrength > 0.0) {
|
||||
float lightRadiusPx = hoverLightRadius * resolution.y;
|
||||
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float hoverFlowMask = 0.0;
|
||||
if (hoverFlowStrength > 0.0) {
|
||||
float hoverRadiusPx = hoverFlowRadius * resolution.y;
|
||||
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
float dragFlowMask = 0.0;
|
||||
if (dragFlowStrength > 0.0) {
|
||||
float dragRadiusPx = dragFlowRadius * resolution.y;
|
||||
dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist);
|
||||
}
|
||||
|
||||
vec2 hoverDisplacement =
|
||||
radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 +
|
||||
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15;
|
||||
vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8;
|
||||
vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement;
|
||||
|
||||
float densityBoost =
|
||||
hoverFlowStrength * hoverFlowMask * 0.22 +
|
||||
dragFlowStrength * dragFlowMask * 0.16;
|
||||
float rowH = baseRowH / (1.0 + densityBoost);
|
||||
|
||||
float offsetY = effectCoord.y;
|
||||
float row = floor(offsetY / rowH);
|
||||
float rowFrac = offsetY / rowH - row;
|
||||
float rowV = (row + 0.5) * rowH / resolution.y;
|
||||
float dy = abs(rowFrac - 0.5);
|
||||
|
||||
float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
|
||||
float effectiveX = effectCoord.x + waveOffset;
|
||||
|
||||
float localCellRatio = cellRatio * (
|
||||
1.0 +
|
||||
hoverFlowStrength * hoverFlowMask * 0.08 +
|
||||
dragFlowStrength * dragFlowMask * 0.1 * motionBias
|
||||
);
|
||||
float cellW = rowH * localCellRatio;
|
||||
float cellIdx = floor(effectiveX / cellW);
|
||||
float cellFrac = (effectiveX - cellIdx * cellW) / cellW;
|
||||
float cellU = (cellIdx + 0.5) * cellW / resolution.x;
|
||||
|
||||
vec2 sampleUv = vec2(
|
||||
clamp(cellU, 0.0, 1.0),
|
||||
clamp(rowV, 0.0, 1.0)
|
||||
);
|
||||
|
||||
vec4 sceneSample = texture2D(tScene, sampleUv);
|
||||
vec4 glowCell = texture2D(tGlow, sampleUv);
|
||||
|
||||
float mask = smoothstep(0.02, 0.08, sceneSample.a);
|
||||
float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float detail = lum - avgLum;
|
||||
|
||||
float litLum = lum + max(detail, 0.0) * shading
|
||||
- max(-detail, 0.0) * shading * 0.55;
|
||||
float lightLift =
|
||||
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34;
|
||||
float lightFocus = hoverLightStrength * hoverLightMask * 0.12;
|
||||
litLum = clamp(litLum + lightLift, 0.0, 1.0);
|
||||
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
litLum = pow(litLum, max(contrast - lightFocus, 0.25));
|
||||
|
||||
float darkness = 1.0 - litLum;
|
||||
float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
|
||||
groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25));
|
||||
float groupedDarkness = 1.0 - groupedLum;
|
||||
darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping);
|
||||
darkness = clamp(
|
||||
(darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001),
|
||||
0.0,
|
||||
1.0
|
||||
);
|
||||
|
||||
float shadowMask = smoothstep(0.42, 0.96, darkness);
|
||||
darkness = mix(
|
||||
darkness,
|
||||
mix(darkness, 1.0, shadowMask),
|
||||
shadowCrush
|
||||
);
|
||||
|
||||
float inkBase = baseInk * smoothstep(0.03, 0.24, darkness);
|
||||
float ink = mix(inkBase, 1.0, darkness);
|
||||
float fill = pow(ink, 1.05) * power;
|
||||
fill = clamp(fill, 0.0, 1.0) * mask;
|
||||
|
||||
float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));
|
||||
float dynamicBarHalfY = min(
|
||||
dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink),
|
||||
0.78
|
||||
);
|
||||
float dx2 = abs(cellFrac - 0.5);
|
||||
float halfFill = fill * 0.5;
|
||||
float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);
|
||||
float capRX = dynamicBarHalf * rowH;
|
||||
float capRY = dynamicBarHalfY * rowH;
|
||||
|
||||
float inDash = 0.0;
|
||||
if (dx2 <= bodyHalfW) {
|
||||
float edgeDist = dynamicBarHalfY - dy;
|
||||
inDash = smoothstep(-0.03, 0.03, edgeDist);
|
||||
} else {
|
||||
float cdx = (dx2 - bodyHalfW) * cellW;
|
||||
float cdy = dy * rowH;
|
||||
float ellipseDist = sqrt(
|
||||
(cdx * cdx) / max(capRX * capRX, 0.0001) +
|
||||
(cdy * cdy) / max(capRY * capRY, 0.0001)
|
||||
);
|
||||
inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist);
|
||||
}
|
||||
|
||||
inDash *= step(0.001, ink) * mask;
|
||||
inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);
|
||||
|
||||
vec4 glow = texture2D(tGlow, vUv);
|
||||
float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);
|
||||
float sharp = smoothstep(0.3, 0.5, inDash + halo);
|
||||
vec3 color = dashColor * sharp;
|
||||
|
||||
gl_FragColor = vec4(color, sharp);
|
||||
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`;
|
||||
|
||||
const IMAGE_SRC = '/images/home/problem/arthur-roblet.png';
|
||||
|
||||
const IMAGE_ZOOM = 1;
|
||||
const NUM_ROWS = 45;
|
||||
const CONTRAST = 1.3;
|
||||
const POWER = 1.1;
|
||||
const SHADING = 1.6;
|
||||
const BASE_INK = 0.16;
|
||||
const MAX_BAR = 0.24;
|
||||
const CELL_RATIO = 2.2;
|
||||
const CUTOFF = 0.02;
|
||||
const DASH_COLOR = '#4A38F5';
|
||||
|
||||
const HOVER_EASE = 0.08;
|
||||
const DRAG_SENS = 0.008;
|
||||
const DRAG_FRICTION = 0.08;
|
||||
const HOVER_WARP_STRENGTH = 3;
|
||||
const HOVER_WARP_RADIUS = 0.15;
|
||||
const DRAG_WARP_STRENGTH = 5;
|
||||
const FOLLOW_HOVER_ENABLED = true;
|
||||
const FOLLOW_DRAG_ENABLED = false;
|
||||
|
||||
type InteractionState = {
|
||||
dragging: boolean;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
targetRotationX: number;
|
||||
targetRotationY: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
};
|
||||
|
||||
function createRenderTarget(width: number, height: number) {
|
||||
return new THREE.WebGLRenderTarget(width, height, {
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractionState(): InteractionState {
|
||||
return {
|
||||
dragging: false,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
pointerX: 0,
|
||||
pointerY: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
targetRotationX: 0,
|
||||
targetRotationY: 0,
|
||||
velocityX: 0,
|
||||
velocityY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadImage(imageUrl: string) {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
image.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function mountHalftoneCanvas(
|
||||
container: HTMLDivElement,
|
||||
imageUrl: string,
|
||||
) {
|
||||
const getWidth = () => Math.max(container.clientWidth, 1);
|
||||
const getHeight = () => Math.max(container.clientHeight, 1);
|
||||
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
|
||||
const getVirtualWidth = () =>
|
||||
Math.max(
|
||||
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
|
||||
1,
|
||||
);
|
||||
|
||||
const image = await loadImage(imageUrl);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
|
||||
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.touchAction = 'none';
|
||||
canvas.style.width = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const imageTexture = new THREE.Texture(image);
|
||||
imageTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
imageTexture.needsUpdate = true;
|
||||
|
||||
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
|
||||
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
|
||||
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const imageMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tImage: { value: imageTexture },
|
||||
imageSize: { value: new THREE.Vector2(image.width, image.height) },
|
||||
viewportSize: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
zoom: { value: IMAGE_ZOOM },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: imagePassthroughFragmentShader,
|
||||
});
|
||||
|
||||
const imageScene = new THREE.Scene();
|
||||
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
|
||||
|
||||
const blurHorizontalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(1, 0) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const blurVerticalMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tInput: { value: null },
|
||||
dir: { value: new THREE.Vector2(0, 1) },
|
||||
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: blurFragmentShader,
|
||||
});
|
||||
|
||||
const halftoneMaterial = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
tScene: { value: sceneTarget.texture },
|
||||
tGlow: { value: blurTargetB.texture },
|
||||
resolution: {
|
||||
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
|
||||
},
|
||||
numRows: { value: NUM_ROWS },
|
||||
glowStr: { value: 0 },
|
||||
contrast: { value: CONTRAST },
|
||||
power: { value: POWER },
|
||||
shading: { value: SHADING },
|
||||
baseInk: { value: BASE_INK },
|
||||
maxBar: { value: MAX_BAR },
|
||||
cellRatio: { value: CELL_RATIO },
|
||||
cutoff: { value: CUTOFF },
|
||||
dashColor: { value: new THREE.Color(DASH_COLOR) },
|
||||
time: { value: 0 },
|
||||
waveAmount: { value: 0 },
|
||||
waveSpeed: { value: 1 },
|
||||
distanceScale: { value: 1 },
|
||||
mouseUv: { value: new THREE.Vector2(-1, -1) },
|
||||
warpStrength: { value: 0 },
|
||||
warpRadius: { value: HOVER_WARP_RADIUS },
|
||||
cropToBounds: { value: 1 },
|
||||
},
|
||||
vertexShader: passThroughVertexShader,
|
||||
fragmentShader: halftoneFragmentShader,
|
||||
});
|
||||
|
||||
const blurHorizontalScene = new THREE.Scene();
|
||||
blurHorizontalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial),
|
||||
);
|
||||
|
||||
const blurVerticalScene = new THREE.Scene();
|
||||
blurVerticalScene.add(
|
||||
new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial),
|
||||
);
|
||||
|
||||
const postScene = new THREE.Scene();
|
||||
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
|
||||
|
||||
const interaction = createInteractionState();
|
||||
|
||||
const syncSize = () => {
|
||||
const virtualWidth = getVirtualWidth();
|
||||
const virtualHeight = getVirtualHeight();
|
||||
|
||||
renderer.setSize(virtualWidth, virtualHeight, false);
|
||||
sceneTarget.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetA.setSize(virtualWidth, virtualHeight);
|
||||
blurTargetB.setSize(virtualWidth, virtualHeight);
|
||||
blurHorizontalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight);
|
||||
halftoneMaterial.uniforms.resolution.value.set(virtualWidth, virtualHeight);
|
||||
imageMaterial.uniforms.viewportSize.value.set(virtualWidth, virtualHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncSize);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const updatePointerPosition = (event: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 1);
|
||||
const height = Math.max(rect.height, 1);
|
||||
|
||||
interaction.mouseX = THREE.MathUtils.clamp(
|
||||
(event.clientX - rect.left) / width,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
interaction.mouseY = THREE.MathUtils.clamp(
|
||||
(event.clientY - rect.top) / height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
interaction.dragging = true;
|
||||
interaction.pointerX = event.clientX;
|
||||
interaction.pointerY = event.clientY;
|
||||
interaction.velocityX = 0;
|
||||
interaction.velocityY = 0;
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
};
|
||||
|
||||
const handleWindowPointerMove = (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
|
||||
if (!interaction.dragging || !FOLLOW_DRAG_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENS;
|
||||
const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENS;
|
||||
interaction.velocityX = deltaY;
|
||||
interaction.velocityY = deltaX;
|
||||
interaction.targetRotationY += deltaX;
|
||||
interaction.targetRotationX += deltaY;
|
||||
interaction.pointerX = event.clientX;
|
||||
interaction.pointerY = event.clientY;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (interaction.dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
interaction.mouseX = 0.5;
|
||||
interaction.mouseY = 0.5;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
interaction.dragging = false;
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
handlePointerUp();
|
||||
handlePointerLeave();
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove);
|
||||
canvas.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointermove', handleWindowPointerMove);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
canvas.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
let animationFrameId = 0;
|
||||
|
||||
const renderFrame = () => {
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame);
|
||||
|
||||
halftoneMaterial.uniforms.time.value = clock.getElapsedTime();
|
||||
|
||||
let warpActive = false;
|
||||
let currentWarpStrength = 0;
|
||||
|
||||
if (FOLLOW_HOVER_ENABLED && !interaction.dragging) {
|
||||
warpActive = true;
|
||||
currentWarpStrength = HOVER_WARP_STRENGTH;
|
||||
}
|
||||
|
||||
if (FOLLOW_DRAG_ENABLED && interaction.dragging) {
|
||||
warpActive = true;
|
||||
currentWarpStrength = DRAG_WARP_STRENGTH;
|
||||
}
|
||||
|
||||
if (FOLLOW_DRAG_ENABLED && !interaction.dragging) {
|
||||
interaction.targetRotationX *= 1 - DRAG_FRICTION;
|
||||
interaction.targetRotationY *= 1 - DRAG_FRICTION;
|
||||
const dragRemaining =
|
||||
Math.abs(interaction.targetRotationX) +
|
||||
Math.abs(interaction.targetRotationY);
|
||||
|
||||
if (dragRemaining > 0.001) {
|
||||
warpActive = true;
|
||||
currentWarpStrength =
|
||||
DRAG_WARP_STRENGTH * Math.min(dragRemaining * 20, 1);
|
||||
}
|
||||
}
|
||||
|
||||
interaction.rotationX +=
|
||||
(interaction.mouseX - interaction.rotationX) * HOVER_EASE;
|
||||
interaction.rotationY +=
|
||||
(interaction.mouseY - interaction.rotationY) * HOVER_EASE;
|
||||
|
||||
if (warpActive) {
|
||||
halftoneMaterial.uniforms.mouseUv.value.set(
|
||||
interaction.rotationX,
|
||||
interaction.rotationY,
|
||||
);
|
||||
halftoneMaterial.uniforms.warpStrength.value = currentWarpStrength;
|
||||
} else {
|
||||
halftoneMaterial.uniforms.warpStrength.value = 0;
|
||||
}
|
||||
|
||||
renderer.setRenderTarget(sceneTarget);
|
||||
renderer.render(imageScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
|
||||
renderer.setRenderTarget(blurTargetA);
|
||||
renderer.render(blurHorizontalScene, orthographicCamera);
|
||||
|
||||
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
|
||||
renderer.setRenderTarget(blurTargetB);
|
||||
renderer.render(blurVerticalScene, orthographicCamera);
|
||||
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.clear();
|
||||
renderer.render(postScene, orthographicCamera);
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('pointermove', handlePointerMove);
|
||||
canvas.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointermove', handleWindowPointerMove);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
canvas.removeEventListener('pointerdown', handlePointerDown);
|
||||
blurHorizontalMaterial.dispose();
|
||||
blurVerticalMaterial.dispose();
|
||||
halftoneMaterial.dispose();
|
||||
imageMaterial.dispose();
|
||||
imageTexture.dispose();
|
||||
fullScreenGeometry.dispose();
|
||||
sceneTarget.dispose();
|
||||
blurTargetA.dispose();
|
||||
blurTargetB.dispose();
|
||||
renderer.dispose();
|
||||
|
||||
if (canvas.parentNode === container) {
|
||||
container.removeChild(canvas);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function LolOverlay() {
|
||||
const mountReference = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = mountReference.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unmountPromise = mountHalftoneCanvas(container, IMAGE_SRC).catch(
|
||||
(error) => {
|
||||
console.error(error);
|
||||
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
void unmountPromise.then((dispose) => dispose?.());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountReference}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { theme } from '@/theme';
|
||||
import { styled } from '@linaria/react';
|
||||
import Monolith from './monolith';
|
||||
|
||||
const DESKTOP_PATH =
|
||||
'M672 395.498V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h664a4 4 0 0 1 4 4v65.155c0 2.363-.837 4.65-2.361 6.454l-23.603 27.936a10 10 0 0 0-2.362 6.453v242.614c0 2.245.756 4.424 2.145 6.188l24.036 30.51a10 10 0 0 1 2.145 6.188';
|
||||
|
|
@ -21,34 +22,39 @@ const StyledVisual = styled.div`
|
|||
`;
|
||||
|
||||
const StyledMasked = styled.div`
|
||||
background-color: ${theme.colors.secondary.background[100]};
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
height: 100%;
|
||||
isolation: isolate;
|
||||
-webkit-mask-image: ${mobileMask};
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-image: ${mobileMask};
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 100% 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
-webkit-mask-image: ${desktopMask};
|
||||
mask-image: ${desktopMask};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
const StyledHalftoneLayer = styled.div`
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export function Visual() {
|
||||
return (
|
||||
<StyledVisual>
|
||||
<StyledMasked>
|
||||
<StyledImage
|
||||
src="/images/home/problem/problem-visual.svg"
|
||||
alt="Abstract black-and-white image of a tall pillar with mountains in the background."
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<StyledHalftoneLayer>
|
||||
<Monolith />
|
||||
</StyledHalftoneLayer>
|
||||
</StyledMasked>
|
||||
</StyledVisual>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import NextImage from 'next/image';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const IMAGE_SRC = '/images/home/problem/monolith2.jpg';
|
||||
const PATTERN_SRC = '/images/home/problem/halftone-pattern.png';
|
||||
|
||||
const BASE_SCALE = 1.08;
|
||||
const HOVER_SCALE = 1.12;
|
||||
const PATTERN_SCALE = 1.15;
|
||||
const EASE = 0.12;
|
||||
const BASE_RANGE_X = 12;
|
||||
const BASE_RANGE_Y = 10;
|
||||
const PATTERN_RANGE_X = 18;
|
||||
const PATTERN_RANGE_Y = 14;
|
||||
const PATTERN_IDLE_OPACITY = 0.52;
|
||||
const PATTERN_ACTIVE_OPACITY = 0.72;
|
||||
|
||||
type MotionState = {
|
||||
baseOffsetX: number;
|
||||
baseOffsetY: number;
|
||||
baseScale: number;
|
||||
glowOpacity: number;
|
||||
glowX: number;
|
||||
glowY: number;
|
||||
patternOffsetX: number;
|
||||
patternOffsetY: number;
|
||||
patternOpacity: number;
|
||||
patternScale: number;
|
||||
};
|
||||
|
||||
const StyledRoot = styled.div`
|
||||
--base-offset-x: 0px;
|
||||
--base-offset-y: 0px;
|
||||
--base-scale: ${BASE_SCALE};
|
||||
--pattern-offset-x: 0px;
|
||||
--pattern-offset-y: 0px;
|
||||
--pattern-opacity: ${PATTERN_IDLE_OPACITY};
|
||||
--pattern-scale: ${PATTERN_SCALE};
|
||||
--glow-opacity: 0;
|
||||
--glow-x: 50%;
|
||||
--glow-y: 38%;
|
||||
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 22%,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0) 38%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(24, 10, 14, 0.04) 0%,
|
||||
rgba(24, 10, 14, 0.02) 42%,
|
||||
rgba(24, 10, 14, 0.12) 100%
|
||||
);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledBaseLayer = styled.div`
|
||||
inset: -8%;
|
||||
position: absolute;
|
||||
transform: translate3d(var(--base-offset-x), var(--base-offset-y), 0)
|
||||
scale(var(--base-scale));
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
`;
|
||||
|
||||
const StyledPatternLayer = styled.div`
|
||||
inset: -8%;
|
||||
opacity: var(--pattern-opacity);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate3d(var(--pattern-offset-x), var(--pattern-offset-y), 0)
|
||||
scale(var(--pattern-scale));
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
`;
|
||||
|
||||
const StyledGlow = styled.div`
|
||||
background: radial-gradient(
|
||||
circle at var(--glow-x) var(--glow-y),
|
||||
rgba(255, 255, 255, 0.26) 0%,
|
||||
rgba(255, 255, 255, 0.08) 16%,
|
||||
rgba(255, 255, 255, 0) 38%
|
||||
);
|
||||
inset: -16%;
|
||||
mix-blend-mode: screen;
|
||||
opacity: var(--glow-opacity);
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
will-change: opacity;
|
||||
`;
|
||||
|
||||
const StyledShade = styled.div`
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(18, 7, 10, 0.02) 0%,
|
||||
rgba(18, 7, 10, 0.02) 30%,
|
||||
rgba(18, 7, 10, 0.16) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(18, 7, 10, 0.1) 0%,
|
||||
rgba(18, 7, 10, 0) 18%,
|
||||
rgba(18, 7, 10, 0) 82%,
|
||||
rgba(18, 7, 10, 0.08) 100%
|
||||
);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function easeValue(current: number, target: number) {
|
||||
return current + (target - current) * EASE;
|
||||
}
|
||||
|
||||
function applyMotionStyles(element: HTMLDivElement, state: MotionState) {
|
||||
element.style.setProperty(
|
||||
'--base-offset-x',
|
||||
`${state.baseOffsetX.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty(
|
||||
'--base-offset-y',
|
||||
`${state.baseOffsetY.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty('--base-scale', state.baseScale.toFixed(4));
|
||||
element.style.setProperty(
|
||||
'--pattern-offset-x',
|
||||
`${state.patternOffsetX.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty(
|
||||
'--pattern-offset-y',
|
||||
`${state.patternOffsetY.toFixed(2)}px`,
|
||||
);
|
||||
element.style.setProperty('--pattern-scale', state.patternScale.toFixed(4));
|
||||
element.style.setProperty(
|
||||
'--pattern-opacity',
|
||||
state.patternOpacity.toFixed(4),
|
||||
);
|
||||
element.style.setProperty('--glow-x', `${state.glowX.toFixed(2)}%`);
|
||||
element.style.setProperty('--glow-y', `${state.glowY.toFixed(2)}%`);
|
||||
element.style.setProperty('--glow-opacity', state.glowOpacity.toFixed(4));
|
||||
}
|
||||
|
||||
export default function Monolith() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = rootRef.current;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current: MotionState = {
|
||||
baseOffsetX: 0,
|
||||
baseOffsetY: 0,
|
||||
baseScale: BASE_SCALE,
|
||||
glowOpacity: 0,
|
||||
glowX: 50,
|
||||
glowY: 38,
|
||||
patternOffsetX: 0,
|
||||
patternOffsetY: 0,
|
||||
patternOpacity: PATTERN_IDLE_OPACITY,
|
||||
patternScale: PATTERN_SCALE,
|
||||
};
|
||||
|
||||
const target: MotionState = { ...current };
|
||||
let frameId = 0;
|
||||
|
||||
const animate = () => {
|
||||
current.baseOffsetX = easeValue(current.baseOffsetX, target.baseOffsetX);
|
||||
current.baseOffsetY = easeValue(current.baseOffsetY, target.baseOffsetY);
|
||||
current.baseScale = easeValue(current.baseScale, target.baseScale);
|
||||
current.patternOffsetX = easeValue(
|
||||
current.patternOffsetX,
|
||||
target.patternOffsetX,
|
||||
);
|
||||
current.patternOffsetY = easeValue(
|
||||
current.patternOffsetY,
|
||||
target.patternOffsetY,
|
||||
);
|
||||
current.patternOpacity = easeValue(
|
||||
current.patternOpacity,
|
||||
target.patternOpacity,
|
||||
);
|
||||
current.patternScale = easeValue(
|
||||
current.patternScale,
|
||||
target.patternScale,
|
||||
);
|
||||
current.glowX = easeValue(current.glowX, target.glowX);
|
||||
current.glowY = easeValue(current.glowY, target.glowY);
|
||||
current.glowOpacity = easeValue(current.glowOpacity, target.glowOpacity);
|
||||
|
||||
applyMotionStyles(element, current);
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const updateTargetsFromPointer = (clientX: number, clientY: number) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const normalizedX = clamp(
|
||||
(clientX - rect.left) / Math.max(rect.width, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const normalizedY = clamp(
|
||||
(clientY - rect.top) / Math.max(rect.height, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
target.baseOffsetX = (0.5 - normalizedX) * BASE_RANGE_X;
|
||||
target.baseOffsetY = (0.5 - normalizedY) * BASE_RANGE_Y;
|
||||
target.baseScale = HOVER_SCALE;
|
||||
target.patternOffsetX = (0.5 - normalizedX) * PATTERN_RANGE_X;
|
||||
target.patternOffsetY = (0.5 - normalizedY) * PATTERN_RANGE_Y;
|
||||
target.patternOpacity = PATTERN_ACTIVE_OPACITY;
|
||||
target.patternScale = PATTERN_SCALE + 0.02;
|
||||
target.glowX = normalizedX * 100;
|
||||
target.glowY = normalizedY * 100;
|
||||
target.glowOpacity = 1;
|
||||
};
|
||||
|
||||
const resetTargets = () => {
|
||||
target.baseOffsetX = 0;
|
||||
target.baseOffsetY = 0;
|
||||
target.baseScale = BASE_SCALE;
|
||||
target.patternOffsetX = 0;
|
||||
target.patternOffsetY = 0;
|
||||
target.patternOpacity = PATTERN_IDLE_OPACITY;
|
||||
target.patternScale = PATTERN_SCALE;
|
||||
target.glowX = 50;
|
||||
target.glowY = 38;
|
||||
target.glowOpacity = 0;
|
||||
};
|
||||
|
||||
const handlePointerEnter = (event: PointerEvent) => {
|
||||
updateTargetsFromPointer(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
updateTargetsFromPointer(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
resetTargets();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
resetTargets();
|
||||
};
|
||||
|
||||
element.addEventListener('pointerenter', handlePointerEnter);
|
||||
element.addEventListener('pointermove', handlePointerMove);
|
||||
element.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
frameId = window.requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
element.removeEventListener('pointerenter', handlePointerEnter);
|
||||
element.removeEventListener('pointermove', handlePointerMove);
|
||||
element.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledRoot ref={rootRef}>
|
||||
<StyledBaseLayer>
|
||||
<NextImage
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
src={IMAGE_SRC}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</StyledBaseLayer>
|
||||
<StyledPatternLayer>
|
||||
<NextImage
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
src={PATTERN_SRC}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center center',
|
||||
}}
|
||||
/>
|
||||
</StyledPatternLayer>
|
||||
<StyledGlow />
|
||||
<StyledShade />
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue