Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898a46621b | |||
| a823ef15cb | |||
| 3277830400 | |||
| 280bdcf9ea | |||
| dea94b9ffa | |||
| 669e985b6a | |||
| 41449fc999 |
@@ -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;
|
||||
@@ -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.
Binary file not shown.
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}
|
||||
|
||||
+2
-162
@@ -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
+1
-5
@@ -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>
|
||||
);
|
||||
|
||||
+11
-4
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user