Compare commits

...

7 Commits

Author SHA1 Message Date
Thomas des Francs 898a46621b Fix halftone glass export parity and clean website files 2026-04-11 19:56:45 +02:00
Thomas des Francs a823ef15cb Refine halftone sharing and animation defaults 2026-04-11 09:53:35 +02:00
Thomas des Francs 3277830400 Remove tracked Playwright output artifacts 2026-04-10 23:57:12 +02:00
Thomas des Francs 280bdcf9ea start fixing home illustrations 2026-04-10 23:54:24 +02:00
Thomas des Francs dea94b9ffa fix homepage illustration 2026-04-10 20:35:08 +02:00
Thomas des Francs 669e985b6a Make halftone exports match preview scaling and framing 2026-04-10 19:12:57 +02:00
Thomas des Francs 41449fc999 Refine halftone controls and exports 2026-04-10 18:21:07 +02:00
51 changed files with 11386 additions and 8157 deletions
+1
View File
@@ -53,3 +53,4 @@ mcp.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
output/playwright/
@@ -0,0 +1,38 @@
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { pathsToModuleNameMapper } from 'ts-jest';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tsConfigPath = resolve(__dirname, './tsconfig.json');
const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
const jestConfig = {
displayName: 'twenty-website-new',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } },
},
},
],
},
moduleNameMapper: {
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageDirectory: './coverage',
};
export default jestConfig;
+23
View File
@@ -36,6 +36,29 @@
"lint": {},
"lint:diff-with-main": {},
"typecheck": {},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{projectRoot}/coverage"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"silent": true,
"coverage": true,
"coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
},
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 1
},
"coverage": {
"coverageReporters": ["lcov", "text"]
},
"watch": {
"watch": true
}
}
},
"fmt": {
"options": {
"files": "."
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 954 KiB

@@ -46,7 +46,7 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
role: { text: 'Head of Engineering' },
company: { text: 'Mid-Market Fintech' },
},
illustration: 'wheelx',
illustration: 'lock',
},
],
};
@@ -22,6 +22,8 @@ import { Testimonials } from '@/sections/Testimonials/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
export const metadata: Metadata = {
@@ -29,6 +31,40 @@ export const metadata: Metadata = {
description: 'Modular, scalable open source CRM for modern teams.',
};
const ThreeCardsIllustrationIntroContent = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(2)};
width: 100%;
`;
const ThreeCardsIllustrationIntroHeader = styled.div`
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(6)};
width: 100%;
`;
const threeCardsIllustrationHeadingClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 921px;
}
[data-family='sans'] {
letter-spacing: -0.02em;
}
`;
const threeCardsIllustrationBodyClassName = css`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 571px;
}
`;
export default async function HomePage() {
const stats = await fetchCommunityStats();
const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats);
@@ -84,16 +120,25 @@ export default async function HomePage() {
<ThreeCards.Root backgroundColor={theme.colors.primary.background[100]}>
<ThreeCards.Intro page={Pages.Home} align="left">
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
<Body body={THREE_CARDS_ILLUSTRATION_DATA.body} size="sm" />
<ThreeCardsIllustrationIntroContent>
<ThreeCardsIllustrationIntroHeader>
<Eyebrow
colorScheme="primary"
heading={THREE_CARDS_ILLUSTRATION_DATA.eyebrow.heading}
/>
<Heading
className={threeCardsIllustrationHeadingClassName}
segments={THREE_CARDS_ILLUSTRATION_DATA.heading}
size="lg"
weight="light"
/>
</ThreeCardsIllustrationIntroHeader>
<Body
body={THREE_CARDS_ILLUSTRATION_DATA.body}
className={threeCardsIllustrationBodyClassName}
size="sm"
/>
</ThreeCardsIllustrationIntroContent>
</ThreeCards.Intro>
<ThreeCards.IllustrationCards
illustrationCards={THREE_CARDS_ILLUSTRATION_DATA.illustrationCards}
@@ -1,132 +1,36 @@
'use client';
import {
IconLayoutSidebarRightCollapse,
IconShare,
} from '@tabler/icons-react';
import { styled } from '@linaria/react';
import type {
HalftoneBackgroundSettings,
HalftoneGeometrySpec,
HalftoneSourceMode,
HalftoneStudioSettings,
HalftoneTabId,
} from '@/app/halftone/_lib/state';
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;
exportBackground: boolean;
exportName: string;
imageFileName: string | null;
onAnimationSettingsChange: (
value: Partial<HalftoneStudioSettings['animation']>,
) => void;
onBackgroundChange: (value: Partial<HalftoneBackgroundSettings>) => void;
onCopyShareLink: () => void;
onDashColorChange: (value: string) => void;
onExportHalftoneImage: (width: number, height: number) => void;
onExportBackgroundChange: (value: boolean) => void;
onExportHtml: () => void;
onExportNameChange: (value: string) => void;
onExportReact: () => void;
@@ -144,9 +48,10 @@ type ControlsPanelProps = {
onShapeChange: (value: string) => void;
onSourceModeChange: (value: HalftoneSourceMode) => void;
onTabChange: (value: HalftoneTabId) => void;
onUploadImage: () => void;
onUploadModel: () => void;
onToggleVisibility: () => void;
onUploadSource: () => void;
previewDistance: number;
visible: boolean;
selectedShape: HalftoneGeometrySpec | undefined;
settings: HalftoneStudioSettings;
shapeOptions: Array<{ label: string; value: string }>;
@@ -160,15 +65,67 @@ const TAB_LABELS: Record<HalftoneTabId, string> = {
export: 'Export',
};
const TabsGroup = styled.div`
display: flex;
gap: 6px;
min-width: 0;
`;
const TABLER_STROKE = 1.7;
const PanelActions = styled.div`
display: inline-flex;
gap: 2px;
margin-left: auto;
`;
const PanelActionButton = styled.button`
align-items: center;
background: transparent;
border: none;
border-radius: 7px;
color: rgba(255, 255, 255, 0.44);
cursor: pointer;
display: inline-flex;
font-family: inherit;
height: 28px;
justify-content: center;
padding: 0;
transition:
background-color 0.15s ease,
color 0.15s ease;
width: 28px;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.74);
}
&:focus-visible {
outline: 1px solid rgba(255, 255, 255, 0.35);
outline-offset: 1px;
}
`;
const PanelToggleTabButton = styled(PanelActionButton)<{ $collapsed: boolean }>`
& > svg {
transform: ${(props) => (props.$collapsed ? 'scaleX(-1)' : 'none')};
transition: transform 0.18s ease;
}
`;
export function ControlsPanel({
activeTab,
defaultExportName,
exportBackground,
exportName,
imageFileName,
onAnimationSettingsChange,
onBackgroundChange,
onCopyShareLink,
onDashColorChange,
onExportHalftoneImage,
onExportBackgroundChange,
onExportHtml,
onExportNameChange,
onExportReact,
@@ -180,29 +137,58 @@ export function ControlsPanel({
onShapeChange,
onSourceModeChange,
onTabChange,
onUploadImage,
onUploadModel,
onToggleVisibility,
onUploadSource,
previewDistance,
visible,
selectedShape,
settings,
shapeOptions,
}: ControlsPanelProps) {
return (
<PanelShell>
<TabsBar>
{TABS.map((tab) => (
<TabButton
$active={tab === activeTab}
key={tab}
onClick={() => onTabChange(tab)}
<PanelShell $collapsed={!visible}>
<TabsBar $collapsed={!visible}>
{visible ? (
<TabsGroup>
{TABS.map((tab) => (
<TabButton
$active={tab === activeTab}
key={tab}
onClick={() => onTabChange(tab)}
type="button"
>
{TAB_LABELS[tab]}
</TabButton>
))}
</TabsGroup>
) : null}
<PanelActions>
<PanelActionButton
aria-label="Copy share link"
onClick={onCopyShareLink}
title="Copy share link"
type="button"
>
{TAB_LABELS[tab]}
</TabButton>
))}
<IconShare aria-hidden size={16} stroke={TABLER_STROKE} />
</PanelActionButton>
<PanelToggleTabButton
$collapsed={!visible}
aria-expanded={visible}
aria-label={visible ? 'Hide right panel' : 'Show right panel'}
onClick={onToggleVisibility}
title={visible ? 'Hide right panel' : 'Show right panel'}
type="button"
>
<IconLayoutSidebarRightCollapse
aria-hidden
size={16}
stroke={TABLER_STROKE}
/>
</PanelToggleTabButton>
</PanelActions>
</TabsBar>
{activeTab === 'design' ? (
{visible && activeTab === 'design' ? (
<DesignTab
imageFileName={imageFileName}
onBackgroundChange={onBackgroundChange}
@@ -213,26 +199,28 @@ export function ControlsPanel({
onPreviewDistanceChange={onPreviewDistanceChange}
onShapeChange={onShapeChange}
onSourceModeChange={onSourceModeChange}
onUploadImage={onUploadImage}
onUploadModel={onUploadModel}
onUploadSource={onUploadSource}
previewDistance={previewDistance}
settings={settings}
shapeOptions={shapeOptions}
/>
) : null}
{activeTab === 'animations' ? (
{visible && activeTab === 'animations' ? (
<AnimationsTab
onAnimationSettingsChange={onAnimationSettingsChange}
settings={settings}
/>
) : null}
{activeTab === 'export' ? (
{visible && activeTab === 'export' ? (
<ExportTab
defaultExportName={defaultExportName}
exportBackground={exportBackground}
exportName={exportName}
imageFileName={imageFileName}
onExportHalftoneImage={onExportHalftoneImage}
onExportBackgroundChange={onExportBackgroundChange}
onExportHtml={onExportHtml}
onExportNameChange={onExportNameChange}
onExportReact={onExportReact}
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import {
disposeGeometryCache,
getGeometryForSpec,
} from '@/app/halftone/_lib/geometry-registry';
import { REFERENCE_PREVIEW_DISTANCE } from '@/app/halftone/_lib/footprint';
import {
deriveExportComponentName,
generateReactComponent,
@@ -22,8 +23,17 @@ import {
DEFAULT_SHAPE_HALFTONE_SETTINGS,
createInitialHalftoneStudioState,
halftoneStudioReducer,
type HalftoneExportPose,
type HalftoneGeometrySpec,
type HalftoneModelLoader,
type HalftoneSourceMode,
normalizeHalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import {
buildShareUrl,
decodeShareState,
encodeShareState,
} from '@/app/halftone/_lib/share';
import { Logo as LogoIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
@@ -38,128 +48,11 @@ import {
} from 'react';
import type * as THREE from 'three';
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 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;
}
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>;
const DESKTOP_CONTROLS_PANEL_WIDTH = 320;
const DESKTOP_CONTROLS_PANEL_OFFSET = 20;
const DESKTOP_CONTROLS_PANEL_FOOTPRINT =
DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET;
const StudioShell = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
@@ -169,6 +62,21 @@ const StudioShell = styled.div<{ $background: string }>`
width: 100%;
`;
const ContentRegion = styled.div<{ $panelOpen: boolean }>`
height: 100%;
margin-right: auto;
position: relative;
transition: width 0.18s ease;
width: ${(props) =>
props.$panelOpen
? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)`
: '100%'};
@media (max-width: ${theme.breakpoints.md - 1}px) {
width: 100%;
}
`;
const CanvasLayer = styled.div`
height: 100%;
width: 100%;
@@ -192,7 +100,7 @@ const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>`
left: 50%;
opacity: ${(props) => (props.$visible ? 1 : 0)};
pointer-events: none;
position: fixed;
position: absolute;
top: 20px;
transform: translateX(-50%);
transition: opacity 0.3s ease;
@@ -218,7 +126,7 @@ const Status = styled.div<{
left: 50%;
opacity: ${(props) => (props.$visible ? 1 : 0)};
pointer-events: none;
position: fixed;
position: absolute;
top: 44px;
transform: translateX(-50%);
transition: opacity 0.2s ease;
@@ -226,8 +134,12 @@ const Status = styled.div<{
`;
const ControlsPositioner = styled.div`
align-items: flex-start;
bottom: 20px;
display: flex;
height: calc(100vh - 40px);
justify-content: flex-end;
pointer-events: none;
position: fixed;
right: 20px;
top: 20px;
@@ -242,14 +154,20 @@ const ControlsPositioner = styled.div`
}
`;
const ControlsPanelFrame = styled.div<{ $visible: boolean }>`
height: ${(props) => (props.$visible ? '100%' : 'auto')};
pointer-events: auto;
@media (max-width: ${theme.breakpoints.md - 1}px) {
width: ${(props) => (props.$visible ? '100%' : 'auto')};
}
`;
const HiddenFileInput = styled.input`
display: none;
`;
const DEFAULT_IMAGE_ASSET_PATH = '/images/shared/halftone/twenty-logo.svg';
const DEFAULT_IMAGE_FILENAME = 'twenty-logo.svg';
const EXPORTED_PRESET_PREVIEW_DISTANCE = 4;
type PendingFilePicker = {
resolve: (file: File | null) => void;
};
@@ -362,10 +280,12 @@ export function HalftoneStudio() {
undefined,
createInitialHalftoneStudioState,
);
const [controlsVisible, setControlsVisible] = useState(true);
const [previewDistance, setPreviewDistance] = useState(7);
const [activeGeometry, setActiveGeometry] = useState<THREE.BufferGeometry>(
() => createFallbackGeometry(),
);
const [exportBackground, setExportBackground] = useState(false);
const [exportName, setExportName] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
@@ -415,14 +335,25 @@ export function HalftoneStudio() {
),
[selectedShape, state.importedFiles],
);
const defaultExportName = useMemo(
() =>
deriveExportComponentName(
selectedShape,
selectedImportedFile ?? undefined,
),
[selectedImportedFile, selectedShape],
);
const defaultExportName = useMemo(() => {
if (state.settings.sourceMode === 'image') {
if (!imageFile || imageFile.name === DEFAULT_IMAGE_FILENAME) {
return 'TwentyImage';
}
return deriveExportComponentName(undefined, imageFile);
}
return deriveExportComponentName(
selectedShape,
selectedImportedFile ?? undefined,
);
}, [
imageFile,
selectedImportedFile,
selectedShape,
state.settings.sourceMode,
]);
useEffect(() => {
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
@@ -430,6 +361,73 @@ export function HalftoneStudio() {
};
}, [state.settings.halftone, state.settings.sourceMode]);
// Hydrate from URL hash on first mount. Done in an effect (not the reducer
// initializer) so the server-rendered HTML matches the client and we don't
// touch `window` during render.
const hashHydratedReference = useRef(false);
useEffect(() => {
if (hashHydratedReference.current) {
return;
}
hashHydratedReference.current = true;
const decoded = decodeShareState(window.location.hash);
if (!decoded) {
return;
}
dispatch({ type: 'replaceSettings', value: decoded.settings });
setPreviewDistance(decoded.previewDistance);
setExportName(decoded.exportName);
}, []);
// Mirror the current design state to the URL hash so a refresh (and a copy
// of the URL) restores the same look. We skip the very first effect run so
// the URL stays clean until the user actually changes something.
const hashSyncInitializedReference = useRef(false);
useEffect(() => {
if (!hashSyncInitializedReference.current) {
hashSyncInitializedReference.current = true;
return;
}
const timeout = window.setTimeout(() => {
const hash = encodeShareState({
settings: state.settings,
previewDistance,
exportName,
});
const nextUrl = `${window.location.pathname}${window.location.search}#${hash}`;
window.history.replaceState(null, '', nextUrl);
}, 250);
return () => window.clearTimeout(timeout);
}, [state.settings, previewDistance, exportName]);
const handleCopyShareLink = useCallback(() => {
const url = buildShareUrl({
settings: state.settings,
previewDistance,
exportName,
});
void navigator.clipboard
.writeText(url)
.then(() => {
dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' });
window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000);
})
.catch(() => {
dispatch({
type: 'setStatus',
message: 'Could not copy the share link.',
isError: true,
});
});
}, [exportName, previewDistance, state.settings]);
const openFilePicker = useCallback((accept: string) => {
return new Promise<File | null>((resolve) => {
const input = fileInputReference.current;
@@ -542,41 +540,8 @@ export function HalftoneStudio() {
}
}, []);
const requestImportedFile = useCallback(
async (shape: HalftoneGeometrySpec) => {
const existingFile = state.importedFiles[shape.key];
if (existingFile) {
return existingFile;
}
dispatch({
type: 'setStatus',
message: `Select ${shape.label} to import it.`,
});
const file = await openFilePicker(
shape.extensions?.join(',') ?? '.fbx,.glb',
);
if (!file) {
dispatch({ type: 'clearStatus' });
return null;
}
dispatch({
type: 'setImportedFile',
key: shape.key,
file,
});
return file;
},
[openFilePicker, state.importedFiles],
);
const handleShapeChange = useCallback(
async (key: string) => {
(key: string) => {
const shape = state.geometrySpecs.find(
(candidate) => candidate.key === key,
);
@@ -585,67 +550,86 @@ export function HalftoneStudio() {
return;
}
if (shape.kind === 'imported' && !state.importedFiles[shape.key]) {
const file = await requestImportedFile(shape);
if (!file) {
return;
}
}
dispatch({
type: 'setShapeKey',
value: key,
});
},
[requestImportedFile, state.geometrySpecs, state.importedFiles],
[state.geometrySpecs],
);
const handleUploadModel = useCallback(async () => {
const file = await openFilePicker('.fbx,.glb');
const activateUploadedModel = useCallback(
(file: File) => {
const currentDashColor = state.settings.halftone.dashColor;
const extension = file.name.split('.').pop()?.toLowerCase();
const loader = extension === 'glb' ? 'glb' : 'fbx';
const nextShape: HalftoneGeometrySpec = {
key: `userUpload_${Date.now()}`,
label: file.name,
kind: 'imported',
loader,
filename: file.name,
description: `${loader.toUpperCase()} model`,
extensions: [`.${loader}`],
userProvided: true,
};
dispatch({
type: 'registerImportedFile',
spec: nextShape,
file,
activate: true,
});
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
...state.settings.halftone,
};
dispatch({ type: 'setSourceMode', value: 'shape' });
dispatch({
type: 'patchHalftone',
value: {
...halftoneBySourceModeReference.current.shape,
dashColor: currentDashColor,
},
});
},
[state.settings.halftone, state.settings.sourceMode],
);
const activateUploadedImage = useCallback(
(file: File) => {
const currentDashColor = state.settings.halftone.dashColor;
setImageFile(file);
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
...state.settings.halftone,
};
dispatch({ type: 'setSourceMode', value: 'image' });
dispatch({
type: 'patchHalftone',
value: {
...halftoneBySourceModeReference.current.image,
dashColor: currentDashColor,
},
});
},
[state.settings.halftone, state.settings.sourceMode],
);
const handleUploadSource = useCallback(async () => {
const file = await openFilePicker(
'.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg',
);
if (!file) {
return;
}
const extension = file.name.split('.').pop()?.toLowerCase();
const loader = extension === 'glb' ? 'glb' : 'fbx';
const nextShape: HalftoneGeometrySpec = {
key: `userUpload_${Date.now()}`,
label: file.name,
kind: 'imported',
loader,
filename: file.name,
description: `${loader.toUpperCase()} model`,
extensions: [`.${loader}`],
userProvided: true,
};
dispatch({
type: 'registerImportedFile',
spec: nextShape,
file,
activate: true,
});
}, [openFilePicker]);
const handleUploadImage = useCallback(async () => {
const file = await openFilePicker('.png,.jpg,.jpeg,.webp,.gif');
if (!file) {
if (/\.(png|jpe?g|webp|gif|svg)$/i.test(file.name)) {
activateUploadedImage(file);
return;
}
setImageFile(file);
halftoneBySourceModeReference.current[state.settings.sourceMode] = {
...state.settings.halftone,
};
dispatch({ type: 'setSourceMode', value: 'image' });
dispatch({
type: 'patchHalftone',
value: { ...halftoneBySourceModeReference.current.image },
});
}, [openFilePicker, state.settings.halftone, state.settings.sourceMode]);
activateUploadedModel(file);
}, [activateUploadedImage, activateUploadedModel, openFilePicker]);
const loadDefaultImageFile = useCallback(async () => {
if (defaultImageFileReference.current) {
@@ -711,7 +695,7 @@ export function HalftoneStudio() {
const handleImportPreset = useCallback(async () => {
const selectedFiles = await openPresetPicker(
'.tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif',
'.tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg',
);
const presetFile =
selectedFiles.find((file) => /\.(tsx|html)$/i.test(file.name)) ?? null;
@@ -799,7 +783,7 @@ export function HalftoneStudio() {
exportPoseReference.current = preset.initialPose;
setCanvasInitialPose(preset.initialPose);
setPreviewDistance(EXPORTED_PRESET_PREVIEW_DISTANCE);
setPreviewDistance(preset.previewDistance ?? REFERENCE_PREVIEW_DISTANCE);
setExportName(
preset.componentName ?? presetFile.name.replace(/\.(tsx|html)$/i, ''),
);
@@ -840,6 +824,9 @@ export function HalftoneStudio() {
.toLowerCase();
const isImageMode = state.settings.sourceMode === 'image';
const importedFile = selectedImportedFile;
const exportBackgroundColor = exportBackground
? state.settings.background.color
: 'transparent';
const modelFilename = importedFile
? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}`
@@ -857,8 +844,10 @@ export function HalftoneStudio() {
componentName,
modelFilename,
exportPoseReference.current,
previewDistance,
importedFile ?? undefined,
imageExportFilename,
exportBackgroundColor,
),
);
@@ -870,7 +859,9 @@ export function HalftoneStudio() {
}, [
defaultExportName,
exportName,
exportBackground,
imageFile,
previewDistance,
selectedImportedFile,
selectedShape,
state.settings,
@@ -879,20 +870,32 @@ export function HalftoneStudio() {
const handleExportHalftoneImage = useCallback(
async (width: number, height: number) => {
const snapshotFn = snapshotReference.current;
const componentName = exportName || defaultExportName;
const kebabName = componentName
.replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase();
if (!snapshotFn) {
return;
}
const blob = await snapshotFn(width, height);
const blob = await snapshotFn(width, height, {
backgroundColor: state.settings.background.color,
includeBackground: exportBackground,
});
if (!blob) {
return;
}
downloadBlob(`halftone-${width}x${height}.png`, blob);
downloadBlob(`${kebabName}-${width}x${height}.png`, blob);
},
[],
[
defaultExportName,
exportBackground,
exportName,
state.settings.background.color,
],
);
const handleExportHtml = useCallback(async () => {
@@ -902,6 +905,9 @@ export function HalftoneStudio() {
.toLowerCase();
const isImageMode = state.settings.sourceMode === 'image';
const importedFile = selectedImportedFile;
const exportBackgroundColor = exportBackground
? state.settings.background.color
: 'transparent';
const modelFilename = importedFile
? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}`
@@ -919,8 +925,10 @@ export function HalftoneStudio() {
componentName,
modelFilename,
exportPoseReference.current,
previewDistance,
importedFile ?? undefined,
imageExportFilename,
exportBackgroundColor,
),
);
@@ -930,7 +938,9 @@ export function HalftoneStudio() {
}, [
defaultExportName,
exportName,
exportBackground,
imageFile,
previewDistance,
selectedImportedFile,
selectedShape,
state.settings,
@@ -961,8 +971,10 @@ export function HalftoneStudio() {
}, [imageFile]);
useEffect(() => {
const geometryCache = geometryCacheReference.current;
return () => {
disposeGeometryCache(geometryCacheReference.current);
disposeGeometryCache(geometryCache);
};
}, []);
@@ -1041,97 +1053,109 @@ export function HalftoneStudio() {
/>
</LogoLink>
<Hint $tone={backgroundTone} $visible={state.showHint}>
{state.settings.sourceMode === 'image'
? 'Drag to shift the halftone pattern'
: 'Click & drag to rotate'}
</Hint>
<Status
$error={state.statusIsError}
$tone={backgroundTone}
$visible={state.statusMessage.length > 0}
>
{state.statusMessage}
</Status>
<ContentRegion $panelOpen={controlsVisible}>
<Hint $tone={backgroundTone} $visible={state.showHint}>
{state.settings.sourceMode === 'image'
? 'Hover to relight the halftone'
: state.settings.animation.followDragEnabled
? 'Click & drag to rotate'
: state.settings.animation.followHoverEnabled
? 'Move the pointer to tilt the object'
: state.settings.animation.autoRotateEnabled
? 'Idle auto-rotate is active'
: 'Open Animations to add motion'}
</Hint>
<Status
$error={state.statusIsError}
$tone={backgroundTone}
$visible={state.statusMessage.length > 0}
>
{state.statusMessage}
</Status>
<CanvasLayer>
<HalftoneCanvas
geometry={activeGeometry}
initialPose={canvasInitialPose}
imageElement={imageElement}
onFirstInteraction={handleFirstInteraction}
onPoseChange={handlePoseChange}
previewDistance={previewDistance}
settings={state.settings}
snapshotRef={snapshotReference}
/>
</CanvasLayer>
<CanvasLayer>
<HalftoneCanvas
geometry={activeGeometry}
initialPose={canvasInitialPose}
imageElement={imageElement}
onFirstInteraction={handleFirstInteraction}
onPoseChange={handlePoseChange}
previewDistance={previewDistance}
settings={state.settings}
snapshotRef={snapshotReference}
/>
</CanvasLayer>
</ContentRegion>
<ControlsPositioner>
<ControlsPanel
activeTab={state.activeTab}
defaultExportName={defaultExportName}
exportName={exportName}
imageFileName={imageFile?.name ?? null}
onAnimationSettingsChange={(value) =>
dispatch({ type: 'patchAnimation', value })
}
onDashColorChange={(value) =>
dispatch({
type: 'patchHalftone',
value: { dashColor: value },
})
}
onExportHalftoneImage={(width, height) => {
void handleExportHalftoneImage(width, height);
}}
onExportHtml={() => {
void handleExportHtml();
}}
onExportNameChange={setExportName}
onExportReact={handleExportReact}
onImportPreset={() => {
void handleImportPreset();
}}
onBackgroundChange={(value) =>
dispatch({ type: 'patchBackground', value })
}
onHalftoneChange={(value) =>
dispatch({ type: 'patchHalftone', value })
}
onLightingChange={(value) =>
dispatch({ type: 'patchLighting', value })
}
onMaterialChange={(value) =>
dispatch({ type: 'patchMaterial', value })
}
onPreviewDistanceChange={setPreviewDistance}
onShapeChange={(value) => {
void handleShapeChange(value);
}}
onSourceModeChange={handleSourceModeChange}
onTabChange={(value) => dispatch({ type: 'setTab', value })}
onUploadImage={() => {
void handleUploadImage();
}}
onUploadModel={() => {
void handleUploadModel();
}}
previewDistance={previewDistance}
selectedShape={selectedShape}
settings={state.settings}
shapeOptions={shapeOptions}
/>
<ControlsPanelFrame $visible={controlsVisible}>
<ControlsPanel
activeTab={state.activeTab}
defaultExportName={defaultExportName}
exportBackground={exportBackground}
exportName={exportName}
imageFileName={imageFile?.name ?? null}
onAnimationSettingsChange={(value) =>
dispatch({ type: 'patchAnimation', value })
}
onCopyShareLink={handleCopyShareLink}
onDashColorChange={(value) =>
dispatch({
type: 'patchHalftone',
value: { dashColor: value },
})
}
onExportHalftoneImage={(width, height) => {
void handleExportHalftoneImage(width, height);
}}
onExportBackgroundChange={setExportBackground}
onExportHtml={() => {
void handleExportHtml();
}}
onExportNameChange={setExportName}
onExportReact={handleExportReact}
onImportPreset={() => {
void handleImportPreset();
}}
onBackgroundChange={(value) =>
dispatch({ type: 'patchBackground', value })
}
onHalftoneChange={(value) =>
dispatch({ type: 'patchHalftone', value })
}
onLightingChange={(value) =>
dispatch({ type: 'patchLighting', value })
}
onMaterialChange={(value) =>
dispatch({ type: 'patchMaterial', value })
}
onPreviewDistanceChange={setPreviewDistance}
onShapeChange={(value) => {
void handleShapeChange(value);
}}
onSourceModeChange={handleSourceModeChange}
onTabChange={(value) => dispatch({ type: 'setTab', value })}
onToggleVisibility={() => setControlsVisible((visible) => !visible)}
onUploadSource={() => {
void handleUploadSource();
}}
previewDistance={previewDistance}
selectedShape={selectedShape}
settings={state.settings}
shapeOptions={shapeOptions}
visible={controlsVisible}
/>
</ControlsPanelFrame>
</ControlsPositioner>
<HiddenFileInput
accept=".fbx,.glb,.png,.jpg,.jpeg,.webp,.gif"
accept=".fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg"
onChange={handleFileInputChange}
ref={fileInputReference}
type="file"
/>
<HiddenFileInput
accept=".tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif"
accept=".tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg"
multiple
onChange={handlePresetFileInputChange}
ref={presetFileInputReference}
@@ -5,6 +5,7 @@ import {
formatDecimal,
formatPercent,
} from '@/app/halftone/_lib/formatters';
import type { HalftoneStudioSettings } from '@/app/halftone/_lib/state';
import {
ControlGrid,
LabelWithTooltip,
@@ -17,104 +18,6 @@ import {
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']>,
@@ -185,69 +88,6 @@ export function AnimationsTab({
</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>
</>
) : (
<>
@@ -269,7 +109,7 @@ export function AnimationsTab({
{animation.autoRotateEnabled ? (
<>
<SliderControl
max={1.5}
max={4}
min={0.05}
onChange={(event) =>
onAnimationSettingsChange({
@@ -1,120 +1,65 @@
'use client';
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
import {
DEFAULT_GLASS_MATERIAL_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
type HalftoneBackgroundSettings,
type HalftoneSourceMode,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { styled } from '@linaria/react';
import {
ColorControlLabel,
ColorControlRow,
ColorField,
ControlGrid,
SelectControl,
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';
const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg';
const DEFAULT_IMAGE_OPTION_LABEL = 'Twenty image';
const IMAGE_SOURCE_VALUE = '__image__';
interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
const ColorSectionHeader = styled(ColorControlRow)`
margin-bottom: 10px;
interface HalftoneMaterialSettings {
roughness: number;
metalness: number;
}
& > ${SectionTitle} {
margin-bottom: 0;
}
`;
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;
}
const ColorSwapButton = styled.button`
align-items: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.34);
cursor: pointer;
display: inline-flex;
height: 16px;
justify-content: center;
padding: 0;
justify-self: end;
transition: color 0.15s ease;
width: 16px;
interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
&:hover {
color: rgba(255, 255, 255, 0.78);
}
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;
}
&:focus-visible {
outline: 1px solid rgba(255, 255, 255, 0.35);
outline-offset: 1px;
}
`;
type DesignTabProps = {
imageFileName: string | null;
@@ -132,8 +77,7 @@ type DesignTabProps = {
onPreviewDistanceChange: (value: number) => void;
onShapeChange: (value: string) => void;
onSourceModeChange: (value: HalftoneSourceMode) => void;
onUploadImage: () => void;
onUploadModel: () => void;
onUploadSource: () => void;
previewDistance: number;
settings: HalftoneStudioSettings;
shapeOptions: Array<{ label: string; value: string }>;
@@ -149,139 +93,124 @@ export function DesignTab({
onPreviewDistanceChange,
onShapeChange,
onSourceModeChange,
onUploadImage,
onUploadModel,
onUploadSource,
previewDistance,
settings,
shapeOptions,
}: DesignTabProps) {
const isImageMode = settings.sourceMode === 'image';
const selectedSourceValue = isImageMode
? IMAGE_SOURCE_VALUE
: settings.shapeKey;
const imageOptionLabel =
imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME
? DEFAULT_IMAGE_OPTION_LABEL
: imageFileName;
const handleSwapColors = () => {
const nextDashColor = settings.background.color;
const nextBackgroundColor = settings.halftone.dashColor;
onDashColorChange(nextDashColor);
onBackgroundChange({ color: nextBackgroundColor });
};
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>
<ShapeRow>
<span>Source</span>
<SelectInput
onChange={(event) => {
const nextValue = event.target.value;
{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}
>
if (nextValue === IMAGE_SOURCE_VALUE) {
onSourceModeChange('image');
return;
}
onSourceModeChange('shape');
onShapeChange(nextValue);
}}
value={selectedSourceValue}
>
<optgroup label="3D">
{shapeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectInput>
<UploadButton
onClick={onUploadModel}
title="Upload model (.fbx / .glb)"
type="button"
</optgroup>
<optgroup label="Images">
<option value={IMAGE_SOURCE_VALUE}>{imageOptionLabel}</option>
</optgroup>
</SelectInput>
<UploadButton
onClick={onUploadSource}
title="Upload image or model (.png / .jpg / .webp / .svg / .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"
>
<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>
)}
<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>
<Section>
<SectionTitle>Visualization</SectionTitle>
<ControlGrid>
<SliderControl
max={12}
min={4}
onChange={(event) =>
onPreviewDistanceChange(Number(event.target.value))
}
step={0.1}
value={previewDistance}
valueLabel={formatDecimal(previewDistance, 1)}
>
Distance
</SliderControl>
</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>Image</SectionTitle>
<ControlGrid>
<SliderControl
max={2.5}
min={0.4}
onChange={(event) =>
onHalftoneChange({
imageContrast: Number(event.target.value),
})
}
step={0.01}
value={settings.halftone.imageContrast}
valueLabel={formatDecimal(settings.halftone.imageContrast, 2)}
>
Contrast
</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>
@@ -354,6 +283,22 @@ export function DesignTab({
<Section>
<SectionTitle>Material</SectionTitle>
<ControlGrid>
<SelectControl
onChange={(event) =>
onMaterialChange(
event.target.value === 'glass'
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS,
)
}
options={[
{ label: 'Solid', value: 'solid' },
{ label: 'Glass', value: 'glass' },
]}
value={settings.material.surface}
>
Surface
</SelectControl>
<SliderControl
max={1}
min={0}
@@ -378,6 +323,55 @@ export function DesignTab({
>
Metalness
</SliderControl>
{settings.material.surface === 'glass' ? (
<>
<SliderControl
max={20}
min={1.1}
onChange={(event) =>
onMaterialChange({
thickness: Number(event.target.value),
})
}
step={0.1}
value={settings.material.thickness}
valueLabel={formatDecimal(settings.material.thickness, 0)}
>
Thickness
</SliderControl>
<SliderControl
max={3}
min={1.1}
onChange={(event) =>
onMaterialChange({
refraction: Number(event.target.value),
})
}
step={0.01}
value={settings.material.refraction}
valueLabel={formatDecimal(settings.material.refraction)}
>
Refraction
</SliderControl>
<SliderControl
max={5}
min={0}
onChange={(event) =>
onMaterialChange({
environmentPower: Number(event.target.value),
})
}
step={0.01}
value={settings.material.environmentPower}
valueLabel={formatDecimal(
settings.material.environmentPower,
2,
)}
>
Power
</SliderControl>
</>
) : null}
</ControlGrid>
</Section>
</>
@@ -395,120 +389,74 @@ export function DesignTab({
{settings.halftone.enabled ? (
<ControlGrid>
<SliderControl
max={150}
min={30}
max={72}
min={8}
onChange={(event) =>
onHalftoneChange({ numRows: Number(event.target.value) })
onHalftoneChange({ scale: Number(event.target.value) })
}
value={settings.halftone.numRows}
valueLabel={String(settings.halftone.numRows)}
step={0.01}
value={settings.halftone.scale}
valueLabel={formatDecimal(settings.halftone.scale, 2)}
>
Rows
Scale
</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}
max={1.5}
min={-1.5}
onChange={(event) =>
onHalftoneChange({ power: Number(event.target.value) })
}
step={0.1}
step={0.01}
value={settings.halftone.power}
valueLabel={formatDecimal(settings.halftone.power, 1)}
valueLabel={formatDecimal(settings.halftone.power, 2)}
>
Power
</SliderControl>
<SliderControl
max={3}
min={0}
max={1.4}
min={0.05}
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) })
onHalftoneChange({ width: Number(event.target.value) })
}
step={0.01}
value={settings.halftone.baseInk}
valueLabel={formatDecimal(settings.halftone.baseInk)}
value={settings.halftone.width}
valueLabel={formatDecimal(settings.halftone.width, 2)}
>
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
Width
</SliderControl>
</ControlGrid>
) : null}
</Section>
<Section>
<SectionTitle>Colors</SectionTitle>
<ColorSectionHeader>
<SectionTitle>Colors</SectionTitle>
<ColorSwapButton
aria-label="Swap dash and background colors"
onClick={handleSwapColors}
title="Swap dash and background colors"
type="button"
>
<svg
aria-hidden="true"
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M15 4l4 0l0 4" />
<path d="M14.75 9.25l4.25 -5.25" />
<path d="M5 19l4 -4" />
<path d="M15 19l4 0l0 -4" />
<path d="M5 5l14 14" />
</svg>
</ColorSwapButton>
</ColorSectionHeader>
<ControlGrid>
<ColorControlRow>
<ColorControlLabel>Dash color</ColorControlLabel>
@@ -1,140 +1,44 @@
'use client';
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import type {
HalftoneGeometrySpec,
HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { useState } from 'react';
import {
ExportButton,
ExportNameInput,
ExportNote,
ExportPreview,
LabelWithTooltip,
Section,
SectionTitle,
SelectControl,
SmallBody,
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';
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' },
];
const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg';
const DEFAULT_IMAGE_LABEL = 'Twenty image';
function sectionLabel(label: string, description: string) {
return <LabelWithTooltip description={description} label={label} />;
}
type ExportTabProps = {
defaultExportName: string;
exportBackground: boolean;
exportName: string;
imageFileName: string | null;
onExportHalftoneImage: (width: number, height: number) => void;
onExportBackgroundChange: (value: boolean) => void;
onExportHtml: () => void;
onExportNameChange: (value: string) => void;
onExportReact: () => void;
@@ -145,8 +49,11 @@ type ExportTabProps = {
export function ExportTab({
defaultExportName,
exportBackground,
exportName,
imageFileName,
onExportHalftoneImage,
onExportBackgroundChange,
onExportHtml,
onExportNameChange,
onExportReact,
@@ -160,6 +67,11 @@ export function ExportTab({
settings.animation,
settings.sourceMode,
);
const sourceLabel = isImageMode
? imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME
? DEFAULT_IMAGE_LABEL
: imageFileName
: (selectedShape?.label ?? settings.shapeKey);
const componentName = exportName || defaultExportName;
@@ -173,11 +85,39 @@ export function ExportTab({
return (
<TabContent>
<Section $first>
<SectionTitle>Download Image</SectionTitle>
<SmallBody>
Downloads a PNG snapshot of the current halftone effect at the selected
resolution.
</SmallBody>
<SectionTitle>
{sectionLabel(
'Export Name',
'Sets the base name used across the PNG, React component, and standalone HTML exports.',
)}
</SectionTitle>
<ExportNameInput
onChange={(event) => onExportNameChange(event.target.value)}
onClick={(event) => event.currentTarget.select()}
onFocus={(event) => event.currentTarget.select()}
placeholder={defaultExportName}
type="text"
value={componentName}
/>
<ToggleControl
checked={exportBackground}
label={sectionLabel(
'Export background',
'Includes the current background color in PNG, React component, and standalone HTML exports instead of keeping them transparent.',
)}
onChange={(event) => onExportBackgroundChange(event.target.checked)}
/>
</Section>
<Section>
<SectionTitle>
{sectionLabel(
'Download Image',
'Downloads a PNG snapshot of the current halftone effect at the selected resolution.',
)}
</SectionTitle>
<SelectControl
onChange={(event) => setResolution(event.target.value)}
@@ -197,18 +137,12 @@ export function ExportTab({
</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}
/>
<SectionTitle>
{sectionLabel(
'Code Exports',
'Downloads either a self-contained React component or standalone HTML with the current design and animation settings baked in. React exports require three.',
)}
</SectionTitle>
<ExportPreview>
<div>
@@ -230,7 +164,7 @@ export function ExportTab({
</div>
<br />
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Shape: ${selectedShape?.key ?? settings.shapeKey}`}
{`// Source: ${sourceLabel}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Animation: ${animationLabel}`}
@@ -239,7 +173,13 @@ export function ExportTab({
{`// Dash color: ${settings.halftone.dashColor}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Rows: ${settings.halftone.numRows}`}
{`// Scale: ${settings.halftone.scale.toFixed(2)}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Power: ${settings.halftone.power.toFixed(2)}`}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.35)' }}>
{`// Width: ${settings.halftone.width.toFixed(2)}`}
</div>
</ExportPreview>
@@ -260,12 +200,12 @@ export function ExportTab({
</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>
<SectionTitle>
{sectionLabel(
'Import Preset',
'Loads a previously exported TSX or HTML halftone preset. If that preset depends on a separate image or model file, select that asset in the picker too.',
)}
</SectionTitle>
<ExportButton onClick={onImportPreset} type="button">
Import Exported Preset
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
const TAB_LABEL_WIDTH = 72;
export const PanelShell = styled.aside`
export const PanelShell = styled.aside<{ $collapsed?: boolean }>`
background: rgba(18, 18, 22, 0.88);
backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -27,22 +27,24 @@ export const PanelShell = styled.aside`
flex-direction: column;
font-family: ${theme.font.family.sans};
font-size: 11px;
height: 100%;
height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
overflow: hidden;
width: min(320px, calc(100vw - 32px));
width: ${(props) =>
props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'};
@media (max-width: ${theme.breakpoints.md - 1}px) {
height: 100%;
width: 100%;
height: ${(props) => (props.$collapsed ? 'auto' : '100%')};
width: ${(props) => (props.$collapsed ? 'auto' : '100%')};
}
`;
export const TabsBar = styled.div`
export const TabsBar = styled.div<{ $collapsed?: boolean }>`
align-items: center;
display: flex;
flex-shrink: 0;
gap: 6px;
margin: 0;
padding: 12px 12px 0;
padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')};
`;
export const TabButton = styled.button<{ $active: boolean }>`
@@ -65,9 +67,7 @@ export const TabButton = styled.button<{ $active: boolean }>`
&:hover {
background: ${(props) =>
props.$active
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(255, 255, 255, 0.04)'};
props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'};
color: ${(props) =>
props.$active
? 'rgba(255, 255, 255, 0.94)'
@@ -250,8 +250,9 @@ export const SelectInput = styled.select`
cursor: pointer;
font-family: ${theme.font.family.sans};
font-size: 11px;
height: 24px;
outline: none;
padding: 7px 34px 7px 10px;
padding: 0 34px 0 10px;
transition: border-color 0.15s ease;
width: 100%;
@@ -527,7 +528,9 @@ export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) {
aria-label={`${ariaLabel} hex value`}
maxLength={7}
onBlur={commitDraftValue}
onClick={(event) => event.currentTarget.select()}
onChange={(event) => setDraftValue(event.target.value.toUpperCase())}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -574,10 +577,10 @@ export const UploadButton = styled.button`
display: flex;
flex-shrink: 0;
font-size: 13px;
height: 32px;
height: 24px;
justify-content: center;
transition: all 0.15s ease;
width: 32px;
width: 24px;
&:hover {
background: rgba(255, 255, 255, 0.12);
@@ -629,30 +632,53 @@ export const ExportPreview = styled.div`
export const ExportButton = styled.button<{ $primary?: boolean }>`
align-items: center;
background: ${(props) =>
props.$primary ? '#4A38F5' : 'rgba(255, 255, 255, 0.08)'};
border: 1px solid
${(props) =>
props.$primary ? 'rgba(116, 98, 255, 0.7)' : 'rgba(255, 255, 255, 0.12)'};
props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'};
border: none;
border-radius: 8px;
color: ${(props) => (props.$primary ? '#fff' : 'rgba(255, 255, 255, 0.8)')};
color: ${(props) =>
props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'};
cursor: pointer;
display: flex;
font-family: ${theme.font.family.sans};
font-size: 11px;
font-weight: 600;
font-weight: ${theme.font.weight.medium};
gap: 8px;
justify-content: center;
line-height: normal;
margin-top: ${(props) => (props.$primary ? '0' : '8px')};
min-height: 31px;
padding: 7px 12px;
transition: all 0.2s ease;
transition:
background-color 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
width: 100%;
&:hover {
background: ${(props) =>
props.$primary ? '#5a4af7' : 'rgba(255, 255, 255, 0.14)'};
border-color: ${(props) =>
props.$primary ? 'rgba(130, 114, 255, 0.9)' : 'rgba(255, 255, 255, 0.22)'};
props.$primary
? 'rgba(255, 255, 255, 0.28)'
: 'rgba(255, 255, 255, 0.24)'};
color: rgba(255, 255, 255, 0.92);
}
&:active {
background: ${(props) =>
props.$primary
? 'rgba(255, 255, 255, 0.3)'
: 'rgba(255, 255, 255, 0.28)'};
transform: translateY(1px);
}
&:focus-visible {
outline: 1px solid rgba(255, 255, 255, 0.36);
outline-offset: 2px;
}
&:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.36);
cursor: not-allowed;
transform: none;
}
`;
@@ -721,9 +747,7 @@ function clampAndSnapValue(
const precision = getStepPrecision(step);
const snappedValue = Math.round((clampedValue - min) / step) * step + min;
return Number(
Math.min(Math.max(snappedValue, min), max).toFixed(precision),
);
return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision));
}
export function SliderControl({
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
import { parseExportedPreset } from '@/app/halftone/_lib/exporters';
import {
HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
getContainedImageRect,
getImageFootprintScale,
getMeshFootprintScale,
REFERENCE_PREVIEW_DISTANCE,
} from '@/app/halftone/_lib/footprint';
import { DEFAULT_HALFTONE_SETTINGS } from '@/app/halftone/_lib/state';
import * as THREE from 'three';
describe('halftone footprint helpers', () => {
it('computes the visible contained image rect', () => {
expect(
getContainedImageRect({
imageHeight: 500,
imageWidth: 1000,
viewportHeight: 800,
viewportWidth: 800,
zoom: 1,
}),
).toEqual({
x: 0,
y: 200,
width: 800,
height: 400,
});
});
it('derives image footprint scale from preview distance', () => {
expect(
getImageFootprintScale({
imageHeight: 1000,
imageWidth: 1000,
previewDistance: REFERENCE_PREVIEW_DISTANCE,
viewportHeight: 600,
viewportWidth: 800,
}),
).toBeCloseTo(1);
expect(
getImageFootprintScale({
imageHeight: 1000,
imageWidth: 1000,
previewDistance: 8,
viewportHeight: 600,
viewportWidth: 800,
}),
).toBeCloseTo(0.5, 5);
});
it('derives mesh footprint scale from projected bounds', () => {
const camera = new THREE.PerspectiveCamera(45, 800 / 600, 0.1, 100);
const target = new THREE.Vector3(0, 0, 0);
camera.position.set(0, 0, 8);
camera.lookAt(target);
camera.updateProjectionMatrix();
camera.updateMatrixWorld(true);
expect(
getMeshFootprintScale({
camera,
localBounds: new THREE.Box3(
new THREE.Vector3(-0.5, -0.5, -0.5),
new THREE.Vector3(0.5, 0.5, 0.5),
),
lookAtTarget: target,
meshMatrixWorld: new THREE.Matrix4(),
viewportHeight: 600,
viewportWidth: 800,
}),
).toBeCloseTo(0.5, 1);
});
it('keeps the exported runtime helpers aligned with the typed helpers', () => {
const runtime = new Function(
'THREE',
`${HALFTONE_FOOTPRINT_RUNTIME_SOURCE}
return { getContainedImageRect, getImageFootprintScale, getMeshFootprintScale };`,
)(THREE) as {
getContainedImageRect: typeof getContainedImageRect;
getImageFootprintScale: typeof getImageFootprintScale;
getMeshFootprintScale: typeof getMeshFootprintScale;
};
const imageArgs = {
imageHeight: 1000,
imageWidth: 1600,
previewDistance: 6,
viewportHeight: 720,
viewportWidth: 1280,
};
expect(runtime.getContainedImageRect({ ...imageArgs, zoom: 1 })).toEqual(
getContainedImageRect({ ...imageArgs, zoom: 1 }),
);
expect(runtime.getImageFootprintScale(imageArgs)).toBeCloseTo(
getImageFootprintScale(imageArgs),
6,
);
const camera = new THREE.PerspectiveCamera(45, 1280 / 720, 0.1, 100);
const lookAtTarget = new THREE.Vector3(0, 0, 0);
camera.position.set(0, 0, 8);
camera.lookAt(lookAtTarget);
camera.updateProjectionMatrix();
camera.updateMatrixWorld(true);
const meshArgs = {
camera,
localBounds: new THREE.Box3(
new THREE.Vector3(-0.5, -0.5, -0.5),
new THREE.Vector3(0.5, 0.5, 0.5),
),
lookAtTarget,
meshMatrixWorld: new THREE.Matrix4(),
viewportHeight: 720,
viewportWidth: 1280,
};
expect(runtime.getMeshFootprintScale(meshArgs)).toBeCloseTo(
getMeshFootprintScale(meshArgs),
6,
);
});
});
describe('parseExportedPreset', () => {
it('falls back to the reference preview distance for legacy presets', () => {
const content = `
const settings = ${JSON.stringify(DEFAULT_HALFTONE_SETTINGS, null, 2)};
const shape = ${JSON.stringify(
{
filename: null,
key: 'torusKnot',
kind: 'builtin',
label: 'Torus Knot',
loader: null,
},
null,
2,
)};
const initialPose = ${JSON.stringify(
{
autoElapsed: 0,
rotateElapsed: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 0,
},
null,
2,
)};
const VIRTUAL_RENDER_HEIGHT = 768;
export default function LegacyHalftone() {
return null;
}
`;
expect(parseExportedPreset(content).previewDistance).toBe(
REFERENCE_PREVIEW_DISTANCE,
);
});
});
@@ -0,0 +1,494 @@
import * as THREE from 'three';
export interface HalftoneRect {
height: number;
width: number;
x: number;
y: number;
}
export interface ImageFootprintScaleArgs {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}
export interface ProjectedBoundsArgs {
camera: THREE.PerspectiveCamera;
localBounds: THREE.Box3;
meshMatrixWorld: THREE.Matrix4;
viewportHeight: number;
viewportWidth: number;
}
export interface MeshFootprintScaleArgs extends ProjectedBoundsArgs {
lookAtTarget: THREE.Vector3;
}
export const VIRTUAL_RENDER_HEIGHT = 768;
export const REFERENCE_PREVIEW_DISTANCE = 4;
const MIN_FOOTPRINT_SCALE = 0.001;
function clampRectToViewport(
rect: HalftoneRect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: HalftoneRect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function createBox3Corners(bounds: THREE.Box3) {
const { min, max } = bounds;
return [
new THREE.Vector3(min.x, min.y, min.z),
new THREE.Vector3(min.x, min.y, max.z),
new THREE.Vector3(min.x, max.y, min.z),
new THREE.Vector3(min.x, max.y, max.z),
new THREE.Vector3(max.x, min.y, min.z),
new THREE.Vector3(max.x, min.y, max.z),
new THREE.Vector3(max.x, max.y, min.z),
new THREE.Vector3(max.x, max.y, max.z),
];
}
export function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
export function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
export function getFootprintScaleFromRects(
currentRect: HalftoneRect | null,
referenceRect: HalftoneRect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
export function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: ImageFootprintScaleArgs) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
export function projectBox3ToViewport({
camera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
}: ProjectedBoundsArgs) {
if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) {
return null;
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
let hasProjectedCorner = false;
for (const corner of createBox3Corners(localBounds)) {
corner.applyMatrix4(meshMatrixWorld).project(camera);
if (
!Number.isFinite(corner.x) ||
!Number.isFinite(corner.y) ||
!Number.isFinite(corner.z)
) {
continue;
}
hasProjectedCorner = true;
const x = (corner.x * 0.5 + 0.5) * viewportWidth;
const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (!hasProjectedCorner) {
return null;
}
return clampRectToViewport(
{
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
viewportWidth,
viewportHeight,
);
}
export function getMeshFootprintScale({
camera,
localBounds,
lookAtTarget,
meshMatrixWorld,
viewportHeight,
viewportWidth,
}: MeshFootprintScaleArgs) {
const currentRect = projectBox3ToViewport({
camera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
});
const referenceCamera = camera.clone();
const currentOffset = referenceCamera.position.clone().sub(lookAtTarget);
const referenceOffset =
currentOffset.lengthSq() > 0
? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE)
: new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE);
referenceCamera.position.copy(lookAtTarget).add(referenceOffset);
referenceCamera.lookAt(lookAtTarget);
referenceCamera.updateProjectionMatrix();
referenceCamera.updateMatrixWorld(true);
const referenceRect = projectBox3ToViewport({
camera: referenceCamera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
// Keep the exported runtime string aligned with the typed helpers above.
// `footprint.test.ts` executes both implementations against the same inputs.
export const HALFTONE_FOOTPRINT_RUNTIME_SOURCE = String.raw`
const REFERENCE_PREVIEW_DISTANCE = ${REFERENCE_PREVIEW_DISTANCE};
const MIN_FOOTPRINT_SCALE = ${MIN_FOOTPRINT_SCALE};
function clampRectToViewport(rect, viewportWidth, viewportHeight) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function createBox3Corners(bounds) {
const { min, max } = bounds;
return [
new THREE.Vector3(min.x, min.y, min.z),
new THREE.Vector3(min.x, min.y, max.z),
new THREE.Vector3(min.x, max.y, min.z),
new THREE.Vector3(min.x, max.y, max.z),
new THREE.Vector3(max.x, min.y, min.z),
new THREE.Vector3(max.x, min.y, max.z),
new THREE.Vector3(max.x, max.y, min.z),
new THREE.Vector3(max.x, max.y, max.z),
];
}
function getImagePreviewZoom(previewDistance) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(currentRect, referenceRect) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function projectBox3ToViewport({
camera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
}) {
if (
localBounds.isEmpty() ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
let hasProjectedCorner = false;
for (const corner of createBox3Corners(localBounds)) {
corner.applyMatrix4(meshMatrixWorld).project(camera);
if (
!Number.isFinite(corner.x) ||
!Number.isFinite(corner.y) ||
!Number.isFinite(corner.z)
) {
continue;
}
hasProjectedCorner = true;
const x = (corner.x * 0.5 + 0.5) * viewportWidth;
const y = (1 - (corner.y * 0.5 + 0.5)) * viewportHeight;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (!hasProjectedCorner) {
return null;
}
return clampRectToViewport(
{
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
viewportWidth,
viewportHeight,
);
}
function getMeshFootprintScale({
camera,
localBounds,
lookAtTarget,
meshMatrixWorld,
viewportHeight,
viewportWidth,
}) {
const currentRect = projectBox3ToViewport({
camera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
});
const referenceCamera = camera.clone();
const currentOffset = referenceCamera.position.clone().sub(lookAtTarget);
const referenceOffset =
currentOffset.lengthSq() > 0
? currentOffset.setLength(REFERENCE_PREVIEW_DISTANCE)
: new THREE.Vector3(0, 0, REFERENCE_PREVIEW_DISTANCE);
referenceCamera.position.copy(lookAtTarget).add(referenceOffset);
referenceCamera.lookAt(lookAtTarget);
referenceCamera.updateProjectionMatrix();
referenceCamera.updateMatrixWorld(true);
const referenceRect = projectBox3ToViewport({
camera: referenceCamera,
localBounds,
meshMatrixWorld,
viewportHeight,
viewportWidth,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
`;
@@ -31,10 +31,6 @@ export function formatAnimationName(animation: {
activeModes.push('hoverLight');
}
if (animation.dragFlowEnabled) {
activeModes.push('dragSmear');
}
return activeModes.length > 0 ? activeModes.join(' + ') : 'still';
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,880 @@
import type { HalftoneMaterialSettings } from '@/app/halftone/_lib/state';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import * as THREE from 'three';
export type HalftoneMaterialAssets = {
glassBackgroundTexture: THREE.Texture;
glassEnvironmentTexture: THREE.Texture;
glassTransmissionScene: THREE.Scene;
solidEnvironmentTexture: THREE.Texture;
};
export class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial {
declare anisotropicBlur: number;
declare attenuationColor: THREE.Color;
declare attenuationDistance: number;
declare buffer: THREE.Texture | null;
declare chromaticAberration: number;
declare distortion: number;
declare distortionScale: number;
declare refractionEnvMap: THREE.Texture | null;
declare temporalDistortion: number;
declare thickness: number;
declare time: number;
declare _transmission: number;
declare useEnvMapRefraction: number;
private readonly halftoneUniforms: Record<string, { value: unknown }>;
public constructor(samples = 10) {
super();
this.halftoneUniforms = {
chromaticAberration: { value: 0.05 },
transmission: { value: 0 },
_transmission: { value: 1 },
transmissionMap: { value: null },
refractionEnvMap: { value: null },
useEnvMapRefraction: { value: 0 },
roughness: { value: 0 },
thickness: { value: 0 },
thicknessMap: { value: null },
attenuationDistance: { value: Infinity },
attenuationColor: { value: new THREE.Color('white') },
anisotropicBlur: { value: 0.1 },
time: { value: 0 },
distortion: { value: 0 },
distortionScale: { value: 0.5 },
temporalDistortion: { value: 0 },
buffer: { value: null },
};
this.customProgramCacheKey = () => `halftone-transmission-${samples}`;
this.onBeforeCompile = (shader) => {
shader.uniforms = {
...shader.uniforms,
...this.halftoneUniforms,
};
shader.defines ??= {};
if (this.anisotropy > 0) {
shader.defines.USE_ANISOTROPY = '';
}
shader.defines.USE_TRANSMISSION = '';
shader.fragmentShader =
`
uniform float chromaticAberration;
uniform float anisotropicBlur;
uniform float time;
uniform float distortion;
uniform float distortionScale;
uniform float temporalDistortion;
uniform sampler2D buffer;
vec3 random3(vec3 c) {
float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
vec3 r;
r.z = fract(512.0 * j);
j *= 0.125;
r.x = fract(512.0 * j);
j *= 0.125;
r.y = fract(512.0 * j);
return r - 0.5;
}
uint hash(uint x) {
x += (x << 10u);
x ^= (x >> 6u);
x += (x << 3u);
x ^= (x >> 11u);
x += (x << 15u);
return x;
}
uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); }
uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); }
uint hash(uvec4 v) {
return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w));
}
float floatConstruct(uint m) {
const uint ieeeMantissa = 0x007FFFFFu;
const uint ieeeOne = 0x3F800000u;
m &= ieeeMantissa;
m |= ieeeOne;
float f = uintBitsToFloat(m);
return f - 1.0;
}
float randomBase(float x) {
return floatConstruct(hash(floatBitsToUint(x)));
}
float randomBase(vec2 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float randomBase(vec3 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float randomBase(vec4 v) {
return floatConstruct(hash(floatBitsToUint(v)));
}
float rand(float seed) {
return randomBase(vec3(gl_FragCoord.xy, seed));
}
const float F3 = 0.3333333;
const float G3 = 0.1666667;
float snoise(vec3 p) {
vec3 s = floor(p + dot(p, vec3(F3)));
vec3 x = p - s + dot(s, vec3(G3));
vec3 e = step(vec3(0.0), x - x.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
vec3 x1 = x - i1 + G3;
vec3 x2 = x - i2 + 2.0 * G3;
vec3 x3 = x - 1.0 + 3.0 * G3;
vec4 w;
vec4 d;
w.x = dot(x, x);
w.y = dot(x1, x1);
w.z = dot(x2, x2);
w.w = dot(x3, x3);
w = max(0.6 - w, 0.0);
d.x = dot(random3(s), x);
d.y = dot(random3(s + i1), x1);
d.z = dot(random3(s + i2), x2);
d.w = dot(random3(s + 1.0), x3);
w *= w;
w *= w;
d *= w;
return dot(d, vec4(52.0));
}
float snoiseFractal(vec3 m) {
return 0.5333333 * snoise(m)
+ 0.2666667 * snoise(2.0 * m)
+ 0.1333333 * snoise(4.0 * m)
+ 0.0666667 * snoise(8.0 * m);
}
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <transmission_pars_fragment>',
`
#ifdef USE_TRANSMISSION
uniform float _transmission;
uniform float thickness;
uniform float attenuationDistance;
uniform vec3 attenuationColor;
uniform sampler2D refractionEnvMap;
uniform float useEnvMapRefraction;
#ifdef USE_TRANSMISSIONMAP
uniform sampler2D transmissionMap;
#endif
#ifdef USE_THICKNESSMAP
uniform sampler2D thicknessMap;
#endif
uniform vec2 transmissionSamplerSize;
uniform sampler2D transmissionSamplerMap;
uniform mat4 modelMatrix;
uniform mat4 projectionMatrix;
varying vec3 vWorldPosition;
vec3 getVolumeTransmissionRay(
const in vec3 n,
const in vec3 v,
const in float thicknessValue,
const in float ior,
const in mat4 modelMatrix
) {
vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior);
vec3 modelScale;
modelScale.x = length(vec3(modelMatrix[0].xyz));
modelScale.y = length(vec3(modelMatrix[1].xyz));
modelScale.z = length(vec3(modelMatrix[2].xyz));
return normalize(refractionVector) * thicknessValue * modelScale;
}
float applyIorToRoughness(
const in float roughnessValue,
const in float ior
) {
return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0);
}
vec2 directionToEquirectUv(const in vec3 direction) {
vec3 dir = normalize(direction);
vec2 uv = vec2(
atan(dir.z, dir.x) * 0.15915494309189535 + 0.5,
asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5
);
return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0));
}
vec4 getTransmissionSample(
const in vec2 fragCoord,
const in vec3 transmissionDirection,
const in float roughnessValue,
const in float ior
) {
if (useEnvMapRefraction > 0.5) {
return texture2D(
refractionEnvMap,
directionToEquirectUv(transmissionDirection)
);
}
float framebufferLod =
log2(transmissionSamplerSize.x) *
applyIorToRoughness(roughnessValue, ior);
return texture2D(buffer, fragCoord.xy);
}
vec3 applyVolumeAttenuation(
const in vec3 radiance,
const in float transmissionDistance,
const in vec3 attenuationColorValue,
const in float attenuationDistanceValue
) {
if (isinf(attenuationDistanceValue)) {
return radiance;
}
vec3 attenuationCoefficient =
-log(attenuationColorValue) / attenuationDistanceValue;
vec3 transmittance =
exp(-attenuationCoefficient * transmissionDistance);
return transmittance * radiance;
}
vec4 getIBLVolumeRefraction(
const in vec3 n,
const in vec3 v,
const in float roughnessValue,
const in vec3 diffuseColor,
const in vec3 specularColor,
const in float specularF90,
const in vec3 position,
const in mat4 modelMatrix,
const in mat4 viewMatrix,
const in mat4 projMatrix,
const in float ior,
const in float thicknessValue,
const in vec3 attenuationColorValue,
const in float attenuationDistanceValue
) {
vec3 transmissionRay = getVolumeTransmissionRay(
n,
v,
thicknessValue,
ior,
modelMatrix
);
vec3 refractedRayExit = position + transmissionRay;
vec4 ndcPos =
projMatrix * viewMatrix * vec4(refractedRayExit, 1.0);
vec2 refractionCoords = ndcPos.xy / ndcPos.w;
refractionCoords += 1.0;
refractionCoords /= 2.0;
vec3 transmissionDirection = normalize(transmissionRay);
vec4 transmittedLight = getTransmissionSample(
refractionCoords,
transmissionDirection,
roughnessValue,
ior
);
vec3 attenuatedColor = applyVolumeAttenuation(
transmittedLight.rgb,
length(transmissionRay),
attenuationColorValue,
attenuationDistanceValue
);
vec3 F = EnvironmentBRDF(
n,
v,
specularColor,
specularF90,
roughnessValue
);
return vec4(
(1.0 - F) * attenuatedColor * diffuseColor,
transmittedLight.a
);
}
#endif
`,
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <transmission_fragment>',
`
material.transmission = _transmission;
material.transmissionAlpha = 1.0;
material.thickness = thickness;
material.attenuationDistance = attenuationDistance;
material.attenuationColor = attenuationColor;
#ifdef USE_TRANSMISSIONMAP
material.transmission *= texture2D(transmissionMap, vUv).r;
#endif
#ifdef USE_THICKNESSMAP
material.thickness *= texture2D(thicknessMap, vUv).g;
#endif
vec3 pos = vWorldPosition;
float runningSeed = 0.0;
vec3 v = normalize(cameraPosition - pos);
vec3 n = inverseTransformDirection(normal, viewMatrix);
vec3 transmission = vec3(0.0);
float transmissionR;
float transmissionG;
float transmissionB;
float randomCoords = rand(runningSeed++);
float thicknessSmear =
thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur);
vec3 distortionNormal = vec3(0.0);
vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion;
if (distortion > 0.0) {
distortionNormal = distortion * vec3(
snoiseFractal(vec3(pos * distortionScale + temporalOffset)),
snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)),
snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset))
);
}
for (float i = 0.0; i < ${samples}.0; i++) {
vec3 sampleNorm = normalize(
n +
roughnessFactor * roughnessFactor * 2.0 *
normalize(
vec3(
rand(runningSeed++) - 0.5,
rand(runningSeed++) - 0.5,
rand(runningSeed++) - 0.5
)
) *
pow(rand(runningSeed++), 0.33) +
distortionNormal
);
transmissionR = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior,
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).r;
transmissionG = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(${samples})),
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).g;
transmissionB = getIBLVolumeRefraction(
sampleNorm,
v,
material.roughness,
material.diffuseColor,
material.specularColor,
material.specularF90,
pos,
modelMatrix,
viewMatrix,
projectionMatrix,
material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(${samples})),
material.thickness + thicknessSmear * (i + randomCoords) / float(${samples}),
material.attenuationColor,
material.attenuationDistance
).b;
transmission.r += transmissionR;
transmission.g += transmissionG;
transmission.b += transmissionB;
}
transmission /= ${samples}.0;
totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission);
`,
);
};
Object.keys(this.halftoneUniforms).forEach((key) => {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this.halftoneUniforms[key]?.value,
set: (value) => {
this.halftoneUniforms[key]!.value = value;
},
});
});
}
}
const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320;
const GLASS_ATTENUATION_DISTANCE_MIN = 0.12;
const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18;
const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12;
const GLASS_ENVIRONMENT_ZOOM = 1.55;
const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303);
const GLASS_TEXTURE_URLS = {
environment: '/halftone/materials/glass/environment.jpg',
} as const;
const MAX_TEXTURE_ANISOTROPY = 8;
function setTextureSampling(
texture: THREE.Texture,
renderer: THREE.WebGLRenderer,
) {
texture.generateMipmaps = true;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = Math.min(
renderer.capabilities.getMaxAnisotropy(),
MAX_TEXTURE_ANISOTROPY,
);
}
function disposeEnvironmentScene(scene: THREE.Scene) {
scene.traverse((object) => {
const mesh = object as THREE.Mesh;
if (mesh.geometry) {
mesh.geometry.dispose();
}
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose());
return;
}
mesh.material?.dispose?.();
});
}
function createSolidEnvironmentTexture(renderer: THREE.WebGLRenderer) {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = pmremGenerator.fromScene(
new RoomEnvironment(),
0.04,
).texture;
pmremGenerator.dispose();
return environmentTexture;
}
function getTextureImageSize(texture: THREE.Texture) {
const image = texture.image as
| {
height?: number;
naturalHeight?: number;
naturalWidth?: number;
videoHeight?: number;
videoWidth?: number;
width?: number;
}
| undefined;
const width =
image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined;
const height =
image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined;
return {
height,
width,
};
}
function createZoomedGlassTexture(
sourceTexture: THREE.Texture,
renderer: THREE.WebGLRenderer,
zoom: number,
) {
if (zoom <= 1) {
return sourceTexture;
}
const { width, height } = getTextureImageSize(sourceTexture);
if (!width || !height) {
return sourceTexture;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return sourceTexture;
}
const cropWidth = width / zoom;
const cropHeight = height / zoom;
const sourceX = (width - cropWidth) / 2;
const sourceY = (height - cropHeight) / 2;
context.drawImage(
sourceTexture.image as CanvasImageSource,
sourceX,
sourceY,
cropWidth,
cropHeight,
0,
0,
width,
height,
);
const zoomedTexture = new THREE.CanvasTexture(canvas);
zoomedTexture.colorSpace = sourceTexture.colorSpace;
zoomedTexture.wrapS = THREE.ClampToEdgeWrapping;
zoomedTexture.wrapT = THREE.ClampToEdgeWrapping;
setTextureSampling(zoomedTexture, renderer);
zoomedTexture.needsUpdate = true;
return zoomedTexture;
}
function createStudioGlassEnvironmentScene(backdropTexture?: THREE.Texture) {
const studioScene = new THREE.Scene();
studioScene.background = backdropTexture ?? GLASS_TRANSMISSION_BACKGROUND;
studioScene.backgroundIntensity = backdropTexture ? 1 : 0.4;
return studioScene;
}
function createStudioGlassEnvironmentTexture(
renderer: THREE.WebGLRenderer,
backdropTexture?: THREE.Texture,
) {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const environmentTexture = backdropTexture
? pmremGenerator.fromEquirectangular(backdropTexture).texture
: pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
pmremGenerator.dispose();
return environmentTexture;
}
function createFallbackGlassBackdropTexture(renderer: THREE.WebGLRenderer) {
const texture = new THREE.DataTexture(
new Uint8Array([3, 3, 3, 255]),
1,
1,
THREE.RGBAFormat,
);
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.mapping = THREE.EquirectangularReflectionMapping;
setTextureSampling(texture, renderer);
texture.needsUpdate = true;
return texture;
}
function loadTexture(
url: string,
renderer: THREE.WebGLRenderer,
colorSpace: THREE.ColorSpace,
) {
const loader = new THREE.TextureLoader();
return new Promise<THREE.Texture>((resolve, reject) => {
loader.load(
url,
(texture) => {
texture.colorSpace = colorSpace;
setTextureSampling(texture, renderer);
resolve(texture);
},
undefined,
reject,
);
});
}
async function loadGlassEnvironmentAssets(renderer: THREE.WebGLRenderer) {
const sourceBackgroundTexture = await loadTexture(
GLASS_TEXTURE_URLS.environment,
renderer,
THREE.SRGBColorSpace,
);
const backgroundTexture = createZoomedGlassTexture(
sourceBackgroundTexture,
renderer,
GLASS_ENVIRONMENT_ZOOM,
);
if (backgroundTexture !== sourceBackgroundTexture) {
sourceBackgroundTexture.dispose();
}
backgroundTexture.mapping = THREE.EquirectangularReflectionMapping;
backgroundTexture.wrapS = THREE.ClampToEdgeWrapping;
backgroundTexture.wrapT = THREE.ClampToEdgeWrapping;
backgroundTexture.needsUpdate = true;
const transmissionScene =
createStudioGlassEnvironmentScene(backgroundTexture);
const environmentTexture = createStudioGlassEnvironmentTexture(
renderer,
backgroundTexture,
);
return {
backgroundTexture,
environmentTexture,
glassTransmissionScene: transmissionScene,
};
}
function getGlassEnvironmentIntensity(power: number) {
return (
GLASS_ENVIRONMENT_INTENSITY_BASE +
power * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER
);
}
export async function createHalftoneMaterialAssets(
renderer: THREE.WebGLRenderer,
): Promise<HalftoneMaterialAssets> {
const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer);
try {
const glassEnvironmentAssets = await loadGlassEnvironmentAssets(renderer);
return {
glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture,
glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture,
glassTransmissionScene: glassEnvironmentAssets.glassTransmissionScene,
solidEnvironmentTexture,
};
} catch {
const transmissionScene = createStudioGlassEnvironmentScene();
const fallbackGlassBackdropTexture =
createFallbackGlassBackdropTexture(renderer);
const fallbackGlassEnvironmentTexture =
createStudioGlassEnvironmentTexture(renderer);
return {
glassBackgroundTexture: fallbackGlassBackdropTexture,
glassEnvironmentTexture: fallbackGlassEnvironmentTexture,
glassTransmissionScene: transmissionScene,
solidEnvironmentTexture,
};
}
}
export function createHalftoneMaterial() {
return new HalftoneTransmissionMaterial();
}
export function applyHalftoneMaterialSettings(
material: HalftoneTransmissionMaterial,
settings: HalftoneMaterialSettings,
assets: HalftoneMaterialAssets,
) {
const isGlass = settings.surface === 'glass';
const glassThickness = settings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS;
const glassEnvironmentIntensity = getGlassEnvironmentIntensity(
settings.environmentPower,
);
const glassAttenuationDistance = Math.max(
glassThickness * 4,
GLASS_ATTENUATION_DISTANCE_MIN,
);
material.color.set(isGlass ? '#ffffff' : settings.color);
material.roughness = settings.roughness;
material.metalness = settings.metalness;
material.envMap = isGlass
? assets.glassEnvironmentTexture
: assets.solidEnvironmentTexture;
material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25;
material.clearcoat = isGlass ? 1 : 0;
material.clearcoatRoughness = isGlass
? Math.max(settings.roughness * 0.25, 0.01)
: 0.08;
material.reflectivity = isGlass ? 0.98 : 0.5;
material.transmission = 0;
material._transmission = isGlass ? 1 : 0;
material.refractionEnvMap = isGlass ? assets.glassBackgroundTexture : null;
material.useEnvMapRefraction = isGlass ? 1 : 0;
material.thickness = isGlass ? glassThickness : 0;
material.ior = isGlass ? settings.refraction : 1.5;
material.buffer = null;
material.bumpMap = null;
material.bumpScale = 0;
material.roughnessMap = null;
material.side = THREE.FrontSide;
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.attenuationColor.set(isGlass ? settings.color : 'white');
material.attenuationDistance = isGlass ? glassAttenuationDistance : Infinity;
material.anisotropicBlur = isGlass
? THREE.MathUtils.lerp(0.03, 0.12, settings.roughness)
: 0.1;
material.chromaticAberration = isGlass ? 0 : 0.05;
material.distortion = 0;
material.distortionScale = 0.5;
material.temporalDistortion = 0;
material.userData.halftoneIsGlass = isGlass;
material.userData.halftoneGlassBacksideThickness = isGlass
? glassThickness * 2
: 0;
material.userData.halftoneGlassBacksideEnvIntensity = isGlass
? glassEnvironmentIntensity * 2.8
: 0;
material.userData.halftoneUseEnvironmentRefraction = isGlass;
material.needsUpdate = true;
}
export function renderHalftoneMaterialScene(options: {
camera: THREE.Camera;
elapsedTime: number;
material: HalftoneTransmissionMaterial;
mesh: THREE.Mesh;
outputTarget: THREE.WebGLRenderTarget | null;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
transmissionBackground?: THREE.Color | THREE.Texture | null;
transmissionBackgroundIntensity?: number;
transmissionScene?: THREE.Scene;
transmissionBacksideTarget: THREE.WebGLRenderTarget;
transmissionTarget: THREE.WebGLRenderTarget;
}) {
const {
camera,
elapsedTime,
material,
mesh,
outputTarget,
renderer,
scene,
transmissionBackground,
transmissionBackgroundIntensity,
transmissionScene,
transmissionBacksideTarget,
transmissionTarget,
} = options;
const isGlass = material.userData.halftoneIsGlass === true;
material.time = elapsedTime;
if (!isGlass) {
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
return;
}
const useEnvironmentRefraction =
material.userData.halftoneUseEnvironmentRefraction === true;
if (useEnvironmentRefraction) {
material.buffer = null;
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
return;
}
const previousToneMapping = renderer.toneMapping;
const previousVisibility = mesh.visible;
const previousBackground = scene.background;
const previousBackgroundIntensity = scene.backgroundIntensity;
const previousSide = material.side;
const previousThickness = material.thickness;
const previousEnvMapIntensity = material.envMapIntensity;
const backsideThickness =
(material.userData.halftoneGlassBacksideThickness as number | undefined) ??
previousThickness;
const backsideEnvMapIntensity =
(material.userData.halftoneGlassBacksideEnvIntensity as
| number
| undefined) ?? previousEnvMapIntensity;
const backgroundIntensity =
transmissionBackgroundIntensity ?? previousEnvMapIntensity;
renderer.toneMapping = THREE.NoToneMapping;
if (transmissionScene) {
renderer.setRenderTarget(transmissionBacksideTarget);
renderer.clear();
renderer.render(transmissionScene, camera);
} else {
scene.background = transmissionBackground ?? GLASS_TRANSMISSION_BACKGROUND;
scene.backgroundIntensity = transmissionBackground
? backgroundIntensity
: 1;
mesh.visible = false;
renderer.setRenderTarget(transmissionBacksideTarget);
renderer.clear();
renderer.render(scene, camera);
mesh.visible = previousVisibility;
}
material.buffer = transmissionBacksideTarget.texture;
material.thickness = backsideThickness;
material.side = THREE.BackSide;
material.envMapIntensity = backsideEnvMapIntensity;
renderer.setRenderTarget(transmissionTarget);
renderer.clear();
renderer.render(scene, camera);
material.buffer = transmissionTarget.texture;
material.thickness = previousThickness;
material.side = previousSide;
material.envMapIntensity = previousEnvMapIntensity;
if (!transmissionScene) {
scene.background = previousBackground;
scene.backgroundIntensity = previousBackgroundIntensity;
}
renderer.setRenderTarget(outputTarget);
renderer.clear();
renderer.render(scene, camera);
renderer.toneMapping = previousToneMapping;
}
export function disposeHalftoneMaterialAssets(assets: HalftoneMaterialAssets) {
assets.glassBackgroundTexture.dispose();
if (assets.glassEnvironmentTexture !== assets.glassBackgroundTexture) {
assets.glassEnvironmentTexture.dispose();
}
disposeEnvironmentScene(assets.glassTransmissionScene);
assets.solidEnvironmentTexture.dispose();
}
@@ -0,0 +1,123 @@
import {
DEFAULT_GEOMETRY_SPECS,
DEFAULT_HALFTONE_SETTINGS,
normalizeHalftoneStudioSettings,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
export type ShareableHalftoneState = {
settings: HalftoneStudioSettings;
previewDistance: number;
exportName: string;
};
function toUrlSafeBase64(input: string) {
return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromUrlSafeBase64(input: string) {
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
while (normalized.length % 4 !== 0) {
normalized += '=';
}
return normalized;
}
function encodeUtf8ToBase64(value: string) {
const bytes = new TextEncoder().encode(value);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function decodeBase64ToUtf8(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new TextDecoder().decode(bytes);
}
export function encodeShareState(state: ShareableHalftoneState): string {
const payload = {
settings: state.settings,
previewDistance: state.previewDistance,
exportName: state.exportName,
};
return toUrlSafeBase64(encodeUtf8ToBase64(JSON.stringify(payload)));
}
// Imported geometry only exists in the user's local session, so a shared URL
// can never reproduce a custom upload — fall back to the default shape if the
// hash points at one.
function sanitizeShapeKey(shapeKey: string): string {
const isBuiltinShape = DEFAULT_GEOMETRY_SPECS.some(
(spec) => spec.key === shapeKey,
);
return isBuiltinShape ? shapeKey : DEFAULT_HALFTONE_SETTINGS.shapeKey;
}
export function decodeShareState(
hash: string | null | undefined,
): ShareableHalftoneState | null {
if (!hash) {
return null;
}
const trimmed = hash.replace(/^#/, '').trim();
if (!trimmed) {
return null;
}
try {
const json = decodeBase64ToUtf8(fromUrlSafeBase64(trimmed));
const parsed = JSON.parse(json) as Partial<ShareableHalftoneState> | null;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const settings = normalizeHalftoneStudioSettings(parsed.settings);
const previewDistance =
typeof parsed.previewDistance === 'number' &&
Number.isFinite(parsed.previewDistance)
? parsed.previewDistance
: null;
const exportName =
typeof parsed.exportName === 'string' ? parsed.exportName : '';
if (previewDistance === null) {
return null;
}
return {
settings: {
...settings,
shapeKey: sanitizeShapeKey(settings.shapeKey),
},
previewDistance,
exportName,
};
} catch {
return null;
}
}
export function buildShareUrl(state: ShareableHalftoneState): string {
const hash = encodeShareState(state);
const { origin, pathname } = window.location;
return `${origin}${pathname}#${hash}`;
}
@@ -1,10 +1,19 @@
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';
export type HalftoneTabId = 'design' | 'animations' | 'export';
export type HalftoneSourceMode = 'shape' | 'image';
export type HalftoneMaterialSurface = 'solid' | 'glass';
export type HalftoneRotateAxis =
| 'x'
| 'y'
| 'z'
| 'xy'
| '-x'
| '-y'
| '-z'
| '-xy';
export type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
export type HalftoneModelLoader = 'fbx' | 'glb';
interface HalftoneLightingSettings {
export interface HalftoneLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
@@ -12,34 +21,31 @@ interface HalftoneLightingSettings {
height: number;
}
interface HalftoneMaterialSettings {
export interface HalftoneMaterialSettings {
surface: HalftoneMaterialSurface;
color: string;
roughness: number;
metalness: number;
thickness: number;
refraction: number;
environmentPower: number;
}
interface HalftoneEffectSettings {
export interface HalftoneEffectSettings {
enabled: boolean;
numRows: number;
contrast: number;
scale: number;
power: number;
shading: number;
baseInk: number;
maxBar: number;
rowMerge: number;
cellRatio: number;
cutoff: number;
highlightOpen: number;
shadowGrouping: number;
shadowCrush: number;
width: number;
imageContrast: number;
dashColor: string;
}
interface HalftoneBackgroundSettings {
export interface HalftoneBackgroundSettings {
transparent: boolean;
color: string;
}
interface HalftoneAnimationSettings {
export interface HalftoneAnimationSettings {
autoRotateEnabled: boolean;
breatheEnabled: boolean;
cameraParallaxEnabled: boolean;
@@ -88,7 +94,7 @@ interface HalftoneAnimationSettings {
waveAmount: number;
}
interface HalftoneStudioSettings {
export interface HalftoneStudioSettings {
sourceMode: HalftoneSourceMode;
shapeKey: string;
lighting: HalftoneLightingSettings;
@@ -98,7 +104,7 @@ interface HalftoneStudioSettings {
animation: HalftoneAnimationSettings;
}
interface HalftoneGeometrySpec {
export interface HalftoneGeometrySpec {
key: string;
label: string;
kind: 'builtin' | 'imported';
@@ -109,7 +115,7 @@ interface HalftoneGeometrySpec {
userProvided?: boolean;
}
interface HalftoneStudioState {
export interface HalftoneStudioState {
activeTab: HalftoneTabId;
geometrySpecs: HalftoneGeometrySpec[];
importedFiles: Record<string, File>;
@@ -119,7 +125,18 @@ interface HalftoneStudioState {
statusIsError: boolean;
}
type HalftoneStudioAction =
export interface HalftoneExportPose {
autoElapsed: number;
rotateElapsed: number;
rotationX: number;
rotationY: number;
rotationZ: number;
targetRotationX: number;
targetRotationY: number;
timeElapsed: number;
}
export type HalftoneStudioAction =
| { type: 'setTab'; value: HalftoneTabId }
| { type: 'setSourceMode'; value: HalftoneSourceMode }
| { type: 'setShapeKey'; value: string }
@@ -135,7 +152,6 @@ type HalftoneStudioAction =
file: File;
activate: boolean;
}
| { type: 'setImportedFile'; key: string; file: File }
| { type: 'setStatus'; message: string; isError?: boolean }
| { type: 'clearStatus' }
| { type: 'hideHint' };
@@ -172,97 +188,165 @@ export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [
{ 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,
scale: 24.72,
power: -0.07,
width: 0.46,
imageContrast: 1,
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,
scale: 24.72,
power: -0.07,
width: 0.46,
imageContrast: 1,
dashColor: '#4A38F5',
};
export const DEFAULT_SOLID_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'solid',
color: '#d4d0c8',
roughness: 0.42,
metalness: 0.16,
thickness: 150,
refraction: 2,
environmentPower: 5,
};
export const DEFAULT_GLASS_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'glass',
color: '#7d7d7d',
roughness: 0,
metalness: 0,
thickness: 15.58,
refraction: 2,
environmentPower: 5,
};
export const LEGACY_HALFTONE_SETTING_KEYS = [
'numRows',
'contrast',
'shading',
'baseInk',
'maxBar',
'rowMerge',
'cellRatio',
'cutoff',
'highlightOpen',
'shadowGrouping',
'shadowCrush',
] as const;
export function isRoundedBandHalftoneSettings(
value: unknown,
): value is HalftoneEffectSettings {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.enabled === 'boolean' &&
typeof candidate.scale === 'number' &&
typeof candidate.power === 'number' &&
typeof candidate.width === 'number' &&
typeof candidate.imageContrast === 'number' &&
typeof candidate.dashColor === 'string'
);
}
export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) {
return sourceMode === 'image'
? DEFAULT_IMAGE_HALFTONE_SETTINGS
: DEFAULT_SHAPE_HALFTONE_SETTINGS;
}
function normalizeHalftoneEffectSettings(
defaults: HalftoneEffectSettings,
settings?: Partial<HalftoneEffectSettings>,
): HalftoneEffectSettings {
return {
enabled: settings?.enabled ?? defaults.enabled,
scale: settings?.scale ?? defaults.scale,
power: settings?.power ?? defaults.power,
width: settings?.width ?? defaults.width,
imageContrast: settings?.imageContrast ?? defaults.imageContrast,
dashColor: settings?.dashColor ?? defaults.dashColor,
};
}
function normalizeMaterialSettings(
settings?: Partial<HalftoneMaterialSettings>,
): HalftoneMaterialSettings {
const surface = settings?.surface === 'glass' ? 'glass' : 'solid';
const defaults =
surface === 'glass'
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS;
return {
surface,
color:
typeof settings?.color === 'string' ? settings.color : defaults.color,
roughness:
typeof settings?.roughness === 'number'
? settings.roughness
: defaults.roughness,
metalness:
typeof settings?.metalness === 'number'
? settings.metalness
: defaults.metalness,
thickness:
typeof settings?.thickness === 'number'
? settings.thickness
: defaults.thickness,
refraction:
typeof settings?.refraction === 'number'
? settings.refraction
: defaults.refraction,
environmentPower:
typeof settings?.environmentPower === 'number'
? settings.environmentPower
: defaults.environmentPower,
};
}
export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'shape' as HalftoneSourceMode,
shapeKey: 'torusKnot',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0.3,
angleDegrees: 53,
height: 2,
},
material: {
roughness: 0.42,
metalness: 0.16,
...DEFAULT_SOLID_MATERIAL_SETTINGS,
},
halftone: DEFAULT_SHAPE_HALFTONE_SETTINGS,
background: {
transparent: true,
color: '#ffffff',
color: '#000000',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.3,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
@@ -277,7 +361,7 @@ export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = {
dragMomentum: true,
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 1,
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
@@ -301,30 +385,99 @@ export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = {
},
};
const LEGACY_GLASS_LIGHTING_SETTINGS: HalftoneLightingSettings = {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
};
const LEGACY_GLASS_MATERIAL_SETTINGS: HalftoneMaterialSettings = {
surface: 'glass',
color: '#7d7d7d',
roughness: 0.1,
metalness: 0.1,
thickness: 150,
refraction: 2,
environmentPower: 5,
};
const DEFAULT_GLASS_LIGHTING_SETTINGS: HalftoneLightingSettings = {
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0.3,
angleDegrees: 53,
height: 2,
};
function lightingMatches(
value: Partial<HalftoneLightingSettings> | undefined,
target: HalftoneLightingSettings,
) {
return (
value?.intensity === target.intensity &&
value?.fillIntensity === target.fillIntensity &&
value?.ambientIntensity === target.ambientIntensity &&
value?.angleDegrees === target.angleDegrees &&
value?.height === target.height
);
}
function materialMatches(
value: Partial<HalftoneMaterialSettings> | undefined,
target: HalftoneMaterialSettings,
) {
return (
value?.surface === target.surface &&
value?.color === target.color &&
value?.roughness === target.roughness &&
value?.metalness === target.metalness &&
value?.thickness === target.thickness &&
value?.refraction === target.refraction &&
value?.environmentPower === target.environmentPower
);
}
export function normalizeHalftoneStudioSettings(
settings?: Partial<HalftoneStudioSettings>,
): HalftoneStudioSettings {
const sourceMode =
settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode;
const mergedMaterial = normalizeMaterialSettings(settings?.material);
const material =
mergedMaterial.surface === 'glass' &&
materialMatches(settings?.material, LEGACY_GLASS_MATERIAL_SETTINGS)
? { ...DEFAULT_GLASS_MATERIAL_SETTINGS }
: mergedMaterial;
const useGlassLightingDefaults =
material.surface === 'glass' &&
lightingMatches(settings?.lighting, LEGACY_GLASS_LIGHTING_SETTINGS);
const lightingDefaults = useGlassLightingDefaults
? DEFAULT_GLASS_LIGHTING_SETTINGS
: DEFAULT_HALFTONE_SETTINGS.lighting;
const backgroundDefaults =
material.surface === 'glass' &&
settings?.background?.color === '#ffffff' &&
settings?.background?.transparent === true
? { ...DEFAULT_HALFTONE_SETTINGS.background, color: '#000000' }
: DEFAULT_HALFTONE_SETTINGS.background;
return {
...DEFAULT_HALFTONE_SETTINGS,
...settings,
sourceMode,
lighting: {
...DEFAULT_HALFTONE_SETTINGS.lighting,
...lightingDefaults,
...settings?.lighting,
},
material: {
...DEFAULT_HALFTONE_SETTINGS.material,
...settings?.material,
},
halftone: {
...getDefaultHalftoneSettings(sourceMode),
...settings?.halftone,
},
material,
halftone: normalizeHalftoneEffectSettings(
getDefaultHalftoneSettings(sourceMode),
settings?.halftone,
),
background: {
...DEFAULT_HALFTONE_SETTINGS.background,
...backgroundDefaults,
...settings?.background,
},
animation: {
@@ -339,7 +492,10 @@ export function createInitialHalftoneStudioState(): HalftoneStudioState {
activeTab: 'design',
geometrySpecs: [...DEFAULT_GEOMETRY_SPECS],
importedFiles: {},
settings: normalizeHalftoneStudioSettings(DEFAULT_HALFTONE_SETTINGS),
settings: normalizeHalftoneStudioSettings({
...DEFAULT_HALFTONE_SETTINGS,
material: DEFAULT_GLASS_MATERIAL_SETTINGS,
}),
showHint: true,
statusMessage: '',
statusIsError: false,
@@ -449,14 +605,6 @@ export function halftoneStudioReducer(
}
: state.settings,
};
case 'setImportedFile':
return {
...state,
importedFiles: {
...state.importedFiles,
[action.key]: action.file,
},
};
case 'setStatus':
return {
...state,
@@ -1,6 +1,6 @@
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
export const HERO_DATA = {
heading: [
{ text: 'Become a ', fontFamily: 'serif' },
{ text: 'Twenty Partner', fontFamily: 'sans' },
@@ -8,6 +8,4 @@ export const HERO_DATA: HeroIllustrationDataType = {
body: {
text: "We're building the #1 open-source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.",
},
illustration:
'https://app.endlesstools.io/embed/1c6c8259-3276-4cf2-84d8-6b7e87e7ec95',
};
} satisfies HeroBaseDataType;
@@ -1,6 +1,6 @@
import type { HeroIllustrationDataType } from '@/sections/Hero/types';
import type { HeroBaseDataType } from '@/sections/Hero/types';
export const HERO_DATA: HeroIllustrationDataType = {
export const HERO_DATA = {
heading: [
{ text: 'The CRM that moves ', fontFamily: 'serif' },
{ text: 'as fast as you do', fontFamily: 'sans' },
@@ -8,5 +8,4 @@ export const HERO_DATA: HeroIllustrationDataType = {
body: {
text: 'Modern interface. AI assistance. All the features you need, ready from day one.',
},
illustration: 'heroProduct',
};
} satisfies HeroBaseDataType;
@@ -6,17 +6,19 @@ import { css } from '@linaria/core';
import { styled } from '@linaria/react';
const EyebrowRow = styled.div`
align-items: start;
display: grid;
align-items: center;
display: flex;
gap: ${theme.spacing(2)};
grid-template-columns: auto 1fr;
text-align: start;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
display: flex;
height: ${theme.lineHeight(6)};
justify-content: center;
width: ${theme.lineHeight(6)};
flex-shrink: 0;
`;
const eyebrowColorPrimary = css`
File diff suppressed because it is too large Load Diff
@@ -1,377 +1,102 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
HelpedHalftoneModel,
type HelpedHalftonePose,
type HelpedHalftoneSettings,
} from '@/illustrations/Helped/HelpedHalftoneModel';
const GLB_URL = '/illustrations/home/helped/money.glb';
const MONEY_PREVIEW_DISTANCE = 5;
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 uCameraPosition;
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);
vec3 viewDir = normalize(uCameraPosition - vWorldPosition);
float ndotl = max(dot(normal, lightDir), 0.05);
float ndotv = abs(dot(normal, viewDir));
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
// Narrow colored bands so most of each stripe period reads as background.
float shadowWeight = mix(1.0, 0.62, ndotl);
float lineWidth = 0.26 * shadowWeight;
float edge = 0.024;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.25);
float dash = fract(vWorldPosition.x * 18.0 + vWorldPosition.z * 5.0);
float dashMask = mix(
1.0,
smoothstep(0.18, 0.48, dash) * (1.0 - smoothstep(0.58, 0.86, dash)),
highlight * 0.4
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.62 + 0.38 * step(0.42, speckle), highlight * 0.42);
// Silhouettes and shadowed sides fall back to transparent (card shows through).
float viewFacing = pow(clamp(ndotv, 0.04, 1.0), 0.52);
float lightFacing = pow(clamp(ndotl, 0.1, 1.0), 0.88);
float alpha = band * viewFacing * lightFacing;
// Brighten RGB vs flat uColor; alpha above still drives edge transparency.
float lightTint = mix(0.72, 1.58, pow(ndotl, 0.82));
float viewTint = mix(0.88, 1.14, viewFacing);
const float colorGain = 1.22;
vec3 lit = uColor * lightTint * viewTint * colorGain;
if (alpha < 0.02) {
discard;
}
gl_FragColor = vec4(lit, alpha);
}
`;
function createScanlineMaterial(
lightDirection: THREE.Vector3,
stripeColor: string,
) {
return new THREE.ShaderMaterial({
uniforms: {
uCameraPosition: { value: new THREE.Vector3() },
uColor: { value: new THREE.Color(stripeColor) },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 23.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
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;
const MONEY_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
},
material: {
roughness: 0.32,
metalness: 0.98,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
dashColor: '#FDFE98',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
springReturnEnabled: true,
springStrength: 0.06,
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,
stripeColor: string,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection, stripeColor);
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.helpedMoneyMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const MONEY_INITIAL_POSE: HelpedHalftonePose = {
autoElapsed: 0.05,
rotateElapsed: 0,
rotationX: 2.0543643190124016e-89,
rotationY: 1.3695919155712744e-87,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 482.5497999999506,
};
export function Money() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const cameraWorldPosition = new THREE.Vector3();
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 * 1.1) / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld, '#E4E58A');
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const motion = 1.1;
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * motion * influence;
targetRotation.x = pointer.y * 0.62 * motion * 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 * motion,
7,
delta,
),
);
const pointerFollow = pointer.inside ? 1 : 0.32;
const mx = pointer.x * pointerFollow;
const my = pointer.y * pointerFollow;
const wobbleAttenuation = pointer.inside ? 1 : 0.36;
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.helpedMoneyMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
sceneObject.position.x =
rest.position.x + mx * motion * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * motion * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y +
(mx + my) * motion * 0.055 * Math.sin(phase * 2.5);
const twist =
motion *
(mx * 0.34 + my * 0.24) *
wobbleAttenuation *
Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
camera.getWorldPosition(cameraWorldPosition);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const material = sceneObject.material;
if (
material instanceof THREE.ShaderMaterial &&
material.uniforms.uCameraPosition
) {
material.uniforms.uCameraPosition.value.copy(cameraWorldPosition);
}
});
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;
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return (
<HelpedHalftoneModel
initialPose={MONEY_INITIAL_POSE}
label="money.glb"
modelUrl={GLB_URL}
previewDistance={MONEY_PREVIEW_DISTANCE}
settings={MONEY_SETTINGS}
/>
);
}
@@ -1,377 +1,102 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
HelpedHalftoneModel,
type HelpedHalftonePose,
type HelpedHalftoneSettings,
} from '@/illustrations/Helped/HelpedHalftoneModel';
const GLB_URL = '/illustrations/home/helped/spaceship.glb';
const SPACESHIP_PREVIEW_DISTANCE = 3.5;
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 uCameraPosition;
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);
vec3 viewDir = normalize(uCameraPosition - vWorldPosition);
float ndotl = max(dot(normal, lightDir), 0.05);
float ndotv = abs(dot(normal, viewDir));
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
// Narrow colored bands so most of each stripe period reads as background.
float shadowWeight = mix(1.0, 0.62, ndotl);
float lineWidth = 0.26 * shadowWeight;
float edge = 0.024;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.25);
float dash = fract(vWorldPosition.x * 18.0 + vWorldPosition.z * 5.0);
float dashMask = mix(
1.0,
smoothstep(0.18, 0.48, dash) * (1.0 - smoothstep(0.58, 0.86, dash)),
highlight * 0.4
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.62 + 0.38 * step(0.42, speckle), highlight * 0.42);
// Silhouettes and shadowed sides fall back to transparent (card shows through).
float viewFacing = pow(clamp(ndotv, 0.04, 1.0), 0.52);
float lightFacing = pow(clamp(ndotl, 0.1, 1.0), 0.88);
float alpha = band * viewFacing * lightFacing;
// Brighten RGB vs flat uColor; alpha above still drives edge transparency.
float lightTint = mix(0.72, 1.58, pow(ndotl, 0.82));
float viewTint = mix(0.88, 1.14, viewFacing);
const float colorGain = 1.22;
vec3 lit = uColor * lightTint * viewTint * colorGain;
if (alpha < 0.02) {
discard;
}
gl_FragColor = vec4(lit, alpha);
}
`;
function createScanlineMaterial(
lightDirection: THREE.Vector3,
stripeColor: string,
) {
return new THREE.ShaderMaterial({
uniforms: {
uCameraPosition: { value: new THREE.Vector3() },
uColor: { value: new THREE.Color(stripeColor) },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 23.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
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;
const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
},
material: {
roughness: 0.32,
metalness: 0.98,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
dashColor: '#89FC9A',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
springReturnEnabled: true,
springStrength: 0.06,
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,
stripeColor: string,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection, stripeColor);
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.helpedSpaceshipMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const SPACESHIP_INITIAL_POSE: HelpedHalftonePose = {
autoElapsed: 0.05,
rotateElapsed: 0,
rotationX: 6.34639162689903e-205,
rotationY: 4.230976261030774e-203,
rotationZ: 0,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 99.20160000002399,
};
export function Spaceship() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const cameraWorldPosition = new THREE.Vector3();
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 * 1.1) / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld, '#89FC9A');
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const motion = 1.1;
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * motion * influence;
targetRotation.x = pointer.y * 0.62 * motion * 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 * motion,
7,
delta,
),
);
const pointerFollow = pointer.inside ? 1 : 0.32;
const mx = pointer.x * pointerFollow;
const my = pointer.y * pointerFollow;
const wobbleAttenuation = pointer.inside ? 1 : 0.36;
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.helpedSpaceshipMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
sceneObject.position.x =
rest.position.x + mx * motion * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * motion * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y +
(mx + my) * motion * 0.055 * Math.sin(phase * 2.5);
const twist =
motion *
(mx * 0.34 + my * 0.24) *
wobbleAttenuation *
Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
camera.getWorldPosition(cameraWorldPosition);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const material = sceneObject.material;
if (
material instanceof THREE.ShaderMaterial &&
material.uniforms.uCameraPosition
) {
material.uniforms.uCameraPosition.value.copy(cameraWorldPosition);
}
});
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;
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return (
<HelpedHalftoneModel
initialPose={SPACESHIP_INITIAL_POSE}
label="spaceship.glb"
modelUrl={GLB_URL}
previewDistance={SPACESHIP_PREVIEW_DISTANCE}
settings={SPACESHIP_SETTINGS}
/>
);
}
@@ -1,377 +1,102 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
HelpedHalftoneModel,
type HelpedHalftonePose,
type HelpedHalftoneSettings,
} from '@/illustrations/Helped/HelpedHalftoneModel';
const GLB_URL = '/illustrations/home/helped/target.glb';
const TARGET_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 uCameraPosition;
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);
vec3 viewDir = normalize(uCameraPosition - vWorldPosition);
float ndotl = max(dot(normal, lightDir), 0.05);
float ndotv = abs(dot(normal, viewDir));
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
// Narrow colored bands so most of each stripe period reads as background.
float shadowWeight = mix(1.0, 0.62, ndotl);
float lineWidth = 0.26 * shadowWeight;
float edge = 0.024;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.25);
float dash = fract(vWorldPosition.x * 18.0 + vWorldPosition.z * 5.0);
float dashMask = mix(
1.0,
smoothstep(0.18, 0.48, dash) * (1.0 - smoothstep(0.58, 0.86, dash)),
highlight * 0.4
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.62 + 0.38 * step(0.42, speckle), highlight * 0.42);
// Silhouettes and shadowed sides fall back to transparent (card shows through).
float viewFacing = pow(clamp(ndotv, 0.04, 1.0), 0.52);
float lightFacing = pow(clamp(ndotl, 0.1, 1.0), 0.88);
float alpha = band * viewFacing * lightFacing;
// Brighten RGB vs flat uColor; alpha above still drives edge transparency.
float lightTint = mix(0.72, 1.58, pow(ndotl, 0.82));
float viewTint = mix(0.88, 1.14, viewFacing);
const float colorGain = 1.22;
vec3 lit = uColor * lightTint * viewTint * colorGain;
if (alpha < 0.02) {
discard;
}
gl_FragColor = vec4(lit, alpha);
}
`;
function createScanlineMaterial(
lightDirection: THREE.Vector3,
stripeColor: string,
) {
return new THREE.ShaderMaterial({
uniforms: {
uCameraPosition: { value: new THREE.Vector3() },
uColor: { value: new THREE.Color(stripeColor) },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 23.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
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;
const TARGET_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
},
material: {
roughness: 0.32,
metalness: 0.98,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
dashColor: '#ED87FC',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
floatEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
springReturnEnabled: true,
springStrength: 0.06,
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,
stripeColor: string,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.material = createScanlineMaterial(lightDirection, stripeColor);
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.helpedTargetMeshRest = rest;
});
}
const StyledVisualMount = styled.div`
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
const TARGET_INITIAL_POSE: HelpedHalftonePose = {
autoElapsed: 0.05,
rotateElapsed: 0,
rotationX: -1.6874445801116057e-120,
rotationY: 1.5322979021809315e-120,
rotationZ: -4.56607033616335e-124,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 94.36910000002324,
};
export function Target() {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let cancelled = false;
let animationFrameId = 0;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
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);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
canvas.style.cursor = 'pointer';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const cameraWorldPosition = new THREE.Vector3();
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) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
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.75 * 1.1) / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld, '#DE8CF6');
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const motion = 1.1;
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * motion * influence;
targetRotation.x = pointer.y * 0.62 * motion * 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 * motion,
7,
delta,
),
);
const pointerFollow = pointer.inside ? 1 : 0.32;
const mx = pointer.x * pointerFollow;
const my = pointer.y * pointerFollow;
const wobbleAttenuation = pointer.inside ? 1 : 0.36;
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.helpedTargetMeshRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
sceneObject.position.x =
rest.position.x + mx * motion * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * motion * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y +
(mx + my) * motion * 0.055 * Math.sin(phase * 2.5);
const twist =
motion *
(mx * 0.34 + my * 0.24) *
wobbleAttenuation *
Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
camera.getWorldPosition(cameraWorldPosition);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const material = sceneObject.material;
if (
material instanceof THREE.ShaderMaterial &&
material.uniforms.uCameraPosition
) {
material.uniforms.uCameraPosition.value.copy(cameraWorldPosition);
}
});
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;
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
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);
}
};
}, []);
return <StyledVisualMount aria-hidden ref={mountReference} />;
return (
<HelpedHalftoneModel
initialPose={TARGET_INITIAL_POSE}
label="target.glb"
modelUrl={GLB_URL}
previewDistance={TARGET_PREVIEW_DISTANCE}
settings={TARGET_SETTINGS}
/>
);
}
@@ -1,6 +1,6 @@
'use client';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import { HourglassCanvas } from '@/illustrations/Testimonials/HourglassCanvas';
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';
@@ -473,8 +473,6 @@ const HOURGLASS_INITIAL_POSE: HalftoneExportPose = {
timeElapsed: 411.9648000000736,
};
const NO_OP = () => {};
const VisualFrame = styled.div`
background-color: transparent;
border-radius: ${theme.radius(1)};
@@ -534,12 +532,9 @@ export function Hourglass() {
return (
<VisualFrame>
{geometry ? (
<HalftoneCanvas
<HourglassCanvas
geometry={geometry}
imageElement={null}
initialPose={HOURGLASS_INITIAL_POSE}
onFirstInteraction={NO_OP}
onPoseChange={NO_OP}
previewDistance={HOURGLASS_PREVIEW_DISTANCE}
settings={HOURGLASS_SETTINGS}
/>
@@ -0,0 +1,780 @@
'use client';
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
interface HourglassLightingSettings {
intensity: number;
fillIntensity: number;
ambientIntensity: number;
angleDegrees: number;
height: number;
}
interface HourglassMaterialSettings {
roughness: number;
metalness: number;
}
interface HourglassEffectSettings {
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 HourglassAnimationSettings {
autoRotateEnabled: boolean;
followHoverEnabled: boolean;
followDragEnabled: boolean;
rotateEnabled: boolean;
autoSpeed: number;
autoWobble: number;
hoverRange: number;
hoverEase: number;
hoverReturn: boolean;
dragSens: number;
dragFriction: number;
dragMomentum: boolean;
rotateAxis: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
rotatePreset: 'axis' | 'lissajous' | 'orbit' | 'tumble';
rotateSpeed: number;
rotatePingPong: boolean;
waveEnabled: boolean;
waveSpeed: number;
waveAmount: number;
}
interface HourglassSettings {
lighting: HourglassLightingSettings;
material: HourglassMaterialSettings;
halftone: HourglassEffectSettings;
background: {
color: string;
};
animation: HourglassAnimationSettings;
}
interface HourglassPose {
autoElapsed: number;
rotateElapsed: number;
rotationX: number;
rotationY: number;
rotationZ: number;
targetRotationX: number;
targetRotationY: number;
timeElapsed: number;
}
interface InteractionState {
autoElapsed: number;
dragging: boolean;
mouseX: number;
mouseY: number;
pointerX: number;
pointerY: number;
rotateElapsed: number;
rotationX: number;
rotationY: number;
rotationZ: number;
targetRotationX: number;
targetRotationY: number;
velocityX: number;
velocityY: number;
}
const VIRTUAL_RENDER_HEIGHT = 768;
const REFERENCE_PREVIEW_DISTANCE = 4;
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 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;
varying vec2 vUv;
void main() {
float rowH = resolution.y / (numRows * distanceScale);
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 waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH;
float effectiveX = gl_FragCoord.x + waveOffset;
float cellW = rowH * cellRatio;
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;
litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);
litLum = pow(litLum, max(contrast, 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>
}
`;
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
format: THREE.RGBAFormat,
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearFilter,
});
}
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 setPrimaryLightPosition(
light: THREE.DirectionalLight,
angleDegrees: number,
height: number,
) {
const angle = (angleDegrees * Math.PI) / 180;
light.position.set(Math.cos(angle) * 5, height, Math.sin(angle) * 5);
}
function createInteractionState(initialPose?: Partial<HourglassPose>) {
return {
autoElapsed: initialPose?.autoElapsed ?? 0,
dragging: false,
mouseX: 0.5,
mouseY: 0.5,
pointerX: 0,
pointerY: 0,
rotateElapsed: initialPose?.rotateElapsed ?? 0,
rotationX: initialPose?.rotationX ?? 0,
rotationY: initialPose?.rotationY ?? 0,
rotationZ: initialPose?.rotationZ ?? 0,
targetRotationX: initialPose?.targetRotationX ?? 0,
targetRotationY: initialPose?.targetRotationY ?? 0,
velocityX: 0,
velocityY: 0,
};
}
const CanvasMount = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type HourglassCanvasProps = {
geometry: THREE.BufferGeometry;
initialPose?: Partial<HourglassPose>;
previewDistance: number;
settings: HourglassSettings;
};
export function HourglassCanvas({
geometry,
initialPose,
previewDistance,
settings,
}: HourglassCanvasProps) {
const mountReference = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
let animationFrameId = 0;
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 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 environmentTexture = createEnvironmentTexture(renderer);
const scene3d = new THREE.Scene();
scene3d.background = null;
const camera = new THREE.PerspectiveCamera(
45,
getWidth() / getHeight(),
0.1,
100,
);
camera.position.z = previewDistance;
const primaryLight = new THREE.DirectionalLight(
0xffffff,
settings.lighting.intensity,
);
setPrimaryLightPosition(
primaryLight,
settings.lighting.angleDegrees,
settings.lighting.height,
);
scene3d.add(primaryLight);
const fillLight = new THREE.DirectionalLight(
0xffffff,
settings.lighting.fillIntensity,
);
fillLight.position.set(-3, -1, 1);
scene3d.add(fillLight);
const ambientLight = new THREE.AmbientLight(
0xffffff,
settings.lighting.ambientIntensity,
);
scene3d.add(ambientLight);
const material = new THREE.MeshPhysicalMaterial({
clearcoat: 0,
clearcoatRoughness: 0.08,
color: 0xd4d0c8,
envMap: environmentTexture,
envMapIntensity: 0.25,
metalness: settings.material.metalness,
reflectivity: 0.5,
roughness: settings.material.roughness,
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: {
dir: { value: new THREE.Vector2(1, 0) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
tInput: { value: null },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
tInput: { value: null },
},
vertexShader: passThroughVertexShader,
fragmentShader: blurFragmentShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
transparent: true,
uniforms: {
baseInk: { value: settings.halftone.baseInk },
cellRatio: { value: settings.halftone.cellRatio },
contrast: { value: settings.halftone.contrast },
cutoff: { value: settings.halftone.cutoff },
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
distanceScale: { value: previewDistance / REFERENCE_PREVIEW_DISTANCE },
glowStr: { value: 0 },
highlightOpen: { value: settings.halftone.highlightOpen },
maxBar: { value: settings.halftone.maxBar },
numRows: { value: settings.halftone.numRows },
power: { value: settings.halftone.power },
resolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
rowMerge: { value: settings.halftone.rowMerge },
shading: { value: settings.halftone.shading },
shadowCrush: { value: settings.halftone.shadowCrush },
shadowGrouping: { value: settings.halftone.shadowGrouping },
tGlow: { value: blurTargetB.texture },
tScene: { value: sceneTarget.texture },
time: { value: 0 },
waveAmount: {
value: settings.animation.waveEnabled
? settings.animation.waveAmount
: 0,
},
waveSpeed: { value: settings.animation.waveSpeed },
},
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(initialPose);
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 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;
canvas.style.cursor = 'grabbing';
};
const handlePointerMove = (event: PointerEvent) => {
updatePointerPosition(event);
if (
!interaction.dragging ||
(!settings.animation.followDragEnabled &&
!settings.animation.autoRotateEnabled)
) {
return;
}
const deltaX =
(event.clientX - interaction.pointerX) * settings.animation.dragSens;
const deltaY =
(event.clientY - interaction.pointerY) * settings.animation.dragSens;
interaction.velocityX = deltaY;
interaction.velocityY = deltaX;
interaction.targetRotationY += deltaX;
interaction.targetRotationX += deltaY;
interaction.pointerX = event.clientX;
interaction.pointerY = event.clientY;
};
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();
const renderFrame = () => {
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = clock.getDelta();
const elapsedTime =
(initialPose?.timeElapsed ?? 0) + clock.elapsedTime;
halftoneMaterial.uniforms.time.value = elapsedTime;
let baseRotationX = 0;
let baseRotationY = 0;
let baseRotationZ = 0;
if (settings.animation.autoRotateEnabled) {
if (!interaction.dragging) {
interaction.autoElapsed += delta;
interaction.targetRotationX += interaction.velocityX;
interaction.targetRotationY += interaction.velocityY;
interaction.velocityX *= 0.92;
interaction.velocityY *= 0.92;
}
baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed;
baseRotationX +=
Math.sin(interaction.autoElapsed * 0.2) *
settings.animation.autoWobble;
}
if (settings.animation.rotateEnabled) {
interaction.rotateElapsed += delta;
const rotateProgress = settings.animation.rotatePingPong
? Math.sin(
interaction.rotateElapsed * settings.animation.rotateSpeed,
) * Math.PI
: interaction.rotateElapsed * settings.animation.rotateSpeed;
if (settings.animation.rotatePreset === 'axis') {
const axisDirection = settings.animation.rotateAxis.startsWith('-')
? -1
: 1;
const axisProgress = rotateProgress * axisDirection;
if (
settings.animation.rotateAxis === 'x' ||
settings.animation.rotateAxis === 'xy' ||
settings.animation.rotateAxis === '-x' ||
settings.animation.rotateAxis === '-xy'
) {
baseRotationX += axisProgress;
}
if (
settings.animation.rotateAxis === 'y' ||
settings.animation.rotateAxis === 'xy' ||
settings.animation.rotateAxis === '-y' ||
settings.animation.rotateAxis === '-xy'
) {
baseRotationY += axisProgress;
}
if (
settings.animation.rotateAxis === 'z' ||
settings.animation.rotateAxis === '-z'
) {
baseRotationZ += axisProgress;
}
}
}
let targetX = baseRotationX;
let targetY = baseRotationY;
let easing = 0.12;
if (settings.animation.followHoverEnabled) {
const rangeRadians = (settings.animation.hoverRange * Math.PI) / 180;
if (
settings.animation.hoverReturn ||
interaction.mouseX !== 0.5 ||
interaction.mouseY !== 0.5
) {
targetX += (interaction.mouseY - 0.5) * rangeRadians;
targetY += (interaction.mouseX - 0.5) * rangeRadians;
}
easing = settings.animation.hoverEase;
}
if (settings.animation.followDragEnabled) {
if (!interaction.dragging && settings.animation.dragMomentum) {
interaction.targetRotationX += interaction.velocityX;
interaction.targetRotationY += interaction.velocityY;
interaction.velocityX *= 1 - settings.animation.dragFriction;
interaction.velocityY *= 1 - settings.animation.dragFriction;
}
targetX += interaction.targetRotationX;
targetY += interaction.targetRotationY;
easing = settings.animation.dragFriction;
}
if (
settings.animation.autoRotateEnabled &&
!settings.animation.followHoverEnabled &&
!settings.animation.followDragEnabled
) {
targetX = baseRotationX + interaction.targetRotationX;
targetY = baseRotationY + interaction.targetRotationY;
if (interaction.dragging) {
targetX = interaction.targetRotationX;
targetY = interaction.targetRotationY;
}
easing = 0.08;
}
interaction.rotationX += (targetX - interaction.rotationX) * easing;
interaction.rotationY += (targetY - interaction.rotationY) * easing;
interaction.rotationZ +=
(baseRotationZ - interaction.rotationZ) *
(settings.animation.rotatePingPong ? 0.18 : 0.12);
mesh.rotation.set(
interaction.rotationX,
interaction.rotationY,
interaction.rotationZ,
);
if (!settings.halftone.enabled) {
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(scene3d, camera);
return;
}
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);
}
};
}, [geometry, initialPose, previewDistance, settings]);
return (
<CanvasMount $background={settings.background.color} ref={mountReference} />
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,753 +0,0 @@
'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} />;
}
File diff suppressed because it is too large Load Diff
@@ -22,8 +22,6 @@ 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 = {
@@ -36,8 +34,6 @@ 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;
@@ -1,5 +0,0 @@
import { type HeroBaseDataType } from '@/sections/Hero/types/HeroBaseData';
export type HeroIllustrationDataType = HeroBaseDataType & {
illustration: string;
};
@@ -31,5 +31,4 @@ export type {
HeroTablePageDefinition,
HeroVisualType,
} from './HeroHomeData';
export type { HeroIllustrationDataType } from './HeroIllustrationData';
export type { HeroWhyTwentyDataType } from './HeroWhyTwentyData';
@@ -10,16 +10,22 @@ import type {
import { theme } from '@/theme';
import { Drawer } from '@base-ui/react/drawer';
import { styled } from '@linaria/react';
import { useState, type ReactNode } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { CloseDrawerWhenNavigationExpandsEffect } from '../../effect-components/CloseDrawerWhenNavigationExpandsEffect';
import { MenuDrawer } from '../Drawer/Drawer';
const StyledSection = styled.section`
const SCROLL_IDLE_TIMEOUT_MS = 150;
const StyledSection = styled.section<{ $isScrolling: boolean }>`
backdrop-filter: blur(10px);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
box-shadow: ${({ $isScrolling }) =>
$isScrolling
? '0 1px 3px 0 rgba(0, 0, 0, 0.06)'
: '0 1px 3px 0 rgba(0, 0, 0, 0)'};
min-width: 0;
position: sticky;
top: 0;
transition: box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
width: 100%;
z-index: 200;
`;
@@ -76,6 +82,33 @@ export function Root({
socialLinks,
}: RootProps) {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isScrolling, setIsScrolling] = useState(false);
const scrollTimeoutRef = useRef<number | null>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolling(true);
if (scrollTimeoutRef.current !== null) {
window.clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = window.setTimeout(() => {
setIsScrolling(false);
scrollTimeoutRef.current = null;
}, SCROLL_IDLE_TIMEOUT_MS);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (scrollTimeoutRef.current !== null) {
window.clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
const buttonColor: {
border: string;
@@ -103,7 +136,7 @@ export function Root({
<CloseDrawerWhenNavigationExpandsEffect
onClose={() => setIsDrawerOpen(false)}
/>
<StyledSection style={{ backgroundColor }}>
<StyledSection $isScrolling={isScrolling} style={{ backgroundColor }}>
<StyledContainer>
<StyledNav aria-label="Primary navigation" data-scheme={scheme}>
{children}
@@ -1,695 +0,0 @@
'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%',
}}
/>
);
}
@@ -22,7 +22,7 @@ const StyledVisual = styled.div`
`;
const StyledMasked = styled.div`
background-color: ${theme.colors.primary.background[100]};
background-color: #1c1c1c;
height: 100%;
isolation: isolate;
-webkit-mask-image: ${mobileMask};
File diff suppressed because it is too large Load Diff
@@ -35,11 +35,7 @@ export function ThreeCardsCardShape({
style={{ display: "block" }}
>
<path d={THREE_CARDS_CARD_SHAPE_FILL_PATH} fill={fillColor} />
<path
d={THREE_CARDS_CARD_SHAPE_STROKE_PATH}
stroke={strokeColor}
strokeOpacity={0.2}
/>
<path d={THREE_CARDS_CARD_SHAPE_STROKE_PATH} stroke={strokeColor} />
</svg>
</div>
);
@@ -7,6 +7,9 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { ThreeCardsCardShape } from './CardShape';
const CARD_OUTLINE_COLOR = theme.colors.primary.border[20];
const CARD_DIVIDER_COLOR = theme.colors.primary.border[40];
const IllustrationCardContainer = styled.div`
position: relative;
display: grid;
@@ -24,7 +27,7 @@ const IllustrationCardContainer = styled.div`
const CardRule = styled.div`
height: 0;
border-top: 1px dotted ${theme.colors.primary.border[20]};
border-top: 1px dotted ${CARD_DIVIDER_COLOR};
width: 100%;
`;
@@ -48,7 +51,7 @@ const AttributionPipe = styled.span`
display: block;
width: 0;
height: 21px;
border-left: 1px solid ${theme.colors.primary.border[20]};
border-left: 1px solid ${CARD_DIVIDER_COLOR};
`;
const CardBodyCell = styled.div`
@@ -57,6 +60,10 @@ const CardBodyCell = styled.div`
min-width: 0;
`;
const CardBody = styled(Body)`
color: ${theme.colors.primary.text[80]};
`;
type IllustrationCardProps = {
illustrationCard: ThreeCardsIllustrationCardType;
variant?: 'shaped' | 'simple';
@@ -74,7 +81,7 @@ export function IllustrationCard({
{variant === 'shaped' && (
<ThreeCardsCardShape
fillColor={theme.colors.primary.background[100]}
strokeColor={theme.colors.primary.border[40]}
strokeColor={CARD_OUTLINE_COLOR}
/>
)}
<Heading
@@ -89,7 +96,7 @@ export function IllustrationCard({
</CardEmbed>
<CardRule />
<CardBodyCell>
<Body body={illustrationCard.body} size="sm" weight="regular" />
<CardBody body={illustrationCard.body} size="sm" weight="regular" />
</CardBodyCell>
{illustrationCard.attribution && (
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["jest", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", ".next"]
}