diff --git a/packages/twenty-website-new/public/illustrations/home/three-cards/sun.glb b/packages/twenty-website-new/public/illustrations/home/three-cards/sun.glb new file mode 100644 index 0000000000..ff7ff03c4a Binary files /dev/null and b/packages/twenty-website-new/public/illustrations/home/three-cards/sun.glb differ diff --git a/packages/twenty-website-new/public/illustrations/home/three-cards/wheelx.fbx b/packages/twenty-website-new/public/illustrations/home/three-cards/wheelx.fbx new file mode 100644 index 0000000000..cb4ce7ae4d Binary files /dev/null and b/packages/twenty-website-new/public/illustrations/home/three-cards/wheelx.fbx differ diff --git a/packages/twenty-website-new/public/images/home/problem/halftone-pattern.png b/packages/twenty-website-new/public/images/home/problem/halftone-pattern.png new file mode 100644 index 0000000000..3821cf3047 Binary files /dev/null and b/packages/twenty-website-new/public/images/home/problem/halftone-pattern.png differ diff --git a/packages/twenty-website-new/public/images/home/problem/monolith2.jpg b/packages/twenty-website-new/public/images/home/problem/monolith2.jpg new file mode 100644 index 0000000000..79d03239bf Binary files /dev/null and b/packages/twenty-website-new/public/images/home/problem/monolith2.jpg differ diff --git a/packages/twenty-website-new/public/images/shared/halftone/twenty-logo.svg b/packages/twenty-website-new/public/images/shared/halftone/twenty-logo.svg new file mode 100644 index 0000000000..b3086be88e --- /dev/null +++ b/packages/twenty-website-new/public/images/shared/halftone/twenty-logo.svg @@ -0,0 +1,13 @@ + + + + diff --git a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts b/packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts index 4dbd3b4427..e5f63753d4 100644 --- a/packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts +++ b/packages/twenty-website-new/src/app/(home)/_constants/three-cards-illustration.ts @@ -46,7 +46,7 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = { role: { text: 'Head of Engineering' }, company: { text: 'Mid-Market Fintech' }, }, - illustration: 'lock', + illustration: 'wheelx', }, ], }; diff --git a/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx b/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx new file mode 100644 index 0000000000..aa2ffb7d39 --- /dev/null +++ b/packages/twenty-website-new/src/app/_components/FooterVisibilityGate.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +type FooterVisibilityGateProps = { + children: ReactNode; +}; + +export function FooterVisibilityGate({ + children, +}: FooterVisibilityGateProps) { + const pathname = usePathname(); + + if (pathname === '/halftone') { + return null; + } + + return <>{children}; +} diff --git a/packages/twenty-website-new/src/app/_constants/footer.ts b/packages/twenty-website-new/src/app/_constants/footer.ts index 1472a12641..6526690720 100644 --- a/packages/twenty-website-new/src/app/_constants/footer.ts +++ b/packages/twenty-website-new/src/app/_constants/footer.ts @@ -27,6 +27,11 @@ export const FOOTER_DATA: FooterDataType = { { label: 'FAQ', href: '#', external: false }, { label: 'Support', href: '#', external: false }, { label: 'Release Notes', href: '/release-notes', external: false }, + { + label: 'Halftone generator', + href: '/halftone', + external: false, + }, ], }, { diff --git a/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx new file mode 100644 index 0000000000..03e6c51404 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { AnimationsTab } from './controls/AnimationsTab'; +import { DesignTab } from './controls/DesignTab'; +import { ExportTab } from './controls/ExportTab'; +import { PanelShell, TabButton, TabsBar } from './controls/controls-ui'; + +type HalftoneTabId = 'design' | 'animations' | 'export'; +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; +type HalftoneModelLoader = 'fbx' | 'glb'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +interface HalftoneGeometrySpec { + key: string; + label: string; + kind: 'builtin' | 'imported'; + loader?: HalftoneModelLoader; + filename?: string; + description?: string; + extensions?: readonly string[]; + userProvided?: boolean; +} + +type ControlsPanelProps = { + activeTab: HalftoneTabId; + defaultExportName: string; + exportName: string; + imageFileName: string | null; + onAnimationSettingsChange: ( + value: Partial, + ) => void; + onBackgroundChange: (value: Partial) => void; + onDashColorChange: (value: string) => void; + onExportHalftoneImage: (width: number, height: number) => void; + onExportHtml: () => void; + onExportNameChange: (value: string) => void; + onExportReact: () => void; + onImportPreset: () => void; + onHalftoneChange: ( + value: Partial, + ) => void; + onLightingChange: ( + value: Partial, + ) => void; + onMaterialChange: ( + value: Partial, + ) => void; + onPreviewDistanceChange: (value: number) => void; + onShapeChange: (value: string) => void; + onSourceModeChange: (value: HalftoneSourceMode) => void; + onTabChange: (value: HalftoneTabId) => void; + onUploadImage: () => void; + onUploadModel: () => void; + previewDistance: number; + selectedShape: HalftoneGeometrySpec | undefined; + settings: HalftoneStudioSettings; + shapeOptions: Array<{ label: string; value: string }>; +}; + +const TABS: HalftoneTabId[] = ['design', 'animations', 'export']; + +const TAB_LABELS: Record = { + design: 'Design', + animations: 'Animations', + export: 'Export', +}; + +export function ControlsPanel({ + activeTab, + defaultExportName, + exportName, + imageFileName, + onAnimationSettingsChange, + onBackgroundChange, + onDashColorChange, + onExportHalftoneImage, + onExportHtml, + onExportNameChange, + onExportReact, + onImportPreset, + onHalftoneChange, + onLightingChange, + onMaterialChange, + onPreviewDistanceChange, + onShapeChange, + onSourceModeChange, + onTabChange, + onUploadImage, + onUploadModel, + previewDistance, + selectedShape, + settings, + shapeOptions, +}: ControlsPanelProps) { + return ( + + + {TABS.map((tab) => ( + onTabChange(tab)} + type="button" + > + {TAB_LABELS[tab]} + + ))} + + + {activeTab === 'design' ? ( + + ) : null} + + {activeTab === 'animations' ? ( + + ) : null} + + {activeTab === 'export' ? ( + + ) : null} + + ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx new file mode 100644 index 0000000000..46fd094e74 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneCanvas.tsx @@ -0,0 +1,1967 @@ +'use client'; + +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { styled } from '@linaria/react'; +import { type MutableRefObject, useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = + | 'x' + | 'y' + | 'z' + | 'xy' + | '-x' + | '-y' + | '-z' + | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneExportPose { + autoElapsed: number; + rotateElapsed: number; + rotationX: number; + rotationY: number; + rotationZ: number; + targetRotationX: number; + targetRotationY: number; + timeElapsed: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +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 + #include + } +`; + +const REFERENCE_PREVIEW_DISTANCE = 4; +const IMAGE_POINTER_FOLLOW = 0.38; +const IMAGE_POINTER_VELOCITY_DAMPING = 0.82; +const IMAGE_DRAG_OFFSET_LIMIT = 0.08; +const MAX_PREVIEW_PIXEL_RATIO = 2; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +const CanvasMount = styled.div<{ $background: string }>` + background: ${(props) => props.$background}; + display: block; + height: 100%; + min-width: 0; + width: 100%; +`; + +export type HalftoneSnapshotFn = ( + width: number, + height: number, +) => Promise; + +type HalftoneCanvasProps = { + geometry: THREE.BufferGeometry | null; + initialPose?: Partial; + imageElement: HTMLImageElement | null; + onFirstInteraction: () => void; + onPoseChange: (pose: HalftoneExportPose) => void; + previewDistance: number; + settings: HalftoneStudioSettings; + snapshotRef?: MutableRefObject; +}; + +type SceneResources = { + ambientLight: THREE.AmbientLight; + blurHorizontalMaterial: THREE.ShaderMaterial; + blurHorizontalScene: THREE.Scene; + blurTargetA: THREE.WebGLRenderTarget; + blurTargetB: THREE.WebGLRenderTarget; + blurVerticalMaterial: THREE.ShaderMaterial; + blurVerticalScene: THREE.Scene; + camera: THREE.PerspectiveCamera; + canvas: HTMLCanvasElement; + environmentTexture: THREE.Texture; + fillLight: THREE.DirectionalLight; + fullScreenGeometry: THREE.PlaneGeometry; + halftoneMaterial: THREE.ShaderMaterial; + imageMaterial: THREE.ShaderMaterial; + imageScene: THREE.Scene; + imageTexture: THREE.Texture | null; + material: THREE.MeshPhysicalMaterial; + mesh: THREE.Mesh; + orthographicCamera: THREE.OrthographicCamera; + postScene: THREE.Scene; + primaryLight: THREE.DirectionalLight; + renderer: THREE.WebGLRenderer; + scene3d: THREE.Scene; + sceneTarget: THREE.WebGLRenderTarget; +}; + +type InteractionState = { + activePointerId: number | null; + autoElapsed: number; + dragOffsetX: number; + dragOffsetY: number; + dragging: boolean; + mouseX: number; + mouseY: number; + pointerInside: boolean; + pointerVelocityX: number; + pointerVelocityY: number; + pointerX: number; + pointerY: number; + rotateElapsed: number; + rotationX: number; + rotationVelocityX: number; + rotationY: number; + rotationVelocityY: number; + rotationZ: number; + rotationVelocityZ: number; + smoothedMouseX: number; + smoothedMouseY: number; + targetRotationX: number; + targetRotationY: number; + velocityX: number; + velocityY: number; +}; + +type PixelBounds = { + maxX: number; + maxY: number; + minX: number; + minY: number; +}; + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function createInteractionState( + initialPose?: Partial, +): InteractionState { + return { + activePointerId: null, + autoElapsed: initialPose?.autoElapsed ?? 0, + dragOffsetX: 0, + dragOffsetY: 0, + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerInside: false, + pointerVelocityX: 0, + pointerVelocityY: 0, + pointerX: 0, + pointerY: 0, + rotateElapsed: initialPose?.rotateElapsed ?? 0, + rotationX: initialPose?.rotationX ?? 0, + rotationVelocityX: 0, + rotationY: initialPose?.rotationY ?? 0, + rotationVelocityY: 0, + rotationZ: initialPose?.rotationZ ?? 0, + rotationVelocityZ: 0, + smoothedMouseX: 0.5, + smoothedMouseY: 0.5, + targetRotationX: initialPose?.targetRotationX ?? 0, + targetRotationY: initialPose?.targetRotationY ?? 0, + velocityX: 0, + velocityY: 0, + }; +} + +function getAlphaCropBounds( + pixels: Uint8Array, + width: number, + height: number, + threshold = 8, + padding = 1, +): PixelBounds | null { + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const alpha = pixels[(y * width + x) * 4 + 3]; + + if (alpha <= threshold) { + continue; + } + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + if (maxX < minX || maxY < minY) { + return null; + } + + return { + minX: Math.max(minX - padding, 0), + minY: Math.max(minY - padding, 0), + maxX: Math.min(maxX + padding, width - 1), + maxY: Math.min(maxY + padding, height - 1), + }; +} + +function getCanvasCursor( + settings: HalftoneStudioSettings, + isDragging: boolean, +) { + if (settings.sourceMode === 'image') { + if (settings.animation.dragFlowEnabled) { + return isDragging ? 'grabbing' : 'crosshair'; + } + + return 'crosshair'; + } + + if ( + settings.animation.followDragEnabled || + settings.animation.autoRotateEnabled + ) { + return isDragging ? 'grabbing' : 'grab'; + } + + return 'default'; +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, +) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +function getImagePreviewZoom(previewDistance: number) { + return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); +} + +function updateLighting( + resources: SceneResources, + settings: HalftoneStudioSettings, +) { + resources.primaryLight.intensity = settings.lighting.intensity; + setPrimaryLightPosition( + resources.primaryLight, + settings.lighting.angleDegrees, + settings.lighting.height, + ); + resources.fillLight.intensity = settings.lighting.fillIntensity; + resources.ambientLight.intensity = settings.lighting.ambientIntensity; +} + +function updateMaterial( + resources: SceneResources, + settings: HalftoneStudioSettings, +) { + resources.material.roughness = settings.material.roughness; + resources.material.metalness = settings.material.metalness; + resources.material.needsUpdate = true; +} + +function updateHalftone( + resources: SceneResources, + settings: HalftoneStudioSettings, +) { + resources.halftoneMaterial.uniforms.numRows.value = settings.halftone.numRows; + resources.halftoneMaterial.uniforms.contrast.value = + settings.halftone.contrast; + resources.halftoneMaterial.uniforms.power.value = settings.halftone.power; + resources.halftoneMaterial.uniforms.shading.value = settings.halftone.shading; + resources.halftoneMaterial.uniforms.baseInk.value = settings.halftone.baseInk; + resources.halftoneMaterial.uniforms.maxBar.value = settings.halftone.maxBar; + resources.halftoneMaterial.uniforms.rowMerge.value = + settings.halftone.rowMerge; + resources.halftoneMaterial.uniforms.cellRatio.value = + settings.halftone.cellRatio; + resources.halftoneMaterial.uniforms.cutoff.value = settings.halftone.cutoff; + resources.halftoneMaterial.uniforms.highlightOpen.value = + settings.halftone.highlightOpen; + resources.halftoneMaterial.uniforms.shadowGrouping.value = + settings.halftone.shadowGrouping; + resources.halftoneMaterial.uniforms.shadowCrush.value = + settings.halftone.shadowCrush; + (resources.halftoneMaterial.uniforms.dashColor.value as THREE.Color).set( + settings.halftone.dashColor, + ); + resources.halftoneMaterial.uniforms.waveAmount.value = + settings.animation.waveEnabled && settings.sourceMode !== 'image' + ? settings.animation.waveAmount + : 0; + resources.halftoneMaterial.uniforms.waveSpeed.value = + settings.animation.waveSpeed; +} + +function syncResources( + resources: SceneResources, + settings: HalftoneStudioSettings, +) { + updateLighting(resources, settings); + updateMaterial(resources, settings); + updateHalftone(resources, settings); +} + +function applySpringStep( + current: number, + target: number, + velocity: number, + strength: number, + damping: number, +) { + const nextVelocity = (velocity + (target - current) * strength) * damping; + const nextValue = current + nextVelocity; + + return { + value: nextValue, + velocity: nextVelocity, + }; +} + +function resetInteractionState( + interactionState: InteractionState, + animation: HalftoneStudioSettings['animation'], +) { + interactionState.activePointerId = null; + interactionState.dragging = false; + interactionState.dragOffsetX = 0; + interactionState.dragOffsetY = 0; + interactionState.mouseX = 0.5; + interactionState.mouseY = 0.5; + interactionState.pointerInside = false; + interactionState.pointerVelocityX = 0; + interactionState.pointerVelocityY = 0; + interactionState.smoothedMouseX = 0.5; + interactionState.smoothedMouseY = 0.5; + interactionState.targetRotationX = 0; + interactionState.targetRotationY = 0; + interactionState.velocityX = 0; + interactionState.velocityY = 0; + interactionState.rotationVelocityX = 0; + interactionState.rotationVelocityY = 0; + interactionState.rotationVelocityZ = 0; + + if (animation.autoRotateEnabled) { + interactionState.autoElapsed = 0; + } +} + +export function HalftoneCanvas({ + geometry, + initialPose, + imageElement, + onFirstInteraction, + onPoseChange, + previewDistance, + settings, + snapshotRef, +}: HalftoneCanvasProps) { + const mountReference = useRef(null); + const resourcesReference = useRef(null); + const settingsReference = useRef(settings); + const interactionReference = useRef( + createInteractionState(initialPose), + ); + const animationReference = useRef(settings.animation); + const didInteractReference = useRef(false); + const initialPoseReference = useRef(initialPose); + const poseChangeReference = useRef(onPoseChange); + const previewDistanceReference = useRef(previewDistance); + + useEffect(() => { + initialPoseReference.current = initialPose; + interactionReference.current = createInteractionState(initialPose); + }, [initialPose]); + + useEffect(() => { + poseChangeReference.current = onPoseChange; + }, [onPoseChange]); + + useEffect(() => { + previewDistanceReference.current = previewDistance; + + const resources = resourcesReference.current; + + if (!resources) { + return; + } + + resources.camera.position.z = previewDistance; + }, [previewDistance]); + + useEffect(() => { + settingsReference.current = settings; + + const resources = resourcesReference.current; + + if (!resources) { + animationReference.current = settings.animation; + return; + } + + const prev = animationReference.current; + const next = settings.animation; + + if ( + prev.autoRotateEnabled !== next.autoRotateEnabled || + prev.followHoverEnabled !== next.followHoverEnabled || + prev.followDragEnabled !== next.followDragEnabled || + prev.hoverLightEnabled !== next.hoverLightEnabled || + prev.dragFlowEnabled !== next.dragFlowEnabled + ) { + resetInteractionState(interactionReference.current, next); + } + + if ( + (!prev.rotateEnabled && next.rotateEnabled) || + prev.rotatePreset !== next.rotatePreset + ) { + interactionReference.current.rotateElapsed = 0; + } + + animationReference.current = settings.animation; + syncResources(resources, settings); + resources.canvas.style.cursor = getCanvasCursor( + settings, + interactionReference.current.dragging, + ); + }, [settings]); + + useEffect(() => { + const resources = resourcesReference.current; + + if (!resources || !geometry) { + return; + } + + resources.mesh.geometry = geometry; + }, [geometry]); + + useEffect(() => { + const resources = resourcesReference.current; + + if (!resources) { + return; + } + + if (imageElement) { + if (resources.imageTexture) { + resources.imageTexture.dispose(); + } + + const texture = new THREE.Texture(imageElement); + texture.needsUpdate = true; + texture.colorSpace = THREE.SRGBColorSpace; + resources.imageTexture = texture; + resources.imageMaterial.uniforms.tImage.value = texture; + resources.imageMaterial.uniforms.imageSize.value.set( + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + } else { + if (resources.imageTexture) { + resources.imageTexture.dispose(); + resources.imageTexture = null; + resources.imageMaterial.uniforms.tImage.value = null; + } + } + }, [imageElement]); + + useEffect(() => { + const container = mountReference.current; + + if (!container || !geometry) { + return; + } + + let animationFrameId = 0; + let cancelled = false; + + 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 getRenderScale = () => + Math.min(window.devicePixelRatio || 1, MAX_PREVIEW_PIXEL_RATIO); + const getRenderHeight = () => + Math.max(Math.round(getVirtualHeight() * getRenderScale()), 1); + const getRenderWidth = () => + Math.max(Math.round(getVirtualWidth() * getRenderScale()), 1); + + const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getRenderWidth(), getRenderHeight(), false); + + const canvas = renderer.domElement; + canvas.style.cursor = getCanvasCursor(settingsReference.current, false); + 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, 1.5); + scene3d.add(primaryLight); + + const fillLight = new THREE.DirectionalLight(0xffffff, 0.15); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight(0xffffff, 0.08); + scene3d.add(ambientLight); + + const material = new THREE.MeshPhysicalMaterial({ + color: 0xd4d0c8, + roughness: 0.42, + metalness: 0.16, + 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(getRenderWidth(), getRenderHeight()); + const blurTargetA = createRenderTarget(getRenderWidth(), getRenderHeight()); + const blurTargetB = createRenderTarget(getRenderWidth(), getRenderHeight()); + 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(getRenderWidth(), getRenderHeight()), + }, + }, + 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(getRenderWidth(), getRenderHeight()), + }, + }, + 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(getRenderWidth(), getRenderHeight()), + }, + numRows: { value: settings.halftone.numRows }, + glowStr: { value: 0 }, + contrast: { value: settings.halftone.contrast }, + power: { value: settings.halftone.power }, + shading: { value: settings.halftone.shading }, + baseInk: { value: settings.halftone.baseInk }, + maxBar: { value: settings.halftone.maxBar }, + rowMerge: { value: settings.halftone.rowMerge }, + cellRatio: { value: settings.halftone.cellRatio }, + cutoff: { value: settings.halftone.cutoff }, + highlightOpen: { value: settings.halftone.highlightOpen }, + shadowGrouping: { value: settings.halftone.shadowGrouping }, + shadowCrush: { value: settings.halftone.shadowCrush }, + dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1.0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add( + new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), + ); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const imageMaterial = new THREE.ShaderMaterial({ + uniforms: { + tImage: { value: null }, + imageSize: { value: new THREE.Vector2(1, 1) }, + viewportSize: { + value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), + }, + zoom: { value: getImagePreviewZoom(previewDistance) }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: imagePassthroughFragmentShader, + }); + + const imageScene = new THREE.Scene(); + imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); + + const resources: SceneResources = { + ambientLight, + blurHorizontalMaterial, + blurHorizontalScene, + blurTargetA, + blurTargetB, + blurVerticalMaterial, + blurVerticalScene, + camera, + canvas, + environmentTexture, + fillLight, + fullScreenGeometry, + halftoneMaterial, + imageMaterial, + imageScene, + imageTexture: null, + material, + mesh, + orthographicCamera, + postScene, + primaryLight, + renderer, + scene3d, + sceneTarget, + }; + + resourcesReference.current = resources; + syncResources(resources, settingsReference.current); + + // Snapshot: render the current frame at arbitrary resolution and return a PNG blob + const captureSnapshot: HalftoneSnapshotFn = async ( + snapshotWidth: number, + snapshotHeight: number, + ) => { + const activeSettings = settingsReference.current; + const isImage = + activeSettings.sourceMode === 'image' && + resources.imageTexture !== null; + + // Create temporary render targets at export resolution + const snapScene = createRenderTarget(snapshotWidth, snapshotHeight); + const snapBlurA = createRenderTarget(snapshotWidth, snapshotHeight); + const snapBlurB = createRenderTarget(snapshotWidth, snapshotHeight); + + // Save current renderer state + const prevSize = renderer.getSize(new THREE.Vector2()); + + // Resize renderer to snapshot resolution + renderer.setSize(snapshotWidth, snapshotHeight, false); + + // Update material uniforms for snapshot resolution + blurHorizontalMaterial.uniforms.res.value.set( + snapshotWidth, + snapshotHeight, + ); + blurVerticalMaterial.uniforms.res.value.set( + snapshotWidth, + snapshotHeight, + ); + halftoneMaterial.uniforms.resolution.value.set( + snapshotWidth, + snapshotHeight, + ); + // Disable distance scaling and pointer effects for export + halftoneMaterial.uniforms.distanceScale.value = 1.0; + halftoneMaterial.uniforms.hoverLightStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + halftoneMaterial.uniforms.cropToBounds.value = isImage ? 1 : 0; + + if (isImage) { + imageMaterial.uniforms.viewportSize.value.set( + snapshotWidth, + snapshotHeight, + ); + imageMaterial.uniforms.zoom.value = 1; + } else { + camera.aspect = snapshotWidth / snapshotHeight; + camera.updateProjectionMatrix(); + } + + // Render scene to snapshot target + renderer.setRenderTarget(snapScene); + if (isImage) { + renderer.render(imageScene, orthographicCamera); + } else { + renderer.render(scene3d, camera); + } + + // Blur passes + halftoneMaterial.uniforms.tScene.value = snapScene.texture; + + blurHorizontalMaterial.uniforms.tInput.value = snapScene.texture; + renderer.setRenderTarget(snapBlurA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; + renderer.setRenderTarget(snapBlurB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = snapBlurB.texture; + renderer.setRenderTarget(snapBlurA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; + renderer.setRenderTarget(snapBlurB); + renderer.render(blurVerticalScene, orthographicCamera); + + halftoneMaterial.uniforms.tGlow.value = snapBlurB.texture; + + // Final halftone render to a target we can read pixels from + const outputTarget = createRenderTarget(snapshotWidth, snapshotHeight); + renderer.setRenderTarget(outputTarget); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + + // Read pixels + const pixelBuffer = new Uint8Array(snapshotWidth * snapshotHeight * 4); + renderer.readRenderTargetPixels( + outputTarget, + 0, + 0, + snapshotWidth, + snapshotHeight, + pixelBuffer, + ); + + // Restore renderer state + renderer.setSize(prevSize.x, prevSize.y, false); + halftoneMaterial.uniforms.tScene.value = sceneTarget.texture; + halftoneMaterial.uniforms.tGlow.value = blurTargetB.texture; + blurHorizontalMaterial.uniforms.res.value.set(prevSize.x, prevSize.y); + blurVerticalMaterial.uniforms.res.value.set(prevSize.x, prevSize.y); + halftoneMaterial.uniforms.resolution.value.set(prevSize.x, prevSize.y); + halftoneMaterial.uniforms.distanceScale.value = isImage + ? 1 + : previewDistanceReference.current / REFERENCE_PREVIEW_DISTANCE; + if (isImage) { + imageMaterial.uniforms.viewportSize.value.set(prevSize.x, prevSize.y); + imageMaterial.uniforms.zoom.value = getImagePreviewZoom( + previewDistanceReference.current, + ); + } else { + camera.aspect = getWidth() / Math.max(getHeight(), 1); + camera.updateProjectionMatrix(); + } + + // Dispose temporary targets + snapScene.dispose(); + snapBlurA.dispose(); + snapBlurB.dispose(); + outputTarget.dispose(); + + // Convert pixels to PNG via canvas + // WebGL readPixels returns rows bottom-to-top, so flip vertically + const flippedBuffer = new Uint8Array(snapshotWidth * snapshotHeight * 4); + const rowSize = snapshotWidth * 4; + for (let y = 0; y < snapshotHeight; y++) { + const srcOffset = y * rowSize; + const dstOffset = (snapshotHeight - 1 - y) * rowSize; + flippedBuffer.set( + pixelBuffer.subarray(srcOffset, srcOffset + rowSize), + dstOffset, + ); + } + + const cropBounds = getAlphaCropBounds( + flippedBuffer, + snapshotWidth, + snapshotHeight, + ) ?? { + minX: 0, + minY: 0, + maxX: snapshotWidth - 1, + maxY: snapshotHeight - 1, + }; + const croppedWidth = cropBounds.maxX - cropBounds.minX + 1; + const croppedHeight = cropBounds.maxY - cropBounds.minY + 1; + const croppedBuffer = new Uint8ClampedArray( + croppedWidth * croppedHeight * 4, + ); + + for (let y = 0; y < croppedHeight; y++) { + const sourceStart = + ((cropBounds.minY + y) * snapshotWidth + cropBounds.minX) * 4; + const sourceEnd = sourceStart + croppedWidth * 4; + const destinationStart = y * croppedWidth * 4; + + croppedBuffer.set( + flippedBuffer.subarray(sourceStart, sourceEnd), + destinationStart, + ); + } + + const imageData = new ImageData( + croppedBuffer, + croppedWidth, + croppedHeight, + ); + const offscreen = document.createElement('canvas'); + offscreen.width = croppedWidth; + offscreen.height = croppedHeight; + const ctx = offscreen.getContext('2d'); + + if (!ctx) { + return null; + } + + ctx.putImageData(imageData, 0, 0); + + return new Promise((resolve) => { + offscreen.toBlob((blob) => resolve(blob), 'image/png'); + }); + }; + + if (snapshotRef) { + snapshotRef.current = captureSnapshot; + } + + const syncSize = () => { + if (cancelled) { + return; + } + + const width = getWidth(); + const height = getHeight(); + const renderWidth = getRenderWidth(); + const renderHeight = getRenderHeight(); + + renderer.setSize(renderWidth, renderHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(renderWidth, renderHeight); + blurTargetA.setSize(renderWidth, renderHeight); + blurTargetB.setSize(renderWidth, renderHeight); + blurHorizontalMaterial.uniforms.res.value.set(renderWidth, renderHeight); + blurVerticalMaterial.uniforms.res.value.set(renderWidth, renderHeight); + halftoneMaterial.uniforms.resolution.value.set(renderWidth, renderHeight); + imageMaterial.uniforms.viewportSize.value.set(renderWidth, renderHeight); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + const updatePointerPosition = ( + event: PointerEvent, + options?: { resetVelocity?: boolean }, + ) => { + const interaction = interactionReference.current; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + const nextMouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, + 0, + 1, + ); + const nextMouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, + 0, + 1, + ); + + const deltaX = nextMouseX - interaction.mouseX; + const deltaY = nextMouseY - interaction.mouseY; + + interaction.mouseX = nextMouseX; + interaction.mouseY = nextMouseY; + interaction.pointerInside = + interaction.dragging || + (event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom); + + if (options?.resetVelocity) { + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + interaction.smoothedMouseX = nextMouseX; + interaction.smoothedMouseY = nextMouseY; + } else { + interaction.pointerVelocityX = deltaX; + interaction.pointerVelocityY = deltaY; + } + + return { deltaX, deltaY }; + }; + + const releasePointerCapture = (pointerId: number | null) => { + if (pointerId === null) { + return; + } + + if (!canvas.hasPointerCapture(pointerId)) { + return; + } + + try { + canvas.releasePointerCapture(pointerId); + } catch { + // Ignore capture release failures during teardown. + } + }; + + const handlePointerDown = (event: PointerEvent) => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + const canDrag = + activeSettings.sourceMode === 'image' + ? activeSettings.animation.dragFlowEnabled + : activeSettings.animation.followDragEnabled || + activeSettings.animation.autoRotateEnabled; + + updatePointerPosition(event, { resetVelocity: true }); + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + + if (canDrag) { + interaction.dragging = true; + interaction.activePointerId = event.pointerId; + interaction.velocityX = 0; + interaction.velocityY = 0; + + try { + canvas.setPointerCapture(event.pointerId); + } catch { + // Pointer capture can fail in some browsers if the canvas is detached. + } + } + + canvas.style.cursor = getCanvasCursor( + activeSettings, + interaction.dragging, + ); + + if (!didInteractReference.current) { + didInteractReference.current = true; + onFirstInteraction(); + } + }; + + const handlePointerMove = (event: PointerEvent) => { + const interaction = interactionReference.current; + const resetVelocity = !interaction.pointerInside && !interaction.dragging; + const pointerStep = updatePointerPosition( + event, + resetVelocity ? { resetVelocity: true } : undefined, + ); + const activeSettings = settingsReference.current; + + if (!interaction.dragging) { + return; + } + + if ( + interaction.activePointerId !== null && + event.pointerId !== interaction.activePointerId + ) { + return; + } + + if (activeSettings.sourceMode === 'image') { + if (!activeSettings.animation.dragFlowEnabled) { + return; + } + + interaction.dragOffsetX = THREE.MathUtils.clamp( + interaction.dragOffsetX + pointerStep.deltaX * 2.2, + -IMAGE_DRAG_OFFSET_LIMIT, + IMAGE_DRAG_OFFSET_LIMIT, + ); + interaction.dragOffsetY = THREE.MathUtils.clamp( + interaction.dragOffsetY + pointerStep.deltaY * 2.2, + -IMAGE_DRAG_OFFSET_LIMIT, + IMAGE_DRAG_OFFSET_LIMIT, + ); + return; + } + + const animation = activeSettings.animation; + + if (!animation.followDragEnabled && !animation.autoRotateEnabled) { + return; + } + + const deltaX = + (event.clientX - interaction.pointerX) * animation.dragSens; + const deltaY = + (event.clientY - interaction.pointerY) * animation.dragSens; + interaction.velocityX = deltaY; + interaction.velocityY = deltaX; + interaction.targetRotationY += deltaX; + interaction.targetRotationX += deltaY; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + }; + + const handlePointerLeave = () => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + + if (interaction.dragging) { + return; + } + + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + + if (activeSettings.sourceMode !== 'image') { + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + } + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + }; + + const handlePointerUp = (event: PointerEvent) => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + const animation = activeSettings.animation; + + updatePointerPosition(event, { resetVelocity: true }); + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + const rect = canvas.getBoundingClientRect(); + interaction.pointerInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if (!interaction.pointerInside && activeSettings.sourceMode !== 'image') { + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + } + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + + if (!animation.springReturnEnabled) { + return; + } + + const springImpulse = Math.max(animation.springStrength * 10, 1.2); + interaction.rotationVelocityX += interaction.velocityX * springImpulse; + interaction.rotationVelocityY += interaction.velocityY * springImpulse; + interaction.rotationVelocityZ += + interaction.velocityY * springImpulse * 0.12; + interaction.targetRotationX = 0; + interaction.targetRotationY = 0; + interaction.velocityX = 0; + interaction.velocityY = 0; + }; + + const handlePointerCancel = () => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + interaction.dragOffsetX = 0; + interaction.dragOffsetY = 0; + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + + if (activeSettings.sourceMode !== 'image') { + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + } + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + }; + + const handleWindowBlur = () => { + handlePointerCancel(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerup', handlePointerUp); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + const clock = new THREE.Clock(); + + const renderFrame = () => { + if (cancelled) { + return; + } + + animationFrameId = window.requestAnimationFrame(renderFrame); + + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + const elapsedTime = + (initialPoseReference.current?.timeElapsed ?? 0) + + clock.getElapsedTime(); + const delta = 1 / 60; + const baseDistance = previewDistanceReference.current; + const isImageMode = activeSettings.sourceMode === 'image'; + const hasImageTexture = resources.imageTexture !== null; + + halftoneMaterial.uniforms.time.value = elapsedTime; + halftoneMaterial.uniforms.waveAmount.value = + activeSettings.animation.waveEnabled && !isImageMode + ? activeSettings.animation.waveAmount + : 0; + halftoneMaterial.uniforms.waveSpeed.value = + activeSettings.animation.waveSpeed; + halftoneMaterial.uniforms.distanceScale.value = isImageMode + ? 1 + : baseDistance / REFERENCE_PREVIEW_DISTANCE; + + // Image mode selected but no image loaded yet — show empty canvas + if (isImageMode && !hasImageTexture) { + renderer.setRenderTarget(null); + renderer.clear(); + return; + } + + halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0; + + if (isImageMode) { + const pointerFollow = interaction.dragging + ? 0.46 + : IMAGE_POINTER_FOLLOW; + const pointerActive = interaction.pointerInside || interaction.dragging; + + interaction.smoothedMouseX += + (interaction.mouseX - interaction.smoothedMouseX) * pointerFollow; + interaction.smoothedMouseY += + (interaction.mouseY - interaction.smoothedMouseY) * pointerFollow; + interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING; + interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING; + + if (activeSettings.animation.dragFlowEnabled) { + const dragDecay = 1 - activeSettings.animation.dragFlowDecay; + interaction.dragOffsetX *= dragDecay; + interaction.dragOffsetY *= dragDecay; + + if (Math.abs(interaction.dragOffsetX) < 0.00005) { + interaction.dragOffsetX = 0; + } + + if (Math.abs(interaction.dragOffsetY) < 0.00005) { + interaction.dragOffsetY = 0; + } + } else { + interaction.dragOffsetX = 0; + interaction.dragOffsetY = 0; + } + + const dragActive = + activeSettings.animation.dragFlowEnabled && + (interaction.dragging || + Math.abs(interaction.dragOffsetX) > 0.0005 || + Math.abs(interaction.dragOffsetY) > 0.0005); + + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.smoothedMouseX, + 1 - interaction.smoothedMouseY, + ); + halftoneMaterial.uniforms.interactionVelocity.value.set( + interaction.pointerVelocityX * getRenderWidth(), + -interaction.pointerVelocityY * getRenderHeight(), + ); + halftoneMaterial.uniforms.dragOffset.value.set( + interaction.dragOffsetX * getRenderWidth(), + -interaction.dragOffsetY * getRenderHeight(), + ); + halftoneMaterial.uniforms.hoverLightStrength.value = + pointerActive && activeSettings.animation.hoverLightEnabled + ? activeSettings.animation.hoverLightIntensity + : 0; + halftoneMaterial.uniforms.hoverLightRadius.value = + activeSettings.animation.hoverLightRadius; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; + halftoneMaterial.uniforms.dragFlowStrength.value = dragActive + ? activeSettings.animation.dragFlowStrength + : 0; + halftoneMaterial.uniforms.dragFlowRadius.value = + activeSettings.animation.dragFlowRadius; + + imageMaterial.uniforms.zoom.value = getImagePreviewZoom(baseDistance); + imageMaterial.uniforms.viewportSize.value.set( + getRenderWidth(), + getRenderHeight(), + ); + + poseChangeReference.current({ + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: interaction.targetRotationX, + targetRotationY: interaction.targetRotationY, + timeElapsed: elapsedTime, + }); + + if (!activeSettings.halftone.enabled) { + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(imageScene, orthographicCamera); + return; + } + + renderer.setRenderTarget(sceneTarget); + renderer.render(imageScene, orthographicCamera); + } + + if (!isImageMode) { + let baseRotationX = 0; + let baseRotationY = 0; + let baseRotationZ = 0; + let meshOffsetY = 0; + let meshScale = 1; + let lightAngle = activeSettings.lighting.angleDegrees; + let lightHeight = activeSettings.lighting.height; + + if (activeSettings.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 * activeSettings.animation.autoSpeed; + baseRotationX += + Math.sin(interaction.autoElapsed * 0.2) * + activeSettings.animation.autoWobble; + } + + if (activeSettings.animation.floatEnabled) { + const floatPhase = elapsedTime * activeSettings.animation.floatSpeed; + const driftAmount = + (activeSettings.animation.driftAmount * Math.PI) / 180; + + meshOffsetY += + Math.sin(floatPhase) * activeSettings.animation.floatAmplitude; + baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; + baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; + } + + if (activeSettings.animation.breatheEnabled) { + meshScale *= + 1 + + Math.sin(elapsedTime * activeSettings.animation.breatheSpeed) * + activeSettings.animation.breatheAmount; + } + + if (activeSettings.animation.rotateEnabled) { + interaction.rotateElapsed += delta; + const rotateProgress = activeSettings.animation.rotatePingPong + ? Math.sin( + interaction.rotateElapsed * + activeSettings.animation.rotateSpeed, + ) * Math.PI + : interaction.rotateElapsed * activeSettings.animation.rotateSpeed; + + if (activeSettings.animation.rotatePreset === 'axis') { + const axisDirection = + activeSettings.animation.rotateAxis.startsWith('-') ? -1 : 1; + const axisProgress = rotateProgress * axisDirection; + + if ( + activeSettings.animation.rotateAxis === 'x' || + activeSettings.animation.rotateAxis === 'xy' || + activeSettings.animation.rotateAxis === '-x' || + activeSettings.animation.rotateAxis === '-xy' + ) { + baseRotationX += axisProgress; + } + + if ( + activeSettings.animation.rotateAxis === 'y' || + activeSettings.animation.rotateAxis === 'xy' || + activeSettings.animation.rotateAxis === '-y' || + activeSettings.animation.rotateAxis === '-xy' + ) { + baseRotationY += axisProgress; + } + + if ( + activeSettings.animation.rotateAxis === 'z' || + activeSettings.animation.rotateAxis === '-z' + ) { + baseRotationZ += axisProgress; + } + } else if (activeSettings.animation.rotatePreset === 'lissajous') { + baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; + baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; + baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; + } else if (activeSettings.animation.rotatePreset === 'orbit') { + baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; + baseRotationY += Math.cos(rotateProgress) * 1.2; + baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; + } else if (activeSettings.animation.rotatePreset === 'tumble') { + baseRotationX += rotateProgress * 0.55; + baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; + baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; + } + } + + if (activeSettings.animation.lightSweepEnabled) { + const lightPhase = + elapsedTime * activeSettings.animation.lightSweepSpeed; + lightAngle += + Math.sin(lightPhase) * activeSettings.animation.lightSweepRange; + lightHeight += + Math.cos(lightPhase * 0.85) * + activeSettings.animation.lightSweepHeightRange; + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (activeSettings.animation.followHoverEnabled) { + const rangeRadians = + (activeSettings.animation.hoverRange * Math.PI) / 180; + + if ( + activeSettings.animation.hoverReturn || + interaction.mouseX !== 0.5 || + interaction.mouseY !== 0.5 + ) { + targetX += (interaction.mouseY - 0.5) * rangeRadians; + targetY += (interaction.mouseX - 0.5) * rangeRadians; + } + + easing = activeSettings.animation.hoverEase; + } + + if (activeSettings.animation.followDragEnabled) { + if (!interaction.dragging && activeSettings.animation.dragMomentum) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= 1 - activeSettings.animation.dragFriction; + interaction.velocityY *= 1 - activeSettings.animation.dragFriction; + } + + targetX += interaction.targetRotationX; + targetY += interaction.targetRotationY; + easing = activeSettings.animation.dragFriction; + } + + if ( + activeSettings.animation.autoRotateEnabled && + !activeSettings.animation.followHoverEnabled && + !activeSettings.animation.followDragEnabled + ) { + targetX = baseRotationX + interaction.targetRotationX; + targetY = baseRotationY + interaction.targetRotationY; + + if (interaction.dragging) { + targetX = interaction.targetRotationX; + targetY = interaction.targetRotationY; + } + + easing = 0.08; + } + + if (activeSettings.animation.springReturnEnabled) { + const springX = applySpringStep( + interaction.rotationX, + targetX, + interaction.rotationVelocityX, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + const springY = applySpringStep( + interaction.rotationY, + targetY, + interaction.rotationVelocityY, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + const springZ = applySpringStep( + interaction.rotationZ, + baseRotationZ, + interaction.rotationVelocityZ, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + + interaction.rotationX = springX.value; + interaction.rotationY = springY.value; + interaction.rotationZ = springZ.value; + interaction.rotationVelocityX = springX.velocity; + interaction.rotationVelocityY = springY.velocity; + interaction.rotationVelocityZ = springZ.velocity; + } else { + interaction.rotationX += (targetX - interaction.rotationX) * easing; + interaction.rotationY += (targetY - interaction.rotationY) * easing; + interaction.rotationZ += + (baseRotationZ - interaction.rotationZ) * + (activeSettings.animation.rotatePingPong ? 0.18 : 0.12); + } + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, + ); + mesh.position.y = meshOffsetY; + mesh.scale.setScalar(meshScale); + + if (activeSettings.animation.cameraParallaxEnabled) { + const cameraRange = activeSettings.animation.cameraParallaxAmount; + const cameraEase = activeSettings.animation.cameraParallaxEase; + const centeredX = (interaction.mouseX - 0.5) * 2; + const centeredY = (0.5 - interaction.mouseY) * 2; + const orbitYaw = centeredX * cameraRange; + const orbitPitch = centeredY * cameraRange * 0.7; + const horizontalRadius = Math.cos(orbitPitch) * baseDistance; + const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; + const targetCameraY = Math.sin(orbitPitch) * baseDistance * 0.85; + const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; + + camera.position.x += (targetCameraX - camera.position.x) * cameraEase; + camera.position.y += (targetCameraY - camera.position.y) * cameraEase; + camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; + } else { + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (baseDistance - camera.position.z) * 0.12; + } + + camera.lookAt(0, meshOffsetY * 0.2, 0); + setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); + + poseChangeReference.current({ + autoElapsed: interaction.autoElapsed, + rotateElapsed: interaction.rotateElapsed, + rotationX: interaction.rotationX, + rotationY: interaction.rotationY, + rotationZ: interaction.rotationZ, + targetRotationX: interaction.targetRotationX, + targetRotationY: interaction.targetRotationY, + timeElapsed: elapsedTime, + }); + + if (!activeSettings.halftone.enabled) { + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(scene3d, camera); + return; + } + + renderer.setRenderTarget(sceneTarget); + renderer.render(scene3d, camera); + halftoneMaterial.uniforms.hoverLightStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + } // end if (!isImageMode) + + 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 () => { + cancelled = true; + resizeObserver.disconnect(); + canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointerup', handlePointerUp); + canvas.removeEventListener('pointercancel', handlePointerCancel); + window.removeEventListener('blur', handleWindowBlur); + canvas.removeEventListener('pointerdown', handlePointerDown); + window.cancelAnimationFrame(animationFrameId); + + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + imageMaterial.dispose(); + + if (resources.imageTexture) { + resources.imageTexture.dispose(); + } + + fullScreenGeometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); + renderer.dispose(); + resourcesReference.current = null; + + if (snapshotRef) { + snapshotRef.current = null; + } + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [onFirstInteraction]); + + return ( + + ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx new file mode 100644 index 0000000000..b62035001f --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx @@ -0,0 +1,1142 @@ +'use client'; + +import { + HalftoneCanvas, + type HalftoneSnapshotFn, +} from '@/app/halftone/_components/HalftoneCanvas'; +import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel'; +import { + createFallbackGeometry, + disposeGeometryCache, + getGeometryForSpec, +} from '@/app/halftone/_lib/geometry-registry'; +import { + deriveExportComponentName, + generateReactComponent, + generateStandaloneHtml, + getExportedModelFile, + parseExportedPreset, +} from '@/app/halftone/_lib/exporters'; +import { + DEFAULT_IMAGE_HALFTONE_SETTINGS, + DEFAULT_SHAPE_HALFTONE_SETTINGS, + createInitialHalftoneStudioState, + halftoneStudioReducer, + normalizeHalftoneStudioSettings, +} from '@/app/halftone/_lib/state'; +import { Logo as LogoIcon } from '@/icons'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import Link from 'next/link'; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} 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; + +const StudioShell = styled.div<{ $background: string }>` + background: ${(props) => props.$background}; + height: 100vh; + overflow: hidden; + position: relative; + width: 100%; +`; + +const CanvasLayer = styled.div` + height: 100%; + width: 100%; +`; + +const LogoLink = styled(Link)` + display: grid; + left: 24px; + position: absolute; + top: 24px; + z-index: 2; +`; + +const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>` + color: ${(props) => + props.$tone === 'dark' + ? 'rgba(255, 255, 255, 0.34)' + : 'rgba(0, 0, 0, 0.3)'}; + font-family: ${theme.font.family.mono}; + font-size: 11px; + left: 50%; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + pointer-events: none; + position: fixed; + top: 20px; + transform: translateX(-50%); + transition: opacity 0.3s ease; + z-index: 1; +`; + +const Status = styled.div<{ + $error: boolean; + $tone: 'dark' | 'light'; + $visible: boolean; +}>` + color: ${(props) => { + if (props.$error) { + return 'rgba(214, 66, 66, 0.92)'; + } + + return props.$tone === 'dark' + ? 'rgba(255, 255, 255, 0.62)' + : 'rgba(0, 0, 0, 0.55)'; + }}; + font-family: ${theme.font.family.mono}; + font-size: 11px; + left: 50%; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + pointer-events: none; + position: fixed; + top: 44px; + transform: translateX(-50%); + transition: opacity 0.2s ease; + z-index: 1; +`; + +const ControlsPositioner = styled.div` + bottom: 20px; + height: calc(100vh - 40px); + position: fixed; + right: 20px; + top: 20px; + z-index: 3; + + @media (max-width: ${theme.breakpoints.md - 1}px) { + bottom: 16px; + height: 48vh; + left: 16px; + right: 16px; + top: 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; +}; + +type PendingPresetPicker = { + resolve: (files: File[]) => void; +}; + +function isLightColor(hex: string) { + const color = hex.replace('#', ''); + const r = parseInt(color.substring(0, 2), 16); + const g = parseInt(color.substring(2, 4), 16); + const b = parseInt(color.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5; +} + +function downloadBlob(filename: string, blob: Blob) { + const anchor = document.createElement('a'); + anchor.href = URL.createObjectURL(blob); + anchor.download = filename; + anchor.click(); + + window.setTimeout(() => { + URL.revokeObjectURL(anchor.href); + }, 0); +} + +function downloadText(filename: string, content: string) { + downloadBlob(filename, new Blob([content], { type: 'text/plain' })); +} + +function createInitialExportPose(): HalftoneExportPose { + return { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 0, + }; +} + +function normalizePresetAssetReference(reference: string | null) { + if (!reference) { + return null; + } + + const trimmed = reference.trim(); + + if (trimmed.startsWith('./data:')) { + return trimmed.slice(2); + } + + if (trimmed.startsWith('data:')) { + return trimmed; + } + + const withoutRelativePrefix = trimmed.replace(/^\.?\//, ''); + const pathSegments = withoutRelativePrefix.split('/').filter(Boolean); + + return pathSegments[pathSegments.length - 1] ?? withoutRelativePrefix; +} + +function findMatchingPresetAsset(files: File[], reference: string | null) { + const normalizedReference = normalizePresetAssetReference(reference); + + if (!normalizedReference || normalizedReference.startsWith('data:')) { + return null; + } + + const targetName = normalizedReference.toLowerCase(); + + return files.find((file) => file.name.toLowerCase() === targetName) ?? null; +} + +function inferPresetModelLoader( + reference: string | null, + fallbackLoader: HalftoneModelLoader | null, +) { + if (fallbackLoader) { + return fallbackLoader; + } + + const normalizedReference = normalizePresetAssetReference(reference) ?? ''; + + return normalizedReference.toLowerCase().endsWith('.fbx') ? 'fbx' : 'glb'; +} + +async function fileFromDataUrl(dataUrl: string, filename: string) { + const response = await fetch(dataUrl); + + if (!response.ok) { + throw new Error(`Could not load embedded asset ${filename}.`); + } + + const blob = await response.blob(); + + return new File([blob], filename, { + type: blob.type || 'application/octet-stream', + }); +} + +export function HalftoneStudio() { + const [state, dispatch] = useReducer( + halftoneStudioReducer, + undefined, + createInitialHalftoneStudioState, + ); + const [previewDistance, setPreviewDistance] = useState(7); + const [activeGeometry, setActiveGeometry] = useState( + () => createFallbackGeometry(), + ); + const [exportName, setExportName] = useState(''); + const [imageFile, setImageFile] = useState(null); + const [imageElement, setImageElement] = useState( + null, + ); + const [canvasInitialPose, setCanvasInitialPose] = + useState(); + const defaultImageFileReference = useRef(null); + const defaultImageFilePromiseReference = useRef | null>(null); + const snapshotReference = useRef(null); + const fileInputReference = useRef(null); + const presetFileInputReference = useRef(null); + const pendingFilePickerReference = useRef(null); + const pendingPresetPickerReference = useRef(null); + const geometryCacheReference = useRef>( + new Map([['torusKnot', activeGeometry]]), + ); + const lastSuccessfulShapeReference = useRef(state.settings.shapeKey); + const exportPoseReference = useRef( + createInitialExportPose(), + ); + const halftoneBySourceModeReference = useRef({ + shape: { ...DEFAULT_SHAPE_HALFTONE_SETTINGS }, + image: { ...DEFAULT_IMAGE_HALFTONE_SETTINGS }, + }); + + const selectedShape = useMemo( + () => + state.geometrySpecs.find( + (shape) => shape.key === state.settings.shapeKey, + ), + [state.geometrySpecs, state.settings.shapeKey], + ); + const shapeOptions = useMemo( + () => + state.geometrySpecs.map((shape) => ({ + label: shape.label, + value: shape.key, + })), + [state.geometrySpecs], + ); + const selectedImportedFile = useMemo( + () => + getExportedModelFile( + selectedShape, + selectedShape ? state.importedFiles[selectedShape.key] : undefined, + ), + [selectedShape, state.importedFiles], + ); + const defaultExportName = useMemo( + () => + deriveExportComponentName( + selectedShape, + selectedImportedFile ?? undefined, + ), + [selectedImportedFile, selectedShape], + ); + + useEffect(() => { + halftoneBySourceModeReference.current[state.settings.sourceMode] = { + ...state.settings.halftone, + }; + }, [state.settings.halftone, state.settings.sourceMode]); + + const openFilePicker = useCallback((accept: string) => { + return new Promise((resolve) => { + const input = fileInputReference.current; + + if (!input) { + resolve(null); + return; + } + + pendingFilePickerReference.current = { resolve }; + input.accept = accept; + + const handleWindowFocus = () => { + window.setTimeout(() => { + const pendingPicker = pendingFilePickerReference.current; + const currentInput = fileInputReference.current; + + if (!pendingPicker) { + return; + } + + if (currentInput?.files?.length) { + return; + } + + pendingFilePickerReference.current = null; + pendingPicker.resolve(null); + }, 300); + }; + + window.addEventListener('focus', handleWindowFocus, { once: true }); + input.click(); + }); + }, []); + + const openPresetPicker = useCallback((accept: string) => { + return new Promise((resolve) => { + const input = presetFileInputReference.current; + + if (!input) { + resolve([]); + return; + } + + pendingPresetPickerReference.current = { resolve }; + input.accept = accept; + + const handleWindowFocus = () => { + window.setTimeout(() => { + const pendingPicker = pendingPresetPickerReference.current; + const currentInput = presetFileInputReference.current; + + if (!pendingPicker) { + return; + } + + if (currentInput?.files?.length) { + return; + } + + pendingPresetPickerReference.current = null; + pendingPicker.resolve([]); + }, 300); + }; + + window.addEventListener('focus', handleWindowFocus, { once: true }); + input.click(); + }); + }, []); + + const handleFileInputChange = useCallback(() => { + const input = fileInputReference.current; + const pendingPicker = pendingFilePickerReference.current; + const file = input?.files?.[0] ?? null; + + if (!pendingPicker) { + if (input) { + input.value = ''; + } + + return; + } + + pendingFilePickerReference.current = null; + pendingPicker.resolve(file); + + if (input) { + input.value = ''; + } + }, []); + + const handlePresetFileInputChange = useCallback(() => { + const input = presetFileInputReference.current; + const pendingPicker = pendingPresetPickerReference.current; + const files = input?.files ? Array.from(input.files) : []; + + if (!pendingPicker) { + if (input) { + input.value = ''; + } + + return; + } + + pendingPresetPickerReference.current = null; + pendingPicker.resolve(files); + + if (input) { + input.value = ''; + } + }, []); + + 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) => { + const shape = state.geometrySpecs.find( + (candidate) => candidate.key === key, + ); + + if (!shape) { + 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], + ); + + const handleUploadModel = useCallback(async () => { + const file = await openFilePicker('.fbx,.glb'); + + 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) { + 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]); + + const loadDefaultImageFile = useCallback(async () => { + if (defaultImageFileReference.current) { + return defaultImageFileReference.current; + } + + if (!defaultImageFilePromiseReference.current) { + defaultImageFilePromiseReference.current = fetch(DEFAULT_IMAGE_ASSET_PATH) + .then(async (response) => { + if (!response.ok) { + throw new Error('Could not load the default image.'); + } + + const blob = await response.blob(); + const file = new File([blob], DEFAULT_IMAGE_FILENAME, { + type: blob.type || 'image/jpeg', + }); + + defaultImageFileReference.current = file; + + return file; + }) + .finally(() => { + defaultImageFilePromiseReference.current = null; + }); + } + + return defaultImageFilePromiseReference.current; + }, []); + + const handleSourceModeChange = useCallback( + (mode: HalftoneSourceMode) => { + if (mode !== state.settings.sourceMode) { + halftoneBySourceModeReference.current[state.settings.sourceMode] = { + ...state.settings.halftone, + }; + dispatch({ type: 'setSourceMode', value: mode }); + dispatch({ + type: 'patchHalftone', + value: { ...halftoneBySourceModeReference.current[mode] }, + }); + } + + if (mode !== 'image' || imageFile) { + return; + } + + void loadDefaultImageFile() + .then((file) => { + setImageFile((currentFile) => currentFile ?? file); + }) + .catch((error) => { + console.error(error); + }); + }, + [ + imageFile, + loadDefaultImageFile, + state.settings.halftone, + state.settings.sourceMode, + ], + ); + + const handleImportPreset = useCallback(async () => { + const selectedFiles = await openPresetPicker( + '.tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif', + ); + const presetFile = + selectedFiles.find((file) => /\.(tsx|html)$/i.test(file.name)) ?? null; + + if (!presetFile) { + return; + } + + try { + const preset = parseExportedPreset(await presetFile.text()); + const relatedFiles = selectedFiles.filter((file) => file !== presetFile); + const nextSettings = normalizeHalftoneStudioSettings(preset.settings); + let nextShapeKey = nextSettings.shapeKey; + const statusMessages: string[] = []; + + if (nextSettings.sourceMode === 'image') { + const matchedImageFile = findMatchingPresetAsset( + relatedFiles, + preset.imageAssetReference, + ); + const nextImageFile = + matchedImageFile ?? + (preset.imageAssetReference + ? await loadDefaultImageFile() + : (imageFile ?? (await loadDefaultImageFile()))); + + setImageFile(nextImageFile); + + if (!matchedImageFile && preset.imageAssetReference) { + const missingImageName = + normalizePresetAssetReference(preset.imageAssetReference) ?? + 'the exported image'; + statusMessages.push( + `${missingImageName} was not selected, so the default image is being used until you upload it.`, + ); + } + } else if (preset.shape.kind === 'imported') { + const embeddedModelReference = normalizePresetAssetReference( + preset.modelAssetReference, + ); + const matchedModelFile = embeddedModelReference?.startsWith('data:') + ? await fileFromDataUrl( + embeddedModelReference, + preset.shape.filename ?? `${preset.shape.label}.glb`, + ) + : findMatchingPresetAsset(relatedFiles, preset.modelAssetReference); + + if (matchedModelFile) { + const loader = inferPresetModelLoader( + matchedModelFile.name, + preset.shape.loader ?? null, + ); + const importedShapeSpec: HalftoneGeometrySpec = { + key: preset.shape.key, + label: matchedModelFile.name, + kind: 'imported', + loader, + filename: matchedModelFile.name, + description: `${loader.toUpperCase()} model`, + extensions: [`.${loader}`], + userProvided: true, + }; + + dispatch({ + type: 'registerImportedFile', + spec: importedShapeSpec, + file: matchedModelFile, + activate: false, + }); + nextShapeKey = importedShapeSpec.key; + } else { + nextShapeKey = 'torusKnot'; + + if (preset.shape.filename || preset.modelAssetReference) { + const missingModelName = + normalizePresetAssetReference( + preset.modelAssetReference ?? preset.shape.filename, + ) ?? preset.shape.label; + statusMessages.push( + `${missingModelName} was not selected, so Torus Knot is being shown until you upload the preset's model.`, + ); + } + } + } + + exportPoseReference.current = preset.initialPose; + setCanvasInitialPose(preset.initialPose); + setPreviewDistance(EXPORTED_PRESET_PREVIEW_DISTANCE); + setExportName( + preset.componentName ?? presetFile.name.replace(/\.(tsx|html)$/i, ''), + ); + dispatch({ + type: 'replaceSettings', + value: { + ...nextSettings, + shapeKey: nextShapeKey, + }, + }); + dispatch({ + type: 'setStatus', + message: + statusMessages.length > 0 + ? `Imported ${presetFile.name}. ${statusMessages.join(' ')}` + : `Imported ${presetFile.name}.`, + }); + } catch (error) { + dispatch({ + type: 'setStatus', + message: + error instanceof Error + ? error.message + : 'Could not import that preset.', + isError: true, + }); + } + }, [imageFile, loadDefaultImageFile, openPresetPicker]); + + const handlePoseChange = useCallback((pose: HalftoneExportPose) => { + exportPoseReference.current = pose; + }, []); + + const handleExportReact = useCallback(() => { + const componentName = exportName || defaultExportName; + const kebabName = componentName + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); + const isImageMode = state.settings.sourceMode === 'image'; + const importedFile = selectedImportedFile; + + const modelFilename = importedFile + ? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}` + : undefined; + + const imageExportFilename = imageFile + ? `${kebabName}${imageFile.name.replace(/^[^.]*/, '')}` + : undefined; + + downloadText( + `${componentName}.tsx`, + generateReactComponent( + state.settings, + selectedShape, + componentName, + modelFilename, + exportPoseReference.current, + importedFile ?? undefined, + imageExportFilename, + ), + ); + + if (isImageMode && imageFile) { + downloadBlob(imageExportFilename ?? imageFile.name, imageFile); + } else if (importedFile) { + downloadBlob(modelFilename ?? importedFile.name, importedFile); + } + }, [ + defaultExportName, + exportName, + imageFile, + selectedImportedFile, + selectedShape, + state.settings, + ]); + + const handleExportHalftoneImage = useCallback( + async (width: number, height: number) => { + const snapshotFn = snapshotReference.current; + + if (!snapshotFn) { + return; + } + + const blob = await snapshotFn(width, height); + + if (!blob) { + return; + } + + downloadBlob(`halftone-${width}x${height}.png`, blob); + }, + [], + ); + + const handleExportHtml = useCallback(async () => { + const componentName = exportName || defaultExportName; + const kebabName = componentName + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); + const isImageMode = state.settings.sourceMode === 'image'; + const importedFile = selectedImportedFile; + + const modelFilename = importedFile + ? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}` + : undefined; + + const imageExportFilename = imageFile + ? `${kebabName}${imageFile.name.replace(/^[^.]*/, '')}` + : undefined; + + downloadText( + `${kebabName}.html`, + await generateStandaloneHtml( + state.settings, + selectedShape, + componentName, + modelFilename, + exportPoseReference.current, + importedFile ?? undefined, + imageExportFilename, + ), + ); + + if (isImageMode && imageFile) { + downloadBlob(imageExportFilename ?? imageFile.name, imageFile); + } + }, [ + defaultExportName, + exportName, + imageFile, + selectedImportedFile, + selectedShape, + state.settings, + ]); + + useEffect(() => { + void loadDefaultImageFile() + .then((file) => { + setImageFile((currentFile) => currentFile ?? file); + }) + .catch((error) => { + console.error(error); + }); + }, [loadDefaultImageFile]); + + useEffect(() => { + if (!imageFile) { + setImageElement(null); + return; + } + + const url = URL.createObjectURL(imageFile); + const img = new Image(); + img.onload = () => setImageElement(img); + img.src = url; + + return () => URL.revokeObjectURL(url); + }, [imageFile]); + + useEffect(() => { + return () => { + disposeGeometryCache(geometryCacheReference.current); + }; + }, []); + + useEffect(() => { + if (!selectedShape) { + return; + } + + let cancelled = false; + + if (selectedShape.kind === 'imported') { + dispatch({ + type: 'setStatus', + message: `Loading ${selectedShape.label}…`, + }); + } else { + dispatch({ type: 'clearStatus' }); + } + + void getGeometryForSpec( + selectedShape, + state.importedFiles[selectedShape.key], + geometryCacheReference.current, + ) + .then((geometry) => { + if (cancelled) { + return; + } + + lastSuccessfulShapeReference.current = selectedShape.key; + setActiveGeometry(geometry); + dispatch({ type: 'clearStatus' }); + }) + .catch((error) => { + if (cancelled) { + return; + } + + console.error(error); + + dispatch({ + type: 'setStatus', + message: + error instanceof Error + ? error.message + : `${selectedShape.label} failed to load.`, + isError: true, + }); + + if (lastSuccessfulShapeReference.current !== selectedShape.key) { + dispatch({ + type: 'setShapeKey', + value: lastSuccessfulShapeReference.current, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [selectedShape, state.importedFiles]); + + const background = state.settings.background.color; + const backgroundTone = isLightColor(background) ? 'light' : 'dark'; + const handleFirstInteraction = useCallback(() => { + dispatch({ type: 'hideHint' }); + }, []); + + return ( + + + + + + + {state.settings.sourceMode === 'image' + ? 'Drag to shift the halftone pattern' + : 'Click & drag to rotate'} + + 0} + > + {state.statusMessage} + + + + + + + + + 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} + /> + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx new file mode 100644 index 0000000000..17119614b1 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx @@ -0,0 +1,824 @@ +'use client'; + +import { + formatAngle, + formatDecimal, + formatPercent, +} from '@/app/halftone/_lib/formatters'; +import { + ControlGrid, + LabelWithTooltip, + Section, + SectionTitle, + SectionToggleHeader, + SelectControl, + SliderControl, + TabContent, + ToggleControl, +} from './controls-ui'; + +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +type AnimationsTabProps = { + onAnimationSettingsChange: ( + value: Partial, + ) => void; + settings: HalftoneStudioSettings; +}; + +function effectLabel(label: string, description: string) { + return ; +} + +export function AnimationsTab({ + onAnimationSettingsChange, + settings, +}: AnimationsTabProps) { + const animation = settings.animation; + const isImageMode = settings.sourceMode === 'image'; + + return ( + + {isImageMode ? ( + <> +
+ + onAnimationSettingsChange({ + hoverLightEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Hover Light', + 'Turns the cursor into a moving light source so the halftone bars open up, tighten, and re-balance as you hover.', + )} + + {animation.hoverLightEnabled ? ( + + + onAnimationSettingsChange({ + hoverLightIntensity: Number(event.target.value), + }) + } + step={0.05} + value={animation.hoverLightIntensity} + valueLabel={formatDecimal(animation.hoverLightIntensity, 2)} + > + Intensity + + + onAnimationSettingsChange({ + hoverLightRadius: Number(event.target.value), + }) + } + step={0.01} + value={animation.hoverLightRadius} + valueLabel={formatDecimal(animation.hoverLightRadius, 2)} + > + Radius + + + ) : null} +
+ +
+ + 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.', + )} + + {animation.dragFlowEnabled ? ( + + + onAnimationSettingsChange({ + dragFlowStrength: Number(event.target.value), + }) + } + step={0.1} + value={animation.dragFlowStrength} + valueLabel={formatDecimal(animation.dragFlowStrength, 1)} + > + Strength + + + onAnimationSettingsChange({ + dragFlowRadius: Number(event.target.value), + }) + } + step={0.01} + value={animation.dragFlowRadius} + valueLabel={formatDecimal(animation.dragFlowRadius, 2)} + > + Radius + + + onAnimationSettingsChange({ + dragFlowDecay: Number(event.target.value), + }) + } + step={0.01} + value={animation.dragFlowDecay} + valueLabel={formatDecimal(animation.dragFlowDecay, 2)} + > + Decay + + + ) : null} +
+ + ) : ( + <> +
+ Rotation + + + onAnimationSettingsChange({ + autoRotateEnabled: event.target.checked, + }) + } + /> + {animation.autoRotateEnabled ? ( + <> + + onAnimationSettingsChange({ + autoSpeed: Number(event.target.value), + }) + } + step={0.05} + value={animation.autoSpeed} + valueLabel={formatDecimal(animation.autoSpeed, 1)} + > + Speed + + + onAnimationSettingsChange({ + autoWobble: Number(event.target.value), + }) + } + step={0.05} + value={animation.autoWobble} + valueLabel={formatDecimal(animation.autoWobble, 1)} + > + Wobble + + + ) : null} + + + onAnimationSettingsChange({ + rotateEnabled: event.target.checked, + }) + } + /> + {animation.rotateEnabled ? ( + <> + + onAnimationSettingsChange({ + rotatePreset: event.target + .value as HalftoneStudioSettings['animation']['rotatePreset'], + }) + } + options={[ + { label: 'Axis rotate', value: 'axis' }, + { label: 'Lissajous', value: 'lissajous' }, + { label: 'Orbit', value: 'orbit' }, + { label: 'Tumble', value: 'tumble' }, + ]} + value={animation.rotatePreset} + > + Pattern + + {animation.rotatePreset === 'axis' ? ( + + onAnimationSettingsChange({ + rotateAxis: event.target + .value as HalftoneStudioSettings['animation']['rotateAxis'], + }) + } + options={[ + { label: 'Y (horizontal)', value: 'y' }, + { label: '-Y (horizontal)', value: '-y' }, + { label: 'X (vertical)', value: 'x' }, + { label: '-X (vertical)', value: '-x' }, + { label: 'Z (roll)', value: 'z' }, + { label: '-Z (roll)', value: '-z' }, + { label: 'X + Y (diagonal)', value: 'xy' }, + { label: '-X + -Y (diagonal)', value: '-xy' }, + ]} + value={animation.rotateAxis} + > + Axis + + ) : null} + + onAnimationSettingsChange({ + rotateSpeed: Number(event.target.value), + }) + } + step={0.1} + value={animation.rotateSpeed} + valueLabel={formatDecimal(animation.rotateSpeed, 1)} + > + Speed + + + onAnimationSettingsChange({ + rotatePingPong: event.target.checked, + }) + } + /> + + ) : null} + +
+ +
+ + onAnimationSettingsChange({ + floatEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Float + Drift', + 'Adds a gentle vertical bob plus a small rotational drift so the object feels suspended instead of locked in place.', + )} + + {animation.floatEnabled ? ( + + + onAnimationSettingsChange({ + floatSpeed: Number(event.target.value), + }) + } + step={0.05} + value={animation.floatSpeed} + valueLabel={formatDecimal(animation.floatSpeed, 2)} + > + Speed + + + onAnimationSettingsChange({ + floatAmplitude: Number(event.target.value), + }) + } + step={0.01} + value={animation.floatAmplitude} + valueLabel={formatDecimal(animation.floatAmplitude, 2)} + > + Height + + + onAnimationSettingsChange({ + driftAmount: Number(event.target.value), + }) + } + step={1} + value={animation.driftAmount} + valueLabel={formatAngle(animation.driftAmount)} + > + Drift + + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + breatheEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Breathing Scale', + 'Pulses the model scale in and out by a small amount to make static shapes feel alive.', + )} + + {animation.breatheEnabled ? ( + + + onAnimationSettingsChange({ + breatheSpeed: Number(event.target.value), + }) + } + step={0.05} + value={animation.breatheSpeed} + valueLabel={formatDecimal(animation.breatheSpeed, 2)} + > + Speed + + + onAnimationSettingsChange({ + breatheAmount: Number(event.target.value), + }) + } + step={0.005} + value={animation.breatheAmount} + valueLabel={formatPercent(animation.breatheAmount)} + > + Amount + + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + lightSweepEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Light Sweep', + 'Animates the primary light angle and height so highlights travel across the halftone surface.', + )} + + {animation.lightSweepEnabled ? ( + + + onAnimationSettingsChange({ + lightSweepSpeed: Number(event.target.value), + }) + } + step={0.05} + value={animation.lightSweepSpeed} + valueLabel={formatDecimal(animation.lightSweepSpeed, 2)} + > + Speed + + + onAnimationSettingsChange({ + lightSweepRange: Number(event.target.value), + }) + } + step={1} + value={animation.lightSweepRange} + valueLabel={formatAngle(animation.lightSweepRange)} + > + Angle + + + onAnimationSettingsChange({ + lightSweepHeightRange: Number(event.target.value), + }) + } + step={0.05} + value={animation.lightSweepHeightRange} + valueLabel={formatDecimal(animation.lightSweepHeightRange, 2)} + > + Height + + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + cameraParallaxEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Camera Parallax', + 'Moves the camera viewpoint with the cursor to create depth without directly rotating the object.', + )} + + {animation.cameraParallaxEnabled ? ( + + + onAnimationSettingsChange({ + cameraParallaxAmount: Number(event.target.value), + }) + } + step={0.01} + value={animation.cameraParallaxAmount} + valueLabel={formatDecimal(animation.cameraParallaxAmount, 2)} + > + Amount + + + onAnimationSettingsChange({ + cameraParallaxEase: Number(event.target.value), + }) + } + step={0.01} + value={animation.cameraParallaxEase} + valueLabel={formatDecimal(animation.cameraParallaxEase, 2)} + > + Easing + + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + springReturnEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Spring Return', + 'Changes the settling behavior so hover and drag interactions rebound and ease back like a spring instead of stopping flat.', + )} + + {animation.springReturnEnabled ? ( + + + onAnimationSettingsChange({ + springStrength: Number(event.target.value), + }) + } + step={0.01} + value={animation.springStrength} + valueLabel={formatDecimal(animation.springStrength, 2)} + > + Strength + + + onAnimationSettingsChange({ + springDamping: Number(event.target.value), + }) + } + step={0.02} + value={animation.springDamping} + valueLabel={formatDecimal(animation.springDamping, 2)} + > + Damping + + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + followHoverEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Follow Mouse (Hover)', + 'Tilts the object toward the pointer while the cursor is over the canvas.', + )} + + {animation.followHoverEnabled ? ( + + + onAnimationSettingsChange({ + hoverRange: Number(event.target.value), + }) + } + value={animation.hoverRange} + valueLabel={formatAngle(animation.hoverRange)} + > + Range + + + onAnimationSettingsChange({ + hoverEase: Number(event.target.value), + }) + } + step={0.01} + value={animation.hoverEase} + valueLabel={formatDecimal(animation.hoverEase, 2)} + > + Easing + + + onAnimationSettingsChange({ + hoverReturn: event.target.checked, + }) + } + /> + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + followDragEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Follow Mouse (Drag)', + 'Lets you rotate the object directly by dragging, optionally continuing with momentum after release.', + )} + + {animation.followDragEnabled ? ( + + + onAnimationSettingsChange({ + dragSens: Number(event.target.value), + }) + } + step={0.001} + value={animation.dragSens} + valueLabel={formatDecimal(animation.dragSens, 3)} + > + Sensitivity + + + onAnimationSettingsChange({ + dragFriction: Number(event.target.value), + }) + } + step={0.01} + value={animation.dragFriction} + valueLabel={formatDecimal(animation.dragFriction, 2)} + > + Friction + + + onAnimationSettingsChange({ + dragMomentum: event.target.checked, + }) + } + /> + + ) : null} +
+ +
+ + onAnimationSettingsChange({ + waveEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Wave', + 'Makes the halftone rows undulate horizontally for a liquid, organic feel.', + )} + + {animation.waveEnabled ? ( + + + onAnimationSettingsChange({ + waveSpeed: Number(event.target.value), + }) + } + step={0.1} + value={animation.waveSpeed} + valueLabel={formatDecimal(animation.waveSpeed, 1)} + > + Speed + + + onAnimationSettingsChange({ + waveAmount: Number(event.target.value), + }) + } + step={0.1} + value={animation.waveAmount} + valueLabel={formatDecimal(animation.waveAmount, 1)} + > + Amount + + + ) : null} +
+ + )} +
+ ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx new file mode 100644 index 0000000000..fc6d523fac --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx @@ -0,0 +1,533 @@ +'use client'; + +import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters'; +import { + ColorControlLabel, + ColorControlRow, + ColorField, + ControlGrid, + Section, + SectionTitle, + SectionToggleHeader, + SelectControl, + SelectInput, + ShapeRow, + SliderControl, + TabContent, + UploadButton, + ValueDisplay, +} from './controls-ui'; + +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +type DesignTabProps = { + imageFileName: string | null; + onBackgroundChange: (value: Partial) => void; + onDashColorChange: (value: string) => void; + onHalftoneChange: ( + value: Partial, + ) => void; + onLightingChange: ( + value: Partial, + ) => void; + onMaterialChange: ( + value: Partial, + ) => void; + onPreviewDistanceChange: (value: number) => void; + onShapeChange: (value: string) => void; + onSourceModeChange: (value: HalftoneSourceMode) => void; + onUploadImage: () => void; + onUploadModel: () => void; + previewDistance: number; + settings: HalftoneStudioSettings; + shapeOptions: Array<{ label: string; value: string }>; +}; + +export function DesignTab({ + imageFileName, + onBackgroundChange, + onDashColorChange, + onHalftoneChange, + onLightingChange, + onMaterialChange, + onPreviewDistanceChange, + onShapeChange, + onSourceModeChange, + onUploadImage, + onUploadModel, + previewDistance, + settings, + shapeOptions, +}: DesignTabProps) { + const isImageMode = settings.sourceMode === 'image'; + + return ( + +
+ Source + + + onSourceModeChange(event.target.value as HalftoneSourceMode) + } + options={[ + { label: '3D Shape', value: 'shape' }, + { label: 'Image', value: 'image' }, + ]} + value={settings.sourceMode} + > + Mode + + + {isImageMode ? ( + + Image + + {imageFileName ?? 'twenty-logo.svg'} + + + + + + + + + + ) : ( + + Shape + onShapeChange(event.target.value)} + value={settings.shapeKey} + > + {shapeOptions.map((option) => ( + + ))} + + + + + + + + + + )} + +
+ + {isImageMode ? ( + <> +
+ Visualization + + + onPreviewDistanceChange(Number(event.target.value)) + } + step={0.1} + value={previewDistance} + valueLabel={formatDecimal(previewDistance, 1)} + > + Distance + + +
+ + ) : ( + <> +
+ Visualization + + + onPreviewDistanceChange(Number(event.target.value)) + } + step={0.1} + value={previewDistance} + valueLabel={formatDecimal(previewDistance, 1)} + > + Distance + + +
+ +
+ Lighting + + + onLightingChange({ intensity: Number(event.target.value) }) + } + step={0.1} + value={settings.lighting.intensity} + valueLabel={formatDecimal(settings.lighting.intensity, 1)} + > + Light + + + onLightingChange({ + fillIntensity: Number(event.target.value), + }) + } + step={0.01} + value={settings.lighting.fillIntensity} + valueLabel={formatDecimal(settings.lighting.fillIntensity)} + > + Fill + + + onLightingChange({ + ambientIntensity: Number(event.target.value), + }) + } + step={0.01} + value={settings.lighting.ambientIntensity} + valueLabel={formatDecimal(settings.lighting.ambientIntensity)} + > + Ambient + + + onLightingChange({ angleDegrees: Number(event.target.value) }) + } + value={settings.lighting.angleDegrees} + valueLabel={formatAngle(settings.lighting.angleDegrees)} + > + Angle + + + onLightingChange({ height: Number(event.target.value) }) + } + step={0.1} + value={settings.lighting.height} + valueLabel={formatDecimal(settings.lighting.height, 1)} + > + Height + + +
+ +
+ Material + + + onMaterialChange({ roughness: Number(event.target.value) }) + } + step={0.01} + value={settings.material.roughness} + valueLabel={formatDecimal(settings.material.roughness)} + > + Roughness + + + onMaterialChange({ metalness: Number(event.target.value) }) + } + step={0.01} + value={settings.material.metalness} + valueLabel={formatDecimal(settings.material.metalness)} + > + Metalness + + +
+ + )} + +
+ + onHalftoneChange({ enabled: event.target.checked }) + } + > + Halftone + + {settings.halftone.enabled ? ( + + + onHalftoneChange({ numRows: Number(event.target.value) }) + } + value={settings.halftone.numRows} + valueLabel={String(settings.halftone.numRows)} + > + Rows + + + onHalftoneChange({ contrast: Number(event.target.value) }) + } + step={0.1} + value={settings.halftone.contrast} + valueLabel={formatDecimal(settings.halftone.contrast, 1)} + > + Contrast + + + onHalftoneChange({ power: Number(event.target.value) }) + } + step={0.1} + value={settings.halftone.power} + valueLabel={formatDecimal(settings.halftone.power, 1)} + > + Power + + + onHalftoneChange({ shading: Number(event.target.value) }) + } + step={0.1} + value={settings.halftone.shading} + valueLabel={formatDecimal(settings.halftone.shading, 1)} + > + Shading + + + onHalftoneChange({ baseInk: Number(event.target.value) }) + } + step={0.01} + value={settings.halftone.baseInk} + valueLabel={formatDecimal(settings.halftone.baseInk)} + > + Base Density + + + onHalftoneChange({ highlightOpen: Number(event.target.value) }) + } + step={0.01} + value={settings.halftone.highlightOpen} + valueLabel={formatDecimal(settings.halftone.highlightOpen)} + > + Highlight Open + + + onHalftoneChange({ + shadowGrouping: Number(event.target.value), + }) + } + step={0.01} + value={settings.halftone.shadowGrouping} + valueLabel={formatDecimal(settings.halftone.shadowGrouping)} + > + Shadow Group + + + onHalftoneChange({ maxBar: Number(event.target.value) }) + } + step={0.01} + value={settings.halftone.maxBar} + valueLabel={formatDecimal(settings.halftone.maxBar)} + > + Thickness + + + onHalftoneChange({ rowMerge: Number(event.target.value) }) + } + step={0.01} + value={settings.halftone.rowMerge} + valueLabel={formatDecimal(settings.halftone.rowMerge)} + > + Row Merge + + + ) : null} +
+ +
+ Colors + + + Dash color + + + + Background + onBackgroundChange({ color: value })} + value={settings.background.color} + /> + + +
+
+ ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx new file mode 100644 index 0000000000..7085a2b6bf --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { formatAnimationName } from '@/app/halftone/_lib/formatters'; +import { useState } from 'react'; +import { + ExportButton, + ExportNameInput, + ExportNote, + ExportPreview, + Section, + SectionTitle, + SelectControl, + SmallBody, + TabContent, +} from './controls-ui'; + +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; +type HalftoneModelLoader = 'fbx' | 'glb'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +interface HalftoneGeometrySpec { + key: string; + label: string; + kind: 'builtin' | 'imported'; + loader?: HalftoneModelLoader; + filename?: string; + description?: string; + extensions?: readonly string[]; + userProvided?: boolean; +} + +const RESOLUTION_OPTIONS = [ + { label: '720p (1280 × 720)', value: '1280x720' }, + { label: '1080p (1920 × 1080)', value: '1920x1080' }, + { label: '2K (2560 × 1440)', value: '2560x1440' }, + { label: '4K (3840 × 2160)', value: '3840x2160' }, +]; + +type ExportTabProps = { + defaultExportName: string; + exportName: string; + onExportHalftoneImage: (width: number, height: number) => void; + onExportHtml: () => void; + onExportNameChange: (value: string) => void; + onExportReact: () => void; + onImportPreset: () => void; + selectedShape: HalftoneGeometrySpec | undefined; + settings: HalftoneStudioSettings; +}; + +export function ExportTab({ + defaultExportName, + exportName, + onExportHalftoneImage, + onExportHtml, + onExportNameChange, + onExportReact, + onImportPreset, + selectedShape, + settings, +}: ExportTabProps) { + const [resolution, setResolution] = useState('1920x1080'); + const isImageMode = settings.sourceMode === 'image'; + const animationLabel = formatAnimationName( + settings.animation, + settings.sourceMode, + ); + + const componentName = exportName || defaultExportName; + + const handleDownloadHalftoneImage = () => { + const [widthStr, heightStr] = resolution.split('x'); + const width = parseInt(widthStr, 10); + const height = parseInt(heightStr, 10); + onExportHalftoneImage(width, height); + }; + + return ( + +
+ Download Image + + Downloads a PNG snapshot of the current halftone effect at the selected + resolution. + + + setResolution(event.target.value)} + options={RESOLUTION_OPTIONS} + value={resolution} + > + Resolution + + + + Download Halftone Image + +
+ +
+ Export React Component + + Downloads a self-contained React component with the current design and + animation settings baked in. Requires three. + + + onExportNameChange(event.target.value)} + placeholder={defaultExportName} + type="text" + value={exportName} + /> + + +
+ {`// ${componentName}.tsx`} +
+
+ + {'// Auto-generated from current settings'} + +
+
+
+ import{' '} + {componentName}{' '} + from{' '} + {`'./${componentName}'`} +
+
+
+ {`// Shape: ${selectedShape?.key ?? settings.shapeKey}`} +
+
+ {`// Animation: ${animationLabel}`} +
+
+ {`// Dash color: ${settings.halftone.dashColor}`} +
+
+ {`// Rows: ${settings.halftone.numRows}`} +
+
+ + + Download React Component + + + Download Standalone HTML + + + + {isImageMode + ? 'The original image is downloaded alongside the export so the halftone effect can be rendered on top of it.' + : selectedShape?.kind === 'imported' + ? 'The standalone HTML export includes the current lighting, material, halftone, and animation settings, and embeds the uploaded model directly in the HTML file.' + : 'The standalone HTML export includes the current lighting, material, halftone, and animation settings.'} + +
+ +
+ 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. + + + + Import Exported Preset + +
+
+ ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx new file mode 100644 index 0000000000..af3842f145 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/controls-ui.tsx @@ -0,0 +1,1001 @@ +'use client'; + +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { + useEffect, + useId, + useRef, + useState, + type ChangeEventHandler, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +const TAB_LABEL_WIDTH = 72; + +export const PanelShell = styled.aside` + background: rgba(18, 18, 22, 0.88); + backdrop-filter: blur(24px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.04) inset; + color: rgba(255, 255, 255, 0.7); + display: flex; + flex-direction: column; + font-family: ${theme.font.family.sans}; + font-size: 11px; + height: 100%; + overflow: hidden; + width: min(320px, calc(100vw - 32px)); + + @media (max-width: ${theme.breakpoints.md - 1}px) { + height: 100%; + width: 100%; + } +`; + +export const TabsBar = styled.div` + display: flex; + flex-shrink: 0; + gap: 6px; + margin: 0; + padding: 12px 12px 0; +`; + +export const TabButton = styled.button<{ $active: boolean }>` + background: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.08)' : 'transparent'}; + border: none; + border-radius: 7px; + color: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.52)'}; + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 12px; + font-weight: ${(props) => (props.$active ? 600 : 500)}; + letter-spacing: 0; + line-height: 1; + padding: 8px 10px; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: ${(props) => + props.$active + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(255, 255, 255, 0.04)'}; + color: ${(props) => + props.$active + ? 'rgba(255, 255, 255, 0.94)' + : 'rgba(255, 255, 255, 0.74)'}; + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; + +export const TabContent = styled.div` + flex: 1; + overflow-y: auto; + padding: 16px; +`; + +export const Section = styled.section<{ $first?: boolean }>` + border-top: ${(props) => + props.$first ? 'none' : '1px solid rgba(255, 255, 255, 0.06)'}; + margin-top: ${(props) => (props.$first ? '0' : '14px')}; + padding-top: ${(props) => (props.$first ? '0' : '14px')}; +`; + +export const SectionTitle = styled.div<{ $preserveCase?: boolean }>` + color: rgba(255, 255, 255, 0.36); + font-size: 10px; + font-weight: 600; + letter-spacing: ${(props) => (props.$preserveCase ? '0.02em' : '0.08em')}; + margin-bottom: 10px; + text-transform: ${(props) => (props.$preserveCase ? 'none' : 'uppercase')}; +`; + +export const SectionHeader = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 10px; + + & > ${SectionTitle} { + margin-bottom: 0; + } +`; + +export const ControlGrid = styled.div` + display: grid; + gap: 10px; +`; + +export const SliderLabel = styled.label` + align-items: center; + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; +`; + +export const SelectLabel = styled.label` + align-items: center; + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); +`; + +export const ControlValue = styled.span` + color: rgba(255, 255, 255, 0.5); + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + text-align: right; +`; + +const EditableControlValueButton = styled.button` + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: text; + font-family: inherit; + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + padding: 0; + text-align: right; + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.25); + outline-offset: 2px; + } +`; + +const EditableControlValueInput = styled.input` + appearance: textfield; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.62); + font-family: inherit; + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 34px; + outline: none; + padding: 0; + text-align: right; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; + margin: 0; + } +`; + +export const SliderInput = styled.input` + appearance: none; + background: transparent; + cursor: pointer; + height: 16px; + outline: none; + transition: transform 0.2s ease; + width: 100%; + -webkit-tap-highlight-color: transparent; + + &::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + rgba(255, 255, 255, 0.35) var(--fill, 50%), + rgba(255, 255, 255, 0.08) var(--fill, 50%) + ); + border-radius: 999px; + height: 6px; + } + + &::-webkit-slider-thumb { + appearance: none; + background: transparent; + border: none; + border-radius: 999px; + height: 16px; + margin-top: -5px; + width: 16px; + } + + &::-moz-range-progress { + background: rgba(255, 255, 255, 0.35); + border-radius: 999px; + height: 6px; + } + + &::-moz-range-thumb { + background: transparent; + border: none; + border-radius: 999px; + height: 16px; + width: 16px; + } + + &::-moz-range-track { + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + height: 6px; + } + + &:active { + transform: scaleY(2); + } +`; + +export const SelectInput = styled.select` + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: rgba(255, 255, 255, 0.07); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.52)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-position: right 12px center; + background-repeat: no-repeat; + background-size: 12px 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 11px; + outline: none; + padding: 7px 34px 7px 10px; + transition: border-color 0.15s ease; + width: 100%; + + &:hover { + border-color: rgba(255, 255, 255, 0.25); + } +`; + +export const ValueDisplay = styled.div` + align-items: center; + background-color: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + display: flex; + font-family: ${theme.font.family.sans}; + font-size: 11px; + min-height: 31px; + overflow: hidden; + padding: 7px 10px; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +`; + +export const ToggleRow = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + padding: 2px 0; +`; + +export const ToggleText = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 11px; +`; + +const LabelWithInfoRow = styled.span` + align-items: center; + display: inline-flex; + gap: 6px; + min-width: 0; +`; + +const InfoTooltipRoot = styled.span` + display: inline-flex; + position: relative; +`; + +const InfoTooltipButton = styled.button` + align-items: center; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.32); + cursor: help; + display: inline-flex; + height: 14px; + justify-content: center; + margin: 0; + padding: 0; + transition: color 0.15s ease; + width: 14px; + + &:hover, + &:focus-visible { + color: rgba(255, 255, 255, 0.7); + outline: none; + } + + &:hover + span, + &:focus-visible + span { + opacity: 1; + transform: translate(-50%, 0); + } +`; + +const InfoTooltipBubble = styled.span` + background: rgba(9, 9, 13, 0.96); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); + color: rgba(255, 255, 255, 0.78); + font-size: 10px; + line-height: 1.45; + max-width: 220px; + padding: 8px 10px; + pointer-events: none; + position: fixed; + width: max-content; + z-index: 4; + + &::before { + background: rgba(9, 9, 13, 0.96); + border-left: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.08); + content: ''; + height: 8px; + left: 50%; + position: absolute; + top: -5px; + transform: translateX(-50%) rotate(45deg); + width: 8px; + } +`; + +export const Toggle = styled.label` + display: block; + flex-shrink: 0; + height: 18px; + position: relative; + width: 28px; +`; + +export const ToggleInput = styled.input` + height: 0; + opacity: 0; + position: absolute; + width: 0; + + &:checked + span { + background: #4a38f5; + } + + &:checked + span::after { + transform: translateX(12px); + } +`; + +export const ToggleTrack = styled.span` + background: rgba(255, 255, 255, 0.14); + border-radius: 999px; + cursor: pointer; + inset: 0; + position: absolute; + transition: background 0.2s ease; + + &::after { + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + content: ''; + height: 12px; + left: 2px; + position: absolute; + top: 3px; + transition: transform 0.2s ease; + width: 12px; + } +`; + +export const ColorControlRow = styled.div` + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr); +`; + +export const ColorControlLabel = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 11px; +`; + +export const ColorPickerField = styled.div` + align-items: center; + cursor: text; + display: flex; + gap: 8px; + justify-content: flex-end; + justify-self: end; + min-height: 16px; + min-width: fit-content; + padding: 0; + position: relative; + width: auto; +`; + +export const ColorSwatch = styled.span` + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); + flex-shrink: 0; + height: 16px; + width: 16px; +`; + +export const ColorHexInput = styled.input` + appearance: none; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.85); + font-family: ${theme.font.family.mono}; + font-size: 11px; + height: 16px; + letter-spacing: 0.02em; + line-height: 16px; + min-width: 7ch; + outline: none; + padding: 0; + text-align: right; + text-transform: uppercase; + width: 7ch; + + &::placeholder { + color: rgba(255, 255, 255, 0.32); + } +`; + +export const ColorSwatchButton = styled.label` + align-items: center; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + height: 16px; + justify-content: center; + position: relative; +`; + +export const ColorInput = styled.input` + appearance: none; + border: none; + cursor: pointer; + height: 100%; + inset: 0; + outline: none; + opacity: 0; + padding: 0; + position: absolute; + width: 100%; +`; + +function normalizeHexColor(value: string): string | null { + const normalizedValue = value.trim().replace(/^#/, ''); + + if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue)) { + return null; + } + + return `#${normalizedValue.toUpperCase()}`; +} + +type ColorFieldProps = { + ariaLabel: string; + onChange: (value: string) => void; + value: string; +}; + +export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) { + const normalizedValue = value.toUpperCase(); + const [draftValue, setDraftValue] = useState(normalizedValue); + + useEffect(() => { + setDraftValue(normalizedValue); + }, [normalizedValue]); + + const commitDraftValue = () => { + const nextValue = normalizeHexColor(draftValue); + + if (!nextValue) { + setDraftValue(normalizedValue); + return; + } + + setDraftValue(nextValue); + + if (nextValue !== normalizedValue) { + onChange(nextValue); + } + }; + + return ( + + setDraftValue(event.target.value.toUpperCase())} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + setDraftValue(normalizedValue); + event.currentTarget.blur(); + } + }} + spellCheck={false} + type="text" + value={draftValue} + /> + + + onChange(event.target.value)} + type="color" + value={value} + /> + + + ); +} + +export const ShapeRow = styled.div` + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: ${TAB_LABEL_WIDTH}px minmax(0, 1fr) auto; +`; + +export const UploadButton = styled.button` + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + display: flex; + flex-shrink: 0; + font-size: 13px; + height: 32px; + justify-content: center; + transition: all 0.15s ease; + width: 32px; + + &:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.22); + color: rgba(255, 255, 255, 0.8); + } +`; + +export const ExportNameInput = styled.input` + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.85); + font-family: ${theme.font.family.sans}; + font-size: 11px; + margin-bottom: 14px; + outline: none; + padding: 7px 10px; + transition: border-color 0.15s ease; + width: 100%; + + &:hover { + border-color: rgba(255, 255, 255, 0.25); + } + + &:focus { + border-color: #4a38f5; + } + + &::placeholder { + color: rgba(255, 255, 255, 0.3); + } +`; + +export const ExportPreview = styled.div` + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + color: rgba(255, 255, 255, 0.55); + font-family: ${theme.font.family.mono}; + font-size: 10px; + line-height: 1.5; + margin-bottom: 14px; + max-height: 200px; + overflow-y: auto; + padding: 12px; +`; + +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)'}; + border-radius: 8px; + color: ${(props) => (props.$primary ? '#fff' : 'rgba(255, 255, 255, 0.8)')}; + cursor: pointer; + display: flex; + font-family: ${theme.font.family.sans}; + font-size: 11px; + font-weight: 600; + gap: 8px; + justify-content: center; + margin-top: ${(props) => (props.$primary ? '0' : '8px')}; + min-height: 31px; + padding: 7px 12px; + transition: all 0.2s 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)'}; + } +`; + +export const ExportNote = styled.div` + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + line-height: 1.5; + margin-top: 12px; +`; + +export const SmallBody = styled.p` + color: rgba(255, 255, 255, 0.45); + font-size: 10px; + line-height: 1.6; + margin-bottom: 14px; +`; + +type SliderControlProps = { + children: ReactNode; + max: number; + min: number; + onChange: ChangeEventHandler; + step?: number; + value: number; + valueLabel: string; +}; + +function getStepPrecision(step: number | undefined) { + if (!step || Number.isInteger(step)) { + return 0; + } + + const stepString = step.toString().toLowerCase(); + + if (stepString.includes('e-')) { + const exponent = stepString.split('e-')[1]; + + return exponent ? Number.parseInt(exponent, 10) : 0; + } + + return stepString.split('.')[1]?.length ?? 0; +} + +function formatEditableValue(value: number, step: number | undefined) { + const precision = getStepPrecision(step); + + if (precision === 0) { + return String(Math.round(value)); + } + + return String(Number(value.toFixed(precision))); +} + +function clampAndSnapValue( + value: number, + min: number, + max: number, + step: number | undefined, +) { + const clampedValue = Math.min(Math.max(value, min), max); + + if (!step || step <= 0) { + return clampedValue; + } + + const precision = getStepPrecision(step); + const snappedValue = Math.round((clampedValue - min) / step) * step + min; + + return Number( + Math.min(Math.max(snappedValue, min), max).toFixed(precision), + ); +} + +export function SliderControl({ + children, + max, + min, + onChange, + step, + value, + valueLabel, +}: SliderControlProps) { + const [draftValue, setDraftValue] = useState(null); + const valueInputReference = useRef(null); + const fillPercent = ((value - min) / (max - min)) * 100; + const isEditing = draftValue !== null; + + useEffect(() => { + if (!isEditing) { + return; + } + + valueInputReference.current?.focus(); + valueInputReference.current?.select(); + }, [isEditing]); + + const commitDraftValue = () => { + if (draftValue === null) { + return; + } + + const nextValue = Number.parseFloat(draftValue); + setDraftValue(null); + + if (!Number.isFinite(nextValue)) { + return; + } + + const normalizedValue = clampAndSnapValue(nextValue, min, max, step); + + onChange({ + target: { value: String(normalizedValue) }, + } as React.ChangeEvent); + }; + + return ( + + {children} + + {isEditing ? ( + setDraftValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitDraftValue(); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + setDraftValue(null); + } + }} + ref={valueInputReference} + step={step} + type="number" + value={draftValue} + /> + ) : ( + { + event.preventDefault(); + event.stopPropagation(); + setDraftValue(formatEditableValue(value, step)); + }} + onMouseDown={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + type="button" + > + {valueLabel} + + )} + + ); +} + +type SelectControlProps = { + children: ReactNode; + onChange: ChangeEventHandler; + value: string; + options: Array<{ label: string; value: string }>; +}; + +export function SelectControl({ + children, + onChange, + options, + value, +}: SelectControlProps) { + return ( + + {children} + + {options.map((option) => ( + + ))} + + + ); +} + +type ToggleControlProps = { + checked: boolean; + label: ReactNode; + onChange: ChangeEventHandler; +}; + +export function ToggleControl({ + checked, + label, + onChange, +}: ToggleControlProps) { + return ( + + {label} + + + + + + ); +} + +type LabelWithTooltipProps = { + description: string; + label: ReactNode; +}; + +export function LabelWithTooltip({ + description, + label, +}: LabelWithTooltipProps) { + const buttonReference = useRef(null); + const tooltipId = useId(); + const [isMounted, setIsMounted] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ + left: 0, + top: 0, + }); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!isOpen) { + return; + } + + const updateTooltipPosition = () => { + const button = buttonReference.current; + + if (!button) { + return; + } + + const rect = button.getBoundingClientRect(); + setTooltipPosition({ + left: rect.left + rect.width / 2, + top: rect.bottom + 12, + }); + }; + + updateTooltipPosition(); + + window.addEventListener('resize', updateTooltipPosition); + window.addEventListener('scroll', updateTooltipPosition, true); + + return () => { + window.removeEventListener('resize', updateTooltipPosition); + window.removeEventListener('scroll', updateTooltipPosition, true); + }; + }, [isOpen]); + + return ( + + {label} + + setIsOpen(false)} + onFocus={() => setIsOpen(true)} + onMouseEnter={() => setIsOpen(true)} + onMouseLeave={() => setIsOpen(false)} + ref={buttonReference} + type="button" + > + + + {isMounted && isOpen + ? createPortal( + + {description} + , + document.body, + ) + : null} + + + ); +} + +type SectionToggleHeaderProps = { + checked: boolean; + children: ReactNode; + onChange: ChangeEventHandler; + preserveCase?: boolean; +}; + +export function SectionToggleHeader({ + checked, + children, + onChange, + preserveCase, +}: SectionToggleHeaderProps) { + return ( + + {children} + + + + + + ); +} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts new file mode 100644 index 0000000000..0bb78df189 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts @@ -0,0 +1,2598 @@ +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 + #include + } +`; + +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; +} + +const GEOMETRY_RUNTIME_SOURCE = String.raw` +function makePolarShape(radiusFunction, segments = 320) { + const shape = new THREE.Shape(); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * Math.PI * 2; + const radius = radiusFunction(angle); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (segmentIndex === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + return shape; +} + +function makeReliefGeometry(shape, options = {}) { + const { + bevelSegments = 8, + bevelSize = 0.08, + bevelThickness = 0.1, + depth = 0.58, + waveDepth = 0.016, + waves = 8, + } = options; + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 2, + bevelEnabled: true, + bevelThickness, + bevelSize, + bevelSegments, + curveSegments: 96, + }); + + geometry.center(); + + const position = geometry.attributes.position; + let maxRadius = 0; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + maxRadius = Math.max( + maxRadius, + Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), + ); + } + + const fullDepth = depth + bevelThickness * 2; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + const x = position.getX(vertexIndex); + const y = position.getY(vertexIndex); + const z = position.getZ(vertexIndex); + const radius = Math.hypot(x, y) / maxRadius; + const angle = Math.atan2(y, x); + const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); + const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); + const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); + const wave = + Math.cos(angle * waves) * + Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * + waveDepth; + const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); + + position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); + } + + position.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function mergeGeometries(geometries) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function makeArrowTarget() { + const targetParts = []; + const arrowParts = []; + const baseRadius = 1.35; + const baseDepth = 0.32; + const bevel = 0.12; + const points = []; + const segments = 16; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + -baseDepth / 2 + bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + baseDepth / 2 - bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + targetParts.push(disc); + + for (const radius of [0.45, 0.85, 1.22]) { + const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); + ring.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), + ); + targetParts.push(ring); + } + + const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2); + bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); + bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); + targetParts.push(bump); + + const shaftLength = 1.5; + const shaftRadius = 0.05; + const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1); + shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0)); + arrowParts.push(shaft); + + const head = new THREE.ConeGeometry(0.12, 0.35, 10); + head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); + arrowParts.push(head); + + for (let finIndex = 0; finIndex < 3; finIndex += 1) { + const finShape = new THREE.Shape(); + finShape.moveTo(0, 0); + finShape.lineTo(0.22, 0.25); + finShape.lineTo(0, 0.5); + finShape.lineTo(0, 0); + + const finGeometry = new THREE.ExtrudeGeometry(finShape, { + depth: 0.012, + bevelEnabled: false, + }); + + finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006)); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), + ); + arrowParts.push(finGeometry); + } + + const nock = new THREE.SphereGeometry(0.065, 8, 8); + nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0)); + arrowParts.push(nock); + + const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); + const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); + const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); + + for (const geometry of arrowParts) { + geometry.applyMatrix4(aim); + geometry.applyMatrix4(tilt); + geometry.applyMatrix4(shift); + } + + const merged = mergeGeometries([...targetParts, ...arrowParts]); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function makeDollarCoin() { + const parts = []; + const baseRadius = 1.3; + const baseDepth = 0.45; + const bevel = 0.18; + const points = []; + const segments = 20; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + Math.sin(angle) * (baseDepth / 2), + ), + ); + } + + points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + parts.push(disc); + + const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + frontRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), + ); + parts.push(frontRim); + + const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + backRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), + ); + parts.push(backRim); + + const createDollarSign = () => { + const geometries = []; + const tubeRadius = 0.1; + const curveRadius = 0.28; + const verticalOffset = 0.22; + + const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); + geometries.push(bar); + + const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), + ); + geometries.push(topArc); + + const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); + bottomArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), + ); + geometries.push(bottomArc); + + const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0), + ); + geometries.push(topSerif); + + const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + bottomSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0), + ); + geometries.push(bottomSerif); + + const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); + const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); + const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12); + diagonal.applyMatrix4( + new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), + ); + geometries.push(diagonal); + + return geometries; + }; + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), + ); + parts.push(geometry); + } + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), + ); + parts.push(geometry); + } + + const merged = mergeGeometries(parts); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function createBuiltinGeometry(shapeKey) { + switch (shapeKey) { + case 'torusKnot': + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + case 'sphere': + return new THREE.SphereGeometry(1.4, 64, 64); + case 'torus': + return new THREE.TorusGeometry(1, 0.45, 64, 100); + case 'icosahedron': + return new THREE.IcosahedronGeometry(1.4, 4); + case 'box': + return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); + case 'cone': + return new THREE.ConeGeometry(1.2, 2.4, 64, 10); + case 'cylinder': + return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); + case 'octahedron': + return new THREE.OctahedronGeometry(1.5, 2); + case 'dodecahedron': + return new THREE.DodecahedronGeometry(1.35, 1); + case 'tetrahedron': + return new THREE.TetrahedronGeometry(1.7, 1); + case 'sunCoin': + return makeReliefGeometry( + makePolarShape( + (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), + ), + { depth: 0.62, waves: 12, waveDepth: 0.018 }, + ); + case 'lotusCoin': + return makeReliefGeometry( + makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), + { depth: 0.64, waves: 8, waveDepth: 0.014 }, + ); + case 'arrowTarget': + return makeArrowTarget(); + case 'dollarCoin': + return makeDollarCoin(); + default: + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + } +} +`; + +const IMPORTED_RUNTIME_SOURCE = String.raw` +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 normalizeImportedGeometry(geometry) { + 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, emptyMessage) { + root.updateMatrixWorld(true); + const geometries = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +function parseFbxGeometry(buffer, label) { + const originalWarn = console.warn; + + console.warn = (...args) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse(buffer, ''); + return extractMergedGeometry(root, label + ' did not contain any mesh geometry.'); + } finally { + console.warn = originalWarn; + } +} + +function parseGlbGeometry(buffer, label) { + return new Promise((resolve, reject) => { + new GLTFLoader(createLoadingManager()).parse( + buffer, + '', + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + label + ' did not contain any mesh geometry.', + ), + ); + } catch (error) { + reject(error); + } + }, + reject, + ); + }); +} + +async function loadImportedGeometryFromUrl(loader, modelUrl, label) { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); + } + + const buffer = await response.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, label); + } + + return parseGlbGeometry(buffer, label); +} +`; + +type ExportedShapeDescriptor = { + filename: string | null; + key: string; + kind: HalftoneGeometrySpec['kind']; + label: string; + loader: HalftoneGeometrySpec['loader'] | null; +}; + +export type ParsedExportedPreset = { + componentName: string | null; + imageAssetReference: string | null; + initialPose: HalftoneExportPose; + modelAssetReference: string | null; + settings: HalftoneStudioSettings; + shape: ExportedShapeDescriptor; +}; + +function createDefaultExportPose(): HalftoneExportPose { + return { + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 0, + }; +} + +function normalizeExportPose( + pose: HalftoneExportPose | undefined, +): HalftoneExportPose { + const fallback = createDefaultExportPose(); + + if (!pose) { + return fallback; + } + + return { + autoElapsed: Number.isFinite(pose.autoElapsed) ? pose.autoElapsed : 0, + rotateElapsed: Number.isFinite(pose.rotateElapsed) ? pose.rotateElapsed : 0, + rotationX: Number.isFinite(pose.rotationX) ? pose.rotationX : 0, + rotationY: Number.isFinite(pose.rotationY) ? pose.rotationY : 0, + rotationZ: Number.isFinite(pose.rotationZ) ? pose.rotationZ : 0, + targetRotationX: Number.isFinite(pose.targetRotationX) + ? pose.targetRotationX + : 0, + targetRotationY: Number.isFinite(pose.targetRotationY) + ? pose.targetRotationY + : 0, + timeElapsed: Number.isFinite(pose.timeElapsed) ? pose.timeElapsed : 0, + }; +} + +function toPascalCase(value: string) { + const tokens = value + .replace(/\.[^.]+$/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + + const joined = tokens + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(''); + + if (!joined) { + return 'HalftoneDashes'; + } + + return /^[A-Za-z_]/.test(joined) ? joined : `Halftone${joined}`; +} + +function extractSerializedJson( + content: string, + name: string, + nextName: string, +) { + const pattern = new RegExp( + String.raw`const\s+${name}\s*=\s*([\s\S]*?)\s*;\s*\r?\nconst\s+${nextName}\s*=`, + ); + const match = content.match(pattern); + + if (!match) { + throw new Error(`Could not find ${name} in the exported preset.`); + } + + try { + return JSON.parse(match[1]) as T; + } catch (error) { + throw new Error( + error instanceof Error + ? `Could not parse ${name}: ${error.message}` + : `Could not parse ${name}.`, + ); + } +} + +function extractFirstMatch(content: string, patterns: RegExp[]) { + for (const pattern of patterns) { + const match = content.match(pattern); + + if (match && match.length > 1) { + return match[match.length - 1] ?? null; + } + } + + return null; +} + +export function parseExportedPreset(content: string): ParsedExportedPreset { + const settings = extractSerializedJson( + content, + 'settings', + 'shape', + ); + const shape = extractSerializedJson( + content, + 'shape', + 'initialPose', + ); + const initialPose = normalizeExportPose( + extractSerializedJson( + content, + 'initialPose', + 'VIRTUAL_RENDER_HEIGHT', + ), + ); + const componentName = + extractFirstMatch(content, [ + /export\s+default\s+function\s+([A-Za-z0-9_]+)/, + /([^<]+)<\/title>/i, + ]) ?? null; + const modelAssetReference = extractFirstMatch(content, [ + /modelUrl\s*=\s*(['"`])([\s\S]*?)\1/, + /modelUrl\s*:\s*(['"`])([\s\S]*?)\1/, + ]); + const imageAssetReference = extractFirstMatch(content, [ + /imageUrl\s*=\s*(['"`])([\s\S]*?)\1/, + /imageUrl\s*:\s*(['"`])([\s\S]*?)\1/, + ]); + + return { + componentName, + imageAssetReference, + initialPose, + modelAssetReference, + settings, + shape, + }; +} + +export function deriveExportComponentName( + shape: HalftoneGeometrySpec | undefined, + importedFile: File | undefined, +) { + const source = + importedFile?.name ?? + shape?.filename ?? + shape?.label ?? + shape?.key ?? + 'HalftoneDashes'; + + return toPascalCase(source); +} + +function createShapeDescriptor( + shape: HalftoneGeometrySpec | undefined, + settings: HalftoneStudioSettings, + importedFile?: File, + modelFilenameOverride?: string, +): ExportedShapeDescriptor { + if (!shape) { + return { + filename: null, + key: settings.shapeKey, + kind: 'builtin', + label: settings.shapeKey, + loader: null, + }; + } + + const effectiveImportedFilename = + modelFilenameOverride ?? importedFile?.name ?? shape.filename ?? null; + + return { + filename: + shape.kind === 'imported' + ? effectiveImportedFilename + : (shape.filename ?? null), + key: shape.key, + kind: shape.kind, + label: + shape.kind === 'imported' + ? (effectiveImportedFilename ?? shape.label) + : shape.label, + loader: shape.loader ?? null, + }; +} + +function getModelMimeType( + file: File, + loader: HalftoneGeometrySpec['loader'] | null, +) { + if (file.type) { + return file.type; + } + + if (loader === 'glb' || file.name.toLowerCase().endsWith('.glb')) { + return 'model/gltf-binary'; + } + + if (loader === 'fbx' || file.name.toLowerCase().endsWith('.fbx')) { + return 'application/octet-stream'; + } + + return 'application/octet-stream'; +} + +async function fileToDataUrl( + file: File, + loader: HalftoneGeometrySpec['loader'] | null, +) { + const buffer = await file.arrayBuffer(); + const blob = new Blob([buffer], { + type: getModelMimeType(file, loader), + }); + + return await new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + + reader.onerror = () => { + reject(reader.error ?? new Error(`Unable to read ${file.name}.`)); + }; + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error(`Unable to encode ${file.name}.`)); + return; + } + + resolve(reader.result); + }; + + reader.readAsDataURL(blob); + }); +} + +function serializeRuntimeSource( + settings: HalftoneStudioSettings, + shape: ExportedShapeDescriptor, + initialPose: HalftoneExportPose, +) { + const isImageMode = settings.sourceMode === 'image'; + + return ` +const settings = ${JSON.stringify(settings, null, 2)}; +const shape = ${JSON.stringify(shape, null, 2)}; +const initialPose = ${JSON.stringify(initialPose, null, 2)}; +const VIRTUAL_RENDER_HEIGHT = ${VIRTUAL_RENDER_HEIGHT}; +const passThroughVertexShader = ${JSON.stringify(passThroughVertexShader)}; +const blurFragmentShader = ${JSON.stringify(blurFragmentShader)}; +const halftoneFragmentShader = ${JSON.stringify(halftoneFragmentShader)}; +${isImageMode ? `const imagePassthroughFragmentShader = ${JSON.stringify(imagePassthroughFragmentShader)};` : ''} + +${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE} + +${isImageMode ? '' : IMPORTED_RUNTIME_SOURCE} + +function createRenderTarget(width, height) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function createInteractionState() { + return { + autoElapsed: initialPose.autoElapsed, + activePointerId: null, + dragOffsetX: 0, + dragOffsetY: 0, + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerInside: false, + pointerVelocityX: 0, + pointerVelocityY: 0, + pointerX: 0, + pointerY: 0, + rotateElapsed: initialPose.rotateElapsed, + rotationX: initialPose.rotationX, + rotationVelocityX: 0, + rotationY: initialPose.rotationY, + rotationVelocityY: 0, + rotationZ: initialPose.rotationZ, + rotationVelocityZ: 0, + smoothedMouseX: 0.5, + smoothedMouseY: 0.5, + targetRotationX: initialPose.targetRotationX, + targetRotationY: initialPose.targetRotationY, + velocityX: 0, + velocityY: 0, + }; +} + +${ + isImageMode + ? '' + : ` +function setPrimaryLightPosition(light, angleDegrees, height) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set(Math.cos(lightAngle) * 5, height, Math.sin(lightAngle) * 5); +} + +function applySpringStep(current, target, velocity, strength, damping) { + const nextVelocity = (velocity + (target - current) * strength) * damping; + const nextValue = current + nextVelocity; + + return { + value: nextValue, + velocity: nextVelocity, + }; +} + +function resetInteractionState(interactionState) { + interactionState.dragging = false; + interactionState.mouseX = 0.5; + interactionState.mouseY = 0.5; + interactionState.targetRotationX = 0; + interactionState.targetRotationY = 0; + interactionState.velocityX = 0; + interactionState.velocityY = 0; + interactionState.rotationVelocityX = 0; + interactionState.rotationVelocityY = 0; + interactionState.rotationVelocityZ = 0; + interactionState.autoElapsed = 0; +} + +async function createGeometry(modelUrl) { + if (shape.kind === 'imported' && shape.loader && modelUrl) { + return loadImportedGeometryFromUrl(shape.loader, modelUrl, shape.label); + } + + return createBuiltinGeometry(shape.key); +} +` +} +`; +} + +function createMountScript() { + return ` +async function mountHalftoneCanvas(options) { + const { + container, + modelUrl, + onError, + } = options; + + 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; + + try { + geometry = await createGeometry(modelUrl); + } catch (error) { + onError?.(error); + geometry = createBuiltinGeometry('torusKnot'); + } + + 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 baseCameraDistance = 4; + const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100); + camera.position.z = baseCameraDistance; + + 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({ + color: 0xd4d0c8, + roughness: settings.material.roughness, + metalness: settings.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: settings.halftone.numRows }, + glowStr: { value: 0 }, + contrast: { value: settings.halftone.contrast }, + power: { value: settings.halftone.power }, + shading: { value: settings.halftone.shading }, + baseInk: { value: settings.halftone.baseInk }, + maxBar: { value: settings.halftone.maxBar }, + rowMerge: { value: settings.halftone.rowMerge }, + cellRatio: { value: settings.halftone.cellRatio }, + cutoff: { value: settings.halftone.cutoff }, + highlightOpen: { value: settings.halftone.highlightOpen }, + shadowGrouping: { value: settings.halftone.shadowGrouping }, + shadowCrush: { value: settings.halftone.shadowCrush }, + dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1.0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const 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 autoRotateEnabled = settings.animation.autoRotateEnabled; + const followHoverEnabled = settings.animation.followHoverEnabled; + const followDragEnabled = settings.animation.followDragEnabled; + const rotateEnabled = settings.animation.rotateEnabled; + + 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) => { + 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) => { + 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) => { + updatePointerPosition(event); + }; + + const handleWindowPointerMove = (event) => { + updatePointerPosition(event); + + if ( + !interaction.dragging || + (!followDragEnabled && !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 handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + }; + + const handlePointerUp = () => { + interaction.dragging = false; + canvas.style.cursor = 'grab'; + + if (!settings.animation.springReturnEnabled) { + return; + } + + const springImpulse = Math.max(settings.animation.springStrength * 10, 1.2); + interaction.rotationVelocityX += interaction.velocityX * springImpulse; + interaction.rotationVelocityY += interaction.velocityY * springImpulse; + interaction.rotationVelocityZ += interaction.velocityY * springImpulse * 0.12; + interaction.targetRotationX = 0; + interaction.targetRotationY = 0; + interaction.velocityX = 0; + interaction.velocityY = 0; + }; + + 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); + + const delta = 1 / 60; + const elapsedTime = initialPose.timeElapsed + clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + + let baseRotationX = 0; + let baseRotationY = 0; + let baseRotationZ = 0; + let meshOffsetY = 0; + let meshScale = 1; + let lightAngle = settings.lighting.angleDegrees; + let lightHeight = settings.lighting.height; + + if (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.floatEnabled) { + const floatPhase = elapsedTime * settings.animation.floatSpeed; + const driftAmount = (settings.animation.driftAmount * Math.PI) / 180; + + meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude; + baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; + baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; + } + + if (settings.animation.breatheEnabled) { + meshScale *= + 1 + + Math.sin(elapsedTime * settings.animation.breatheSpeed) * + settings.animation.breatheAmount; + } + + if (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; + } + } else if (settings.animation.rotatePreset === 'lissajous') { + baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; + baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; + baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; + } else if (settings.animation.rotatePreset === 'orbit') { + baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; + baseRotationY += Math.cos(rotateProgress) * 1.2; + baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; + } else if (settings.animation.rotatePreset === 'tumble') { + baseRotationX += rotateProgress * 0.55; + baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; + baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; + } + } + + if (settings.animation.lightSweepEnabled) { + const lightPhase = elapsedTime * settings.animation.lightSweepSpeed; + lightAngle += Math.sin(lightPhase) * settings.animation.lightSweepRange; + lightHeight += + Math.cos(lightPhase * 0.85) * + settings.animation.lightSweepHeightRange; + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (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 (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 (autoRotateEnabled && !followHoverEnabled && !followDragEnabled) { + targetX = baseRotationX + interaction.targetRotationX; + targetY = baseRotationY + interaction.targetRotationY; + + if (interaction.dragging) { + targetX = interaction.targetRotationX; + targetY = interaction.targetRotationY; + } + + easing = 0.08; + } + + if (settings.animation.springReturnEnabled) { + const springX = applySpringStep( + interaction.rotationX, + targetX, + interaction.rotationVelocityX, + settings.animation.springStrength, + settings.animation.springDamping, + ); + const springY = applySpringStep( + interaction.rotationY, + targetY, + interaction.rotationVelocityY, + settings.animation.springStrength, + settings.animation.springDamping, + ); + const springZ = applySpringStep( + interaction.rotationZ, + baseRotationZ, + interaction.rotationVelocityZ, + settings.animation.springStrength, + settings.animation.springDamping, + ); + + interaction.rotationX = springX.value; + interaction.rotationY = springY.value; + interaction.rotationZ = springZ.value; + interaction.rotationVelocityX = springX.velocity; + interaction.rotationVelocityY = springY.velocity; + interaction.rotationVelocityZ = springZ.velocity; + } else { + 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, + ); + mesh.position.y = meshOffsetY; + mesh.scale.setScalar(meshScale); + + if (settings.animation.cameraParallaxEnabled) { + const cameraRange = settings.animation.cameraParallaxAmount; + const cameraEase = settings.animation.cameraParallaxEase; + const centeredX = (interaction.mouseX - 0.5) * 2; + const centeredY = (0.5 - interaction.mouseY) * 2; + const orbitYaw = centeredX * cameraRange; + const orbitPitch = centeredY * cameraRange * 0.7; + const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance; + const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; + const targetCameraY = + Math.sin(orbitPitch) * baseCameraDistance * 0.85; + const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; + + camera.position.x += (targetCameraX - camera.position.x) * cameraEase; + camera.position.y += (targetCameraY - camera.position.y) * cameraEase; + camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; + } else { + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; + } + + camera.lookAt(0, meshOffsetY * 0.2, 0); + setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); + + 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(); + canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointerup', handlePointerUp); + canvas.removeEventListener('pointercancel', handlePointerCancel); + window.removeEventListener('blur', handleWindowBlur); + 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); + } + }; +} +`; +} + +function createImageMountScript() { + return ` +async function mountHalftoneCanvas(options) { + const { + container, + imageUrl, + onError, + } = options; + + 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, + ); + + // Load image + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = 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: 1 }, + }, + 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: settings.halftone.numRows }, + glowStr: { value: 0 }, + contrast: { value: settings.halftone.contrast }, + power: { value: settings.halftone.power }, + shading: { value: settings.halftone.shading }, + baseInk: { value: settings.halftone.baseInk }, + maxBar: { value: settings.halftone.maxBar }, + rowMerge: { value: settings.halftone.rowMerge }, + cellRatio: { value: settings.halftone.cellRatio }, + cutoff: { value: settings.halftone.cutoff }, + highlightOpen: { value: settings.halftone.highlightOpen }, + shadowGrouping: { value: settings.halftone.shadowGrouping }, + shadowCrush: { value: settings.halftone.shadowCrush }, + dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: settings.animation.waveSpeed }, + distanceScale: { value: 1.0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: settings.animation.hoverLightRadius }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: settings.animation.dragFlowRadius }, + 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 imagePointerFollow = 0.38; + const imagePointerVelocityDamping = 0.82; + const imageDragOffsetLimit = 0.08; + + 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, options = {}) => { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + const nextMouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, 0, 1, + ); + const nextMouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, 0, 1, + ); + + const deltaX = nextMouseX - interaction.mouseX; + const deltaY = nextMouseY - interaction.mouseY; + + interaction.mouseX = nextMouseX; + interaction.mouseY = nextMouseY; + interaction.pointerInside = + interaction.dragging || + (event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom); + + if (options.resetVelocity) { + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + interaction.smoothedMouseX = nextMouseX; + interaction.smoothedMouseY = nextMouseY; + } else { + interaction.pointerVelocityX = deltaX; + interaction.pointerVelocityY = deltaY; + } + + return { deltaX, deltaY }; + }; + + const releasePointerCapture = (pointerId) => { + if (pointerId === null) { + return; + } + + if (!canvas.hasPointerCapture(pointerId)) { + return; + } + + try { + canvas.releasePointerCapture(pointerId); + } catch { + // Ignore capture release failures during teardown. + } + }; + + const handlePointerDown = (event) => { + updatePointerPosition(event, { resetVelocity: true }); + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + + if (!settings.animation.dragFlowEnabled) { + return; + } + + interaction.dragging = true; + interaction.activePointerId = event.pointerId; + interaction.velocityX = 0; + interaction.velocityY = 0; + + try { + canvas.setPointerCapture(event.pointerId); + } catch { + // Pointer capture can fail if the canvas is detached. + } + }; + + const handlePointerMove = (event) => { + const resetVelocity = !interaction.pointerInside && !interaction.dragging; + const pointerStep = updatePointerPosition( + event, + resetVelocity ? { resetVelocity: true } : undefined, + ); + + if (!interaction.dragging) { + return; + } + + if ( + interaction.activePointerId !== null && + event.pointerId !== interaction.activePointerId + ) { + return; + } + + if (!settings.animation.dragFlowEnabled) { + return; + } + + interaction.dragOffsetX = THREE.MathUtils.clamp( + interaction.dragOffsetX + pointerStep.deltaX * 2.2, + -imageDragOffsetLimit, + imageDragOffsetLimit, + ); + interaction.dragOffsetY = THREE.MathUtils.clamp( + interaction.dragOffsetY + pointerStep.deltaY * 2.2, + -imageDragOffsetLimit, + imageDragOffsetLimit, + ); + }; + + const handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + }; + + const handlePointerUp = (event) => { + updatePointerPosition(event, { resetVelocity: true }); + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + const rect = canvas.getBoundingClientRect(); + interaction.pointerInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + }; + + const handlePointerCancel = () => { + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + interaction.dragOffsetX = 0; + interaction.dragOffsetY = 0; + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + }; + + const handleWindowBlur = () => { + handlePointerCancel(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerup', handlePointerUp); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + const clock = new THREE.Clock(); + let animationFrameId = 0; + + const renderFrame = () => { + animationFrameId = window.requestAnimationFrame(renderFrame); + + const elapsedTime = clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + const pointerFollow = interaction.dragging ? 0.46 : imagePointerFollow; + const pointerActive = interaction.pointerInside || interaction.dragging; + + interaction.smoothedMouseX += + (interaction.mouseX - interaction.smoothedMouseX) * pointerFollow; + interaction.smoothedMouseY += + (interaction.mouseY - interaction.smoothedMouseY) * pointerFollow; + interaction.pointerVelocityX *= imagePointerVelocityDamping; + interaction.pointerVelocityY *= imagePointerVelocityDamping; + + if (settings.animation.dragFlowEnabled) { + const dragDecay = 1 - settings.animation.dragFlowDecay; + interaction.dragOffsetX *= dragDecay; + interaction.dragOffsetY *= dragDecay; + + if (Math.abs(interaction.dragOffsetX) < 0.00005) { + interaction.dragOffsetX = 0; + } + + if (Math.abs(interaction.dragOffsetY) < 0.00005) { + interaction.dragOffsetY = 0; + } + } else { + interaction.dragOffsetX = 0; + interaction.dragOffsetY = 0; + } + + const dragActive = + settings.animation.dragFlowEnabled && + (interaction.dragging || + Math.abs(interaction.dragOffsetX) > 0.0005 || + Math.abs(interaction.dragOffsetY) > 0.0005); + + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.smoothedMouseX, + 1 - interaction.smoothedMouseY, + ); + halftoneMaterial.uniforms.interactionVelocity.value.set( + interaction.pointerVelocityX * getVirtualWidth(), + -interaction.pointerVelocityY * getVirtualHeight(), + ); + halftoneMaterial.uniforms.dragOffset.value.set( + interaction.dragOffsetX * getVirtualWidth(), + -interaction.dragOffsetY * getVirtualHeight(), + ); + halftoneMaterial.uniforms.hoverLightStrength.value = + pointerActive && settings.animation.hoverLightEnabled + ? settings.animation.hoverLightIntensity + : 0; + halftoneMaterial.uniforms.hoverLightRadius.value = + settings.animation.hoverLightRadius; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; + halftoneMaterial.uniforms.dragFlowStrength.value = dragActive + ? settings.animation.dragFlowStrength + : 0; + halftoneMaterial.uniforms.dragFlowRadius.value = + settings.animation.dragFlowRadius; + + 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); + canvas.removeEventListener('pointerup', handlePointerUp); + canvas.removeEventListener('pointercancel', handlePointerCancel); + 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 function getExportedModelFile( + shape: HalftoneGeometrySpec | undefined, + importedFile: File | undefined, +) { + if (!shape || shape.kind !== 'imported' || !importedFile) { + return null; + } + + return importedFile; +} + +export function generateReactComponent( + settings: HalftoneStudioSettings, + selectedShape: HalftoneGeometrySpec | undefined, + componentName = 'HalftoneDashes', + modelFilenameOverride?: string, + initialPose?: HalftoneExportPose, + importedFile?: File, + imageFilename?: string, +) { + const isImageMode = settings.sourceMode === 'image'; + const shape = createShapeDescriptor( + selectedShape, + settings, + importedFile, + modelFilenameOverride, + ); + const pose = normalizeExportPose(initialPose); + const defaultModelUrl = + modelFilenameOverride ?? shape.filename ?? 'model.glb'; + const defaultImageUrl = imageFilename ?? 'image.png'; + const background = 'transparent'; + + if (isImageMode) { + return `import { useEffect, useRef, type CSSProperties } from 'react'; +import * as THREE from 'three'; + +${serializeRuntimeSource(settings, shape, pose)} + +${createImageMountScript()} + +type ${componentName}Props = { + imageUrl?: string; + style?: CSSProperties; +}; + +export default function ${componentName}({ + imageUrl = ${JSON.stringify(`./${defaultImageUrl}`)}, + style, +}: ${componentName}Props) { + const mountReference = useRef<HTMLDivElement>(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + const unmount = mountHalftoneCanvas({ + container, + imageUrl, + onError: (error) => { + console.error(error); + }, + }); + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, [imageUrl]); + + return ( + <div + ref={mountReference} + style={{ + background: ${JSON.stringify(background)}, + height: '100%', + width: '100%', + ...style, + }} + /> + ); +} +`; + } + + return `import { useEffect, useRef, type CSSProperties } from 'react'; +import * as THREE from 'three'; +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + +${serializeRuntimeSource(settings, shape, pose)} + +${createMountScript()} + +type ${componentName}Props = { + modelUrl?: string; + style?: CSSProperties; +}; + +export default function ${componentName}({ + modelUrl = ${JSON.stringify(`./${defaultModelUrl}`)}, + style, +}: ${componentName}Props) { + const mountReference = useRef<HTMLDivElement>(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + const unmount = mountHalftoneCanvas({ + container, + modelUrl, + onError: (error) => { + console.error(error); + }, + }); + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, [modelUrl]); + + return ( + <div + ref={mountReference} + style={{ + background: ${JSON.stringify(background)}, + height: '100%', + width: '100%', + ...style, + }} + /> + ); +} +`; +} + +export async function generateStandaloneHtml( + settings: HalftoneStudioSettings, + selectedShape: HalftoneGeometrySpec | undefined, + componentName = 'HalftoneDashes', + modelFilenameOverride?: string, + initialPose?: HalftoneExportPose, + importedFile?: File, + imageFilename?: string, +) { + const isImageMode = settings.sourceMode === 'image'; + const shape = createShapeDescriptor( + selectedShape, + settings, + importedFile, + modelFilenameOverride, + ); + const pose = normalizeExportPose(initialPose); + const defaultImageUrl = imageFilename ?? 'image.png'; + const background = 'transparent'; + const embeddedImportedModelUrl = + !isImageMode && shape.kind === 'imported' && importedFile + ? await fileToDataUrl(importedFile, shape.loader) + : null; + const defaultModelUrl = + embeddedImportedModelUrl ?? + modelFilenameOverride ?? + shape.filename ?? + 'model.glb'; + + const threeImports = isImageMode + ? `import * as THREE from 'three';` + : `import * as THREE from 'three'; + import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; + import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; + import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';`; + + const mountScript = isImageMode + ? createImageMountScript() + : createMountScript(); + + const mountCall = isImageMode + ? `mountHalftoneCanvas({ + container, + imageUrl: ${JSON.stringify(`./${defaultImageUrl}`)}, + onError: (error) => { + console.error(error); + }, + });` + : `mountHalftoneCanvas({ + container, + modelUrl: ${JSON.stringify(`./${defaultModelUrl}`)}, + onError: (error) => { + console.error(error); + }, + });`; + + const captionText = isImageMode + ? `Place <code>${defaultImageUrl}</code> next to this HTML file.` + : `Standalone export of the current halftone scene. + ${ + shape.kind === 'imported' + ? embeddedImportedModelUrl + ? 'The uploaded model is embedded directly in this HTML file.' + : `Place <code>${defaultModelUrl}</code> next to this HTML file to keep the current uploaded shape.` + : '' + }`; + + return `<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>${componentName} + + + + +
+
+
+ ${captionText} +
+
+ + + + +`; +} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts b/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts new file mode 100644 index 0000000000..f9f66d40c2 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/formatters.ts @@ -0,0 +1,108 @@ +export function formatAngle(value: number) { + return `${value}°`; +} + +export function formatDecimal(value: number, digits = 2) { + return value.toFixed(digits); +} + +export function formatPercent(value: number, digits = 0) { + return `${(value * 100).toFixed(digits)}%`; +} + +export function formatAnimationName(animation: { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + dragFlowEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + rotatePreset: string; + springReturnEnabled: boolean; +}, sourceMode: 'shape' | 'image') { + const activeModes: string[] = []; + + if (sourceMode === 'image') { + if (animation.hoverLightEnabled) { + activeModes.push('hoverLight'); + } + + if (animation.dragFlowEnabled) { + activeModes.push('dragSmear'); + } + + return activeModes.length > 0 ? activeModes.join(' + ') : 'still'; + } + + if (animation.autoRotateEnabled) { + activeModes.push('autoRotate'); + } + + if (animation.floatEnabled) { + activeModes.push('float'); + } + + if (animation.breatheEnabled) { + activeModes.push('breathe'); + } + + if (animation.followHoverEnabled) { + activeModes.push('followHover'); + } + + if (animation.followDragEnabled) { + activeModes.push('followDrag'); + } + + if (animation.rotateEnabled) { + activeModes.push( + animation.rotatePreset === 'axis' + ? 'rotate' + : `rotate(${animation.rotatePreset})`, + ); + } + + if (animation.lightSweepEnabled) { + activeModes.push('lightSweep'); + } + + if (animation.cameraParallaxEnabled) { + activeModes.push('cameraParallax'); + } + + if (animation.springReturnEnabled) { + activeModes.push('spring'); + } + + return activeModes.length > 0 ? activeModes.join(' + ') : 'still'; +} + +export function getBackgroundTone(background: { + color: string; + transparent: boolean; +}) { + if (background.transparent) { + return 'light'; + } + + const normalized = background.color.replace('#', ''); + const expanded = + normalized.length === 3 + ? normalized + .split('') + .map((character) => `${character}${character}`) + .join('') + : normalized; + + const value = Number.parseInt(expanded, 16); + const red = (value >> 16) & 255; + const green = (value >> 8) & 255; + const blue = value & 255; + const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255; + + return luminance < 0.5 ? 'dark' : 'light'; +} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts b/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts new file mode 100644 index 0000000000..e414ada9b5 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/geometry-registry.ts @@ -0,0 +1,737 @@ +import * as THREE from 'three'; +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; +import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + +type HalftoneModelLoader = 'fbx' | 'glb'; + +interface HalftoneGeometrySpec { + key: string; + label: string; + kind: 'builtin' | 'imported'; + loader?: HalftoneModelLoader; + filename?: string; + description?: string; + extensions?: readonly string[]; + userProvided?: boolean; +} + +type GeometryCacheEntry = THREE.BufferGeometry | Promise; + +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for ( + let vertexIndex = 0; + vertexIndex < geometryInfo.vertexCount; + vertexIndex += 1 + ) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = 1.6 / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + + return loadingManager; +} + +function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { + root.updateMatrixWorld(true); + + const geometries: THREE.BufferGeometry[] = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +function parseFbxGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, +) { + const originalWarn = console.warn; + + console.warn = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse( + buffer, + resourcePath, + ); + + return extractMergedGeometry( + root, + `${label} did not contain any mesh geometry.`, + ); + } finally { + console.warn = originalWarn; + } +} + +function parseGlbGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, +) { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + resourcePath, + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + `${label} did not contain any mesh geometry.`, + ), + ); + } catch (error) { + reject(error); + } + }, + reject, + ); + }).finally(() => { + dracoLoader.dispose(); + }); +} + +async function loadImportedGeometry( + loader: HalftoneModelLoader, + file: File, + label: string, +) { + const buffer = await file.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, '', label); + } + + return parseGlbGeometry(buffer, '', label); +} + +function makePolarShape( + radiusFunction: (angle: number) => number, + segments = 320, +) { + const shape = new THREE.Shape(); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * Math.PI * 2; + const radius = radiusFunction(angle); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (segmentIndex === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + return shape; +} + +function makeReliefGeometry( + shape: THREE.Shape, + options: { + depth?: number; + bevelSize?: number; + bevelThickness?: number; + bevelSegments?: number; + waves?: number; + waveDepth?: number; + } = {}, +) { + const { + bevelSegments = 8, + bevelSize = 0.08, + bevelThickness = 0.1, + depth = 0.58, + waveDepth = 0.016, + waves = 8, + } = options; + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 2, + bevelEnabled: true, + bevelThickness, + bevelSize, + bevelSegments, + curveSegments: 96, + }); + + geometry.center(); + + const position = geometry.attributes.position; + let maxRadius = 0; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + maxRadius = Math.max( + maxRadius, + Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), + ); + } + + const fullDepth = depth + bevelThickness * 2; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + const x = position.getX(vertexIndex); + const y = position.getY(vertexIndex); + const z = position.getZ(vertexIndex); + const radius = Math.hypot(x, y) / maxRadius; + const angle = Math.atan2(y, x); + const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); + const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); + const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); + const wave = + Math.cos(angle * waves) * + Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * + waveDepth; + const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); + + position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); + } + + position.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function makeArrowTarget() { + const targetParts: THREE.BufferGeometry[] = []; + const arrowParts: THREE.BufferGeometry[] = []; + const baseRadius = 1.35; + const baseDepth = 0.32; + const bevel = 0.12; + const points: THREE.Vector2[] = []; + const segments = 16; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + -baseDepth / 2 + bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + baseDepth / 2 - bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + targetParts.push(disc); + + for (const radius of [0.45, 0.85, 1.22]) { + const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); + ring.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), + ); + targetParts.push(ring); + } + + const bump = new THREE.SphereGeometry( + 0.32, + 32, + 24, + 0, + Math.PI * 2, + 0, + Math.PI / 2, + ); + bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); + bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); + targetParts.push(bump); + + const shaftLength = 1.5; + const shaftRadius = 0.05; + const shaft = new THREE.CylinderGeometry( + shaftRadius, + shaftRadius, + shaftLength, + 10, + 1, + ); + shaft.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0), + ); + arrowParts.push(shaft); + + const head = new THREE.ConeGeometry(0.12, 0.35, 10); + head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); + arrowParts.push(head); + + for (let finIndex = 0; finIndex < 3; finIndex += 1) { + const finShape = new THREE.Shape(); + finShape.moveTo(0, 0); + finShape.lineTo(0.22, 0.25); + finShape.lineTo(0, 0.5); + finShape.lineTo(0, 0); + + const finGeometry = new THREE.ExtrudeGeometry(finShape, { + depth: 0.012, + bevelEnabled: false, + }); + + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, 0, -0.006), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), + ); + arrowParts.push(finGeometry); + } + + const nock = new THREE.SphereGeometry(0.065, 8, 8); + nock.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0), + ); + arrowParts.push(nock); + + const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); + const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); + const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); + + for (const geometry of arrowParts) { + geometry.applyMatrix4(aim); + geometry.applyMatrix4(tilt); + geometry.applyMatrix4(shift); + } + + const merged = mergeGeometries([...targetParts, ...arrowParts]); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function makeDollarCoin() { + const parts: THREE.BufferGeometry[] = []; + const baseRadius = 1.3; + const baseDepth = 0.45; + const bevel = 0.18; + const points: THREE.Vector2[] = []; + const segments = 20; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + Math.sin(angle) * (baseDepth / 2), + ), + ); + } + + points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + parts.push(disc); + + const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + frontRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), + ); + parts.push(frontRim); + + const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + backRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), + ); + parts.push(backRim); + + const createDollarSign = () => { + const geometries: THREE.BufferGeometry[] = []; + const tubeRadius = 0.1; + const curveRadius = 0.28; + const verticalOffset = 0.22; + + const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); + geometries.push(bar); + + const topArc = new THREE.TorusGeometry( + curveRadius, + tubeRadius, + 16, + 32, + Math.PI, + ); + topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), + ); + geometries.push(topArc); + + const bottomArc = new THREE.TorusGeometry( + curveRadius, + tubeRadius, + 16, + 32, + Math.PI, + ); + bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); + bottomArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), + ); + geometries.push(bottomArc); + + const topSerif = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + 0.22, + 12, + ); + topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation( + 0.16, + verticalOffset + curveRadius, + 0, + ), + ); + geometries.push(topSerif); + + const bottomSerif = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + 0.22, + 12, + ); + bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + bottomSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation( + -0.16, + -verticalOffset - curveRadius, + 0, + ), + ); + geometries.push(bottomSerif); + + const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); + const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); + const diagonal = new THREE.CylinderGeometry( + tubeRadius, + tubeRadius, + diagonalLength + 0.12, + 12, + ); + diagonal.applyMatrix4( + new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), + ); + geometries.push(diagonal); + + return geometries; + }; + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), + ); + parts.push(geometry); + } + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), + ); + parts.push(geometry); + } + + const merged = mergeGeometries(parts); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +const BUILTIN_GEOMETRIES: Record THREE.BufferGeometry> = { + torusKnot: () => new THREE.TorusKnotGeometry(1, 0.35, 200, 32), + sphere: () => new THREE.SphereGeometry(1.4, 64, 64), + torus: () => new THREE.TorusGeometry(1, 0.45, 64, 100), + icosahedron: () => new THREE.IcosahedronGeometry(1.4, 4), + box: () => new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6), + cone: () => new THREE.ConeGeometry(1.2, 2.4, 64, 10), + cylinder: () => new THREE.CylinderGeometry(1, 1, 2.3, 64, 10), + octahedron: () => new THREE.OctahedronGeometry(1.5, 2), + dodecahedron: () => new THREE.DodecahedronGeometry(1.35, 1), + tetrahedron: () => new THREE.TetrahedronGeometry(1.7, 1), + sunCoin: () => + makeReliefGeometry( + makePolarShape( + (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), + ), + { depth: 0.62, waves: 12, waveDepth: 0.018 }, + ), + lotusCoin: () => + makeReliefGeometry( + makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), + { depth: 0.64, waves: 8, waveDepth: 0.014 }, + ), + arrowTarget: () => makeArrowTarget(), + dollarCoin: () => makeDollarCoin(), +}; + +async function createGeometry( + spec: HalftoneGeometrySpec, + file: File | undefined, +) { + if (spec.kind === 'imported') { + if (!file || !spec.loader) { + throw new Error(`No file is available for ${spec.label}.`); + } + + return loadImportedGeometry( + spec.loader as HalftoneModelLoader, + file, + spec.label, + ); + } + + const factory = BUILTIN_GEOMETRIES[spec.key]; + + if (!factory) { + throw new Error(`Unsupported geometry: ${spec.key}.`); + } + + return factory(); +} + +export function createFallbackGeometry() { + return BUILTIN_GEOMETRIES.torusKnot(); +} + +export async function getGeometryForSpec( + spec: HalftoneGeometrySpec, + file: File | undefined, + cache: Map, +) { + const cached = cache.get(spec.key); + + if (cached) { + return cached instanceof Promise ? cached : Promise.resolve(cached); + } + + const created = createGeometry(spec, file); + + if (created instanceof Promise) { + const pending = created + .then((geometry) => { + cache.set(spec.key, geometry); + return geometry; + }) + .catch((error) => { + cache.delete(spec.key); + throw error; + }); + + cache.set(spec.key, pending); + + return pending; + } + + cache.set(spec.key, created); + + return Promise.resolve(created); +} + +export function disposeGeometryCache(cache: Map) { + for (const value of cache.values()) { + if (!(value instanceof Promise)) { + value.dispose(); + } + } + + cache.clear(); +} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/state.ts b/packages/twenty-website-new/src/app/halftone/_lib/state.ts new file mode 100644 index 0000000000..2f7e92e3ee --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/state.ts @@ -0,0 +1,482 @@ +type HalftoneTabId = 'design' | 'animations' | 'export'; +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; +type HalftoneModelLoader = 'fbx' | 'glb'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +interface HalftoneGeometrySpec { + key: string; + label: string; + kind: 'builtin' | 'imported'; + loader?: HalftoneModelLoader; + filename?: string; + description?: string; + extensions?: readonly string[]; + userProvided?: boolean; +} + +interface HalftoneStudioState { + activeTab: HalftoneTabId; + geometrySpecs: HalftoneGeometrySpec[]; + importedFiles: Record; + settings: HalftoneStudioSettings; + showHint: boolean; + statusMessage: string; + statusIsError: boolean; +} + +type HalftoneStudioAction = + | { type: 'setTab'; value: HalftoneTabId } + | { type: 'setSourceMode'; value: HalftoneSourceMode } + | { type: 'setShapeKey'; value: string } + | { type: 'replaceSettings'; value: HalftoneStudioSettings } + | { type: 'patchLighting'; value: Partial } + | { type: 'patchMaterial'; value: Partial } + | { type: 'patchHalftone'; value: Partial } + | { type: 'patchBackground'; value: Partial } + | { type: 'patchAnimation'; value: Partial } + | { + type: 'registerImportedFile'; + spec: HalftoneGeometrySpec; + file: File; + activate: boolean; + } + | { type: 'setImportedFile'; key: string; file: File } + | { type: 'setStatus'; message: string; isError?: boolean } + | { type: 'clearStatus' } + | { type: 'hideHint' }; + +function upsertGeometrySpec( + geometrySpecs: HalftoneGeometrySpec[], + spec: HalftoneGeometrySpec, +) { + const existingIndex = geometrySpecs.findIndex( + (geometrySpec) => geometrySpec.key === spec.key, + ); + + if (existingIndex === -1) { + return [...geometrySpecs, spec]; + } + + return geometrySpecs.map((geometrySpec, index) => + index === existingIndex ? spec : geometrySpec, + ); +} + +export const DEFAULT_GEOMETRY_SPECS: HalftoneGeometrySpec[] = [ + { key: 'torusKnot', label: 'Torus Knot', kind: 'builtin' }, + { key: 'sphere', label: 'Sphere', kind: 'builtin' }, + { key: 'torus', label: 'Torus', kind: 'builtin' }, + { key: 'icosahedron', label: 'Icosahedron', kind: 'builtin' }, + { key: 'box', label: 'Box', kind: 'builtin' }, + { key: 'cone', label: 'Cone', kind: 'builtin' }, + { key: 'cylinder', label: 'Cylinder', kind: 'builtin' }, + { key: 'octahedron', label: 'Octahedron', kind: 'builtin' }, + { key: 'dodecahedron', label: 'Dodecahedron', kind: 'builtin' }, + { key: 'tetrahedron', label: 'Tetrahedron', kind: 'builtin' }, + { key: 'sunCoin', label: 'Sun Coin', kind: 'builtin' }, + { key: 'lotusCoin', label: 'Lotus Coin', kind: 'builtin' }, + { key: 'arrowTarget', label: 'Arrow Target', kind: 'builtin' }, + { key: 'dollarCoin', label: 'Dollar Coin', kind: 'builtin' }, + { + key: 'wheel', + label: 'Wheel.fbx', + kind: 'imported', + loader: 'fbx', + filename: 'Wheel.fbx', + description: 'FBX model', + extensions: ['.fbx'], + }, + { + key: 'twoGlb', + label: 'two.glb', + kind: 'imported', + loader: 'glb', + filename: 'two.glb', + description: 'GLB model', + extensions: ['.glb'], + }, +]; + +export const DEFAULT_SHAPE_HALFTONE_SETTINGS: HalftoneEffectSettings = { + enabled: true, + numRows: 45, + contrast: 1.3, + power: 1.1, + shading: 1.6, + baseInk: 0.12, + maxBar: 0.24, + rowMerge: 0.06, + cellRatio: 2.2, + cutoff: 0.02, + highlightOpen: 0.05, + shadowGrouping: 0.18, + shadowCrush: 0.14, + dashColor: '#4A38F5', +}; + +export const DEFAULT_IMAGE_HALFTONE_SETTINGS: HalftoneEffectSettings = { + enabled: true, + numRows: 80, + contrast: 1.5, + power: 1.2, + shading: 0.8, + baseInk: 0.06, + maxBar: 0.32, + rowMerge: 0.18, + cellRatio: 2.0, + cutoff: 0.02, + highlightOpen: 0.14, + shadowGrouping: 0.38, + shadowCrush: 0.24, + dashColor: '#4A38F5', +}; + +export function getDefaultHalftoneSettings(sourceMode: HalftoneSourceMode) { + return sourceMode === 'image' + ? DEFAULT_IMAGE_HALFTONE_SETTINGS + : DEFAULT_SHAPE_HALFTONE_SETTINGS; +} + +export const DEFAULT_HALFTONE_SETTINGS: HalftoneStudioSettings = { + sourceMode: 'shape' as HalftoneSourceMode, + shapeKey: 'torusKnot', + lighting: { + intensity: 1.5, + fillIntensity: 0.15, + ambientIntensity: 0.08, + angleDegrees: 45, + height: 2, + }, + material: { + roughness: 0.42, + metalness: 0.16, + }, + halftone: DEFAULT_SHAPE_HALFTONE_SETTINGS, + background: { + transparent: true, + color: '#ffffff', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: false, + floatEnabled: false, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.3, + autoWobble: 0.3, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.008, + dragFriction: 0.08, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 1, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.5, + lightSweepRange: 28, + lightSweepSpeed: 0.7, + springDamping: 0.72, + springReturnEnabled: false, + springStrength: 0.18, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, +}; + +export function normalizeHalftoneStudioSettings( + settings?: Partial, +): HalftoneStudioSettings { + const sourceMode = + settings?.sourceMode ?? DEFAULT_HALFTONE_SETTINGS.sourceMode; + + return { + ...DEFAULT_HALFTONE_SETTINGS, + ...settings, + sourceMode, + lighting: { + ...DEFAULT_HALFTONE_SETTINGS.lighting, + ...settings?.lighting, + }, + material: { + ...DEFAULT_HALFTONE_SETTINGS.material, + ...settings?.material, + }, + halftone: { + ...getDefaultHalftoneSettings(sourceMode), + ...settings?.halftone, + }, + background: { + ...DEFAULT_HALFTONE_SETTINGS.background, + ...settings?.background, + }, + animation: { + ...DEFAULT_HALFTONE_SETTINGS.animation, + ...settings?.animation, + }, + }; +} + +export function createInitialHalftoneStudioState(): HalftoneStudioState { + return { + activeTab: 'design', + geometrySpecs: [...DEFAULT_GEOMETRY_SPECS], + importedFiles: {}, + settings: normalizeHalftoneStudioSettings(DEFAULT_HALFTONE_SETTINGS), + showHint: true, + statusMessage: '', + statusIsError: false, + }; +} + +export function halftoneStudioReducer( + state: HalftoneStudioState, + action: HalftoneStudioAction, +): HalftoneStudioState { + switch (action.type) { + case 'setTab': + return { + ...state, + activeTab: action.value, + }; + case 'setSourceMode': + return { + ...state, + settings: { + ...state.settings, + sourceMode: action.value, + }, + }; + case 'setShapeKey': + return { + ...state, + settings: { + ...state.settings, + shapeKey: action.value, + }, + }; + case 'replaceSettings': + return { + ...state, + settings: normalizeHalftoneStudioSettings(action.value), + }; + case 'patchLighting': + return { + ...state, + settings: { + ...state.settings, + lighting: { + ...state.settings.lighting, + ...action.value, + }, + }, + }; + case 'patchMaterial': + return { + ...state, + settings: { + ...state.settings, + material: { + ...state.settings.material, + ...action.value, + }, + }, + }; + case 'patchHalftone': + return { + ...state, + settings: { + ...state.settings, + halftone: { + ...state.settings.halftone, + ...action.value, + }, + }, + }; + case 'patchBackground': + return { + ...state, + settings: { + ...state.settings, + background: { + ...state.settings.background, + ...action.value, + }, + }, + }; + case 'patchAnimation': + return { + ...state, + settings: { + ...state.settings, + animation: { + ...state.settings.animation, + ...action.value, + }, + }, + }; + case 'registerImportedFile': + return { + ...state, + geometrySpecs: action.spec.userProvided + ? upsertGeometrySpec(state.geometrySpecs, action.spec) + : state.geometrySpecs, + importedFiles: { + ...state.importedFiles, + [action.spec.key]: action.file, + }, + settings: action.activate + ? { + ...state.settings, + shapeKey: action.spec.key, + } + : state.settings, + }; + case 'setImportedFile': + return { + ...state, + importedFiles: { + ...state.importedFiles, + [action.key]: action.file, + }, + }; + case 'setStatus': + return { + ...state, + statusMessage: action.message, + statusIsError: action.isError ?? false, + }; + case 'clearStatus': + return { + ...state, + statusMessage: '', + statusIsError: false, + }; + case 'hideHint': + return state.showHint + ? { + ...state, + showHint: false, + } + : state; + default: + return state; + } +} diff --git a/packages/twenty-website-new/src/app/halftone/page.tsx b/packages/twenty-website-new/src/app/halftone/page.tsx new file mode 100644 index 0000000000..7262737091 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/page.tsx @@ -0,0 +1,11 @@ +import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Halftone Generator — Twenty', + description: 'Interactive halftone generator exported from Twenty.', +}; + +export default function HalftonePage() { + return ; +} diff --git a/packages/twenty-website-new/src/app/layout.tsx b/packages/twenty-website-new/src/app/layout.tsx index 4b4dba6776..96b10b9897 100644 --- a/packages/twenty-website-new/src/app/layout.tsx +++ b/packages/twenty-website-new/src/app/layout.tsx @@ -1,4 +1,5 @@ import { FOOTER_DATA } from '@/app/_constants/footer'; +import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate'; import { fetchCommunityStats } from '@/lib/community/fetch-community-stats'; import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'; import { Footer } from '@/sections/Footer/components'; @@ -85,14 +86,16 @@ export default async function RootLayout({ className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`} > {children} - - - - - + + + + + + + ); diff --git a/packages/twenty-website-new/src/illustrations/Faq/Background.tsx b/packages/twenty-website-new/src/illustrations/Faq/Background.tsx index fa3e8c01b0..a519a9f303 100644 --- a/packages/twenty-website-new/src/illustrations/Faq/Background.tsx +++ b/packages/twenty-website-new/src/illustrations/Faq/Background.tsx @@ -1,5 +1,6 @@ 'use client'; +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import { useEffect, useRef } from 'react'; @@ -7,82 +8,306 @@ import * as THREE from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -const GLB_URL = '/illustrations/common/faq/faq.glb'; +const VIRTUAL_RENDER_HEIGHT = 768; -// World spin axis (x, y, z). Camera on +Z: (0,0,1) = spinner in the view plane. -// Try (0,1,0) or (1,0,0) if motion or framing looks wrong. -const scanlineVertexShader = /* glsl */ ` - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; void main() { - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPosition.xyz; - vWorldNormal = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * viewMatrix * worldPosition; + vUv = uv; + gl_Position = vec4(position, 1.0); } `; -const scanlineFragmentShader = /* glsl */ ` - uniform vec3 uColor; - uniform vec3 uLightDir; - uniform float uStripeScale; +const blurFragmentShader = /* glsl */ ` + precision highp float; - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; void main() { - vec3 normal = normalize(vWorldNormal); - vec3 lightDir = normalize(uLightDir); - float ndotl = max(dot(normal, lightDir), 0.06); + vec4 sum = vec4(0.0); + vec2 px = dir / res; - float y = vWorldPosition.y * uStripeScale; - float cell = fract(y); + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; - float shadowWeight = mix(1.0, 0.5, ndotl); - float lineWidth = 0.58 * shadowWeight; - float edge = 0.035; - float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell); + sum += texture2D(tInput, vUv) * w[0]; - float highlight = pow(ndotl, 1.35); - float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0); - float dashMask = mix( - 1.0, - smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)), - highlight - ); - band *= dashMask; - - float speckle = fract( - sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453 - ); - band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85); - - if (band < 0.015) { - discard; + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; } - vec3 lit = uColor * mix(0.72, 1.18, ndotl); - gl_FragColor = vec4(lit, band); + gl_FragColor = sum; } `; -function createScanlineMaterial(lightDirection: THREE.Vector3) { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#1e5bff') }, - uLightDir: { value: lightDirection.clone() }, - uStripeScale: { value: 16.0 }, - }, - vertexShader: scanlineVertexShader, - fragmentShader: scanlineFragmentShader, - transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float rowMerge; + uniform float cellRatio; + uniform float cutoff; + uniform float highlightOpen; + uniform float shadowGrouping; + uniform float shadowCrush; + uniform vec3 dashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float distanceScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float dragFlowRadius; + uniform float cropToBounds; + + varying vec2 vUv; + + void main() { + // Crop to image bounds: discard fragments outside source image (image mode only) + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + float baseRowH = resolution.y / (numRows * distanceScale); + vec2 pointerPx = interactionUv * resolution; + vec2 fragDelta = gl_FragCoord.xy - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * resolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * resolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + float dragFlowMask = 0.0; + if (dragFlowStrength > 0.0) { + float dragRadiusPx = dragFlowRadius * resolution.y; + dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; + vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; + vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; + + float densityBoost = + hoverFlowStrength * hoverFlowMask * 0.22 + + dragFlowStrength * dragFlowMask * 0.16; + float rowH = baseRowH / (1.0 + densityBoost); + + float offsetY = effectCoord.y; + float row = floor(offsetY / rowH); + float rowFrac = offsetY / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; + + float localCellRatio = cellRatio * ( + 1.0 + + hoverFlowStrength * hoverFlowMask * 0.08 + + dragFlowStrength * dragFlowMask * 0.1 * motionBias + ); + float cellW = rowH * localCellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; + float lightFocus = hoverLightStrength * hoverLightMask * 0.12; + litLum = clamp(litLum + lightLift, 0.0, 1.0); + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, max(contrast - lightFocus, 0.25)); + + float darkness = 1.0 - litLum; + float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); + float groupedDarkness = 1.0 - groupedLum; + darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); + darkness = clamp( + (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), + 0.0, + 1.0 + ); + + float shadowMask = smoothstep(0.42, 0.96, darkness); + darkness = mix( + darkness, + mix(darkness, 1.0, shadowMask), + shadowCrush + ); + + float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); + float ink = mix(inkBase, 1.0, darkness); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dynamicBarHalfY = min( + dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), + 0.78 + ); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalfY - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float ellipseDist = sqrt( + (cdx * cdx) / max(capRX * capRX, 0.0001) + + (cdy * cdy) / max(capRY * capRY, 0.0001) + ); + inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const GLB_URL = '/illustrations/common/faq/faq.glb'; + +const LIGHT_INTENSITY = 3.1; +const FILL_LIGHT_INTENSITY = 0.15; +const AMBIENT_LIGHT_INTENSITY = 0.08; +const LIGHT_ANGLE_DEGREES = 45; +const LIGHT_HEIGHT = 2; + +const MATERIAL_ROUGHNESS = 0.42; +const MATERIAL_METALNESS = 0.16; + +const HALFTONE_ROWS = 127; +const HALFTONE_CONTRAST = 1.6; +const HALFTONE_POWER = 1.2; +const HALFTONE_SHADING = 1.6; +const HALFTONE_BASE_INK = 0.16; +const HALFTONE_MAX_BAR = 0.24; +const HALFTONE_CELL_RATIO = 2.2; +const HALFTONE_CUTOFF = 0.02; +const HALFTONE_DASH_COLOR = '#4A38F5'; + +const ROTATE_AXIS: 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy' = '-z'; +const ROTATE_SPEED = 0.1; +const ROTATE_PING_PONG = false; +const INITIAL_ROTATE_ELAPSED = 275.10000000007847; +const INITIAL_TIME_ELAPSED = 249.21849999998116; +const BASE_CAMERA_DISTANCE = 4; +const MODEL_OFFSET_Y = 0.52; + +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, }); } +function disposeMaterial(material: THREE.Material | THREE.Material[]) { + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + return; + } + + material.dispose(); +} + function disposeObjectSubtree(root: THREE.Object3D) { root.traverse((sceneObject) => { if (!(sceneObject instanceof THREE.Mesh)) { @@ -90,26 +315,243 @@ function disposeObjectSubtree(root: THREE.Object3D) { } sceneObject.geometry?.dispose(); - - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } + disposeMaterial(sceneObject.material); }); } -function applyScanlineMaterials( - modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for ( + let vertexIndex = 0; + vertexIndex < geometryInfo.vertexCount; + vertexIndex += 1 + ) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, ) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + + return loadingManager; +} + +function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = 1.6 / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { + root.updateMatrixWorld(true); + + const geometries: THREE.BufferGeometry[] = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { return; } - sceneObject.material = createScanlineMaterial(lightDirection); + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +async function loadFaqGeometry(modelUrl: string) { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error(`Unable to load FAQ model from ${modelUrl}.`); + } + + const buffer = await response.arrayBuffer(); + + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return await new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + '', + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + 'FAQ model did not contain any mesh geometry.', + ), + ); + } catch (error) { + reject(error); + } finally { + disposeObjectSubtree(gltf.scene); + } + }, + reject, + ); + }); +} + +function createFaqMaterial(environmentTexture: THREE.Texture) { + return new THREE.MeshPhysicalMaterial({ + color: 0xd4d0c8, + roughness: MATERIAL_ROUGHNESS, + metalness: MATERIAL_METALNESS, + envMap: environmentTexture, + envMapIntensity: 0.25, + clearcoat: 0, + clearcoatRoughness: 0.08, + reflectivity: 0.5, + transmission: 0, }); } @@ -145,111 +587,301 @@ export function FaqBackground() { useEffect(() => { const container = mountReference.current; + if (!container) { return; } let cancelled = false; let animationFrameId = 0; + let rotateElapsed = INITIAL_ROTATE_ELAPSED; - const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize(); + const getWidth = () => Math.max(container.clientWidth, 1); + const getHeight = () => Math.max(container.clientHeight, 1); + const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualWidth = () => + Math.max( + Math.round( + getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), + ), + 1, + ); - const scene = new THREE.Scene(); - const width = container.clientWidth; - const height = container.clientHeight; - - const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); - camera.position.set(0, 0, 5.05); - - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); + const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + const canvas = renderer.domElement; canvas.style.display = 'block'; canvas.style.height = '100%'; canvas.style.width = '100%'; container.appendChild(canvas); - const wheel = new THREE.Group(); - scene.add(wheel); + const environmentTexture = createEnvironmentTexture(renderer); + const scene3d = new THREE.Scene(); + scene3d.background = null; - const spinAxisWorld = new THREE.Vector3(0, 0, 1).normalize(); + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, + ); + camera.position.z = BASE_CAMERA_DISTANCE; - const clock = new THREE.Clock(); + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + scene3d.add(primaryLight); - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath( - 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/', + const fillLight = new THREE.DirectionalLight( + 0xffffff, + FILL_LIGHT_INTENSITY, + ); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight( + 0xffffff, + AMBIENT_LIGHT_INTENSITY, + ); + scene3d.add(ambientLight); + + const material = createFaqMaterial(environmentTexture); + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); + mesh.visible = false; + scene3d.add(mesh); + + const sceneTarget = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetA = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const blurTargetB = createRenderTarget( + getVirtualWidth(), + getVirtualHeight(), + ); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + numRows: { value: HALFTONE_ROWS }, + glowStr: { value: 0 }, + contrast: { value: HALFTONE_CONTRAST }, + power: { value: HALFTONE_POWER }, + shading: { value: HALFTONE_SHADING }, + baseInk: { value: HALFTONE_BASE_INK }, + maxBar: { value: HALFTONE_MAX_BAR }, + cellRatio: { value: HALFTONE_CELL_RATIO }, + cutoff: { value: HALFTONE_CUTOFF }, + dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), ); - const loader = new GLTFLoader(); - loader.setDRACOLoader(dracoLoader); + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add( + new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), + ); - loader.load( - GLB_URL, - (gltf) => { + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const syncSize = () => { + const width = getWidth(); + const height = getHeight(); + const virtualWidth = getVirtualWidth(); + const virtualHeight = getVirtualHeight(); + + renderer.setSize(virtualWidth, virtualHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(virtualWidth, virtualHeight); + blurTargetA.setSize(virtualWidth, virtualHeight); + blurTargetB.setSize(virtualWidth, virtualHeight); + blurHorizontalMaterial.uniforms.res.value.set( + virtualWidth, + virtualHeight, + ); + blurVerticalMaterial.uniforms.res.value.set(virtualWidth, virtualHeight); + halftoneMaterial.uniforms.resolution.value.set( + virtualWidth, + virtualHeight, + ); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + loadFaqGeometry(GLB_URL) + .then((geometry) => { if (cancelled) { - disposeObjectSubtree(gltf.scene); + geometry.dispose(); return; } - const modelRoot = gltf.scene; - const bounds = new THREE.Box3().setFromObject(modelRoot); - const center = bounds.getCenter(new THREE.Vector3()); - const size = bounds.getSize(new THREE.Vector3()); - const maxAxis = Math.max(size.x, size.y, size.z, 0.001); - const scale = 4 / maxAxis; + mesh.geometry.dispose(); + mesh.geometry = geometry; + mesh.visible = true; + }) + .catch((error) => { + console.error(error); + }); - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); + const clock = new THREE.Clock(); - applyScanlineMaterials(modelRoot, lightDirectionWorld); - wheel.add(modelRoot); - wheel.position.y = 0.52; - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - wheel.rotateOnWorldAxis(spinAxisWorld, -delta * 0.5); - renderer.render(scene, camera); - }; - - renderFrame(); - }, - undefined, - undefined, - ); - - const handleResize = () => { - if (!mountReference.current || cancelled) { + const renderFrame = () => { + if (cancelled) { return; } - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - if (nextWidth < 1 || nextHeight < 1) { - return; + animationFrameId = window.requestAnimationFrame(renderFrame); + + const delta = 1 / 60; + const elapsedTime = INITIAL_TIME_ELAPSED + clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + + rotateElapsed += delta; + + const rotateProgress = ROTATE_PING_PONG + ? Math.sin(rotateElapsed * ROTATE_SPEED) * Math.PI + : rotateElapsed * ROTATE_SPEED; + const axisDirection = ROTATE_AXIS.startsWith('-') ? -1 : 1; + const axisProgress = rotateProgress * axisDirection; + + let rotationX = 0; + let rotationY = 0; + let rotationZ = 0; + + if ( + ROTATE_AXIS === 'x' || + ROTATE_AXIS === 'xy' || + ROTATE_AXIS === '-x' || + ROTATE_AXIS === '-xy' + ) { + rotationX += axisProgress; } - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); + + if ( + ROTATE_AXIS === 'y' || + ROTATE_AXIS === 'xy' || + ROTATE_AXIS === '-y' || + ROTATE_AXIS === '-xy' + ) { + rotationY += axisProgress; + } + + if (ROTATE_AXIS === 'z' || ROTATE_AXIS === '-z') { + rotationZ += axisProgress; + } + + mesh.rotation.set(rotationX, rotationY, rotationZ); + mesh.position.y = MODEL_OFFSET_Y; + mesh.scale.setScalar(1); + + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; + camera.lookAt(0, MODEL_OFFSET_Y * 0.2, 0); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + + renderer.setRenderTarget(sceneTarget); + renderer.clear(); + renderer.render(scene3d, camera); + + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); }; - window.addEventListener('resize', handleResize); + renderFrame(); return () => { cancelled = true; - window.removeEventListener('resize', handleResize); + resizeObserver.disconnect(); window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + mesh.geometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); renderer.dispose(); - dracoLoader.dispose(); if (canvas.parentNode === container) { container.removeChild(canvas); diff --git a/packages/twenty-website-new/src/illustrations/Footer/Background.tsx b/packages/twenty-website-new/src/illustrations/Footer/Background.tsx index 4d65f365b6..8c4d67b19b 100644 --- a/packages/twenty-website-new/src/illustrations/Footer/Background.tsx +++ b/packages/twenty-website-new/src/illustrations/Footer/Background.tsx @@ -1,86 +1,350 @@ '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'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -const GLB_URL = '/illustrations/common/footer/footer.glb'; +const VIRTUAL_RENDER_HEIGHT = 768; -// Framing for the extruded “ZO” hero shot (low angle, centered, room below for links). -const scanlineVertexShader = /* glsl */ ` - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; void main() { - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPosition.xyz; - vWorldNormal = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * viewMatrix * worldPosition; + vUv = uv; + gl_Position = vec4(position, 1.0); } `; -const scanlineFragmentShader = /* glsl */ ` - uniform vec3 uColor; - uniform vec3 uLightDir; - uniform float uStripeScale; +const blurFragmentShader = /* glsl */ ` + precision highp float; - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; void main() { - vec3 normal = normalize(vWorldNormal); - vec3 lightDir = normalize(uLightDir); - float ndotl = max(dot(normal, lightDir), 0.06); + vec4 sum = vec4(0.0); + vec2 px = dir / res; - float y = vWorldPosition.y * uStripeScale; - float cell = fract(y); + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; - float shadowWeight = mix(1.0, 0.5, ndotl); - float lineWidth = 0.58 * shadowWeight; - float edge = 0.035; - float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell); + sum += texture2D(tInput, vUv) * w[0]; - float highlight = pow(ndotl, 1.35); - float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0); - float dashMask = mix( - 1.0, - smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)), - highlight - ); - band *= dashMask; - - float speckle = fract( - sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453 - ); - band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85); - - if (band < 0.015) { - discard; + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; } - vec3 lit = uColor * mix(0.72, 1.18, ndotl); - gl_FragColor = vec4(lit, band); + gl_FragColor = sum; } `; -function createFooterScanlineMaterial(lightDirection: THREE.Vector3) { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#b0b0b0') }, - uLightDir: { value: lightDirection.clone() }, - uStripeScale: { value: 16.0 }, - }, - vertexShader: scanlineVertexShader, - fragmentShader: scanlineFragmentShader, - transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float rowMerge; + uniform float cellRatio; + uniform float cutoff; + uniform float highlightOpen; + uniform float shadowGrouping; + uniform float shadowCrush; + uniform vec3 dashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float distanceScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float dragFlowRadius; + uniform float cropToBounds; + + varying vec2 vUv; + + void main() { + // Crop to image bounds: discard fragments outside source image (image mode only) + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + float baseRowH = resolution.y / (numRows * distanceScale); + vec2 pointerPx = interactionUv * resolution; + vec2 fragDelta = gl_FragCoord.xy - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * resolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * resolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + float dragFlowMask = 0.0; + if (dragFlowStrength > 0.0) { + float dragRadiusPx = dragFlowRadius * resolution.y; + dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; + vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; + vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; + + float densityBoost = + hoverFlowStrength * hoverFlowMask * 0.22 + + dragFlowStrength * dragFlowMask * 0.16; + float rowH = baseRowH / (1.0 + densityBoost); + + float offsetY = effectCoord.y; + float row = floor(offsetY / rowH); + float rowFrac = offsetY / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; + + float localCellRatio = cellRatio * ( + 1.0 + + hoverFlowStrength * hoverFlowMask * 0.08 + + dragFlowStrength * dragFlowMask * 0.1 * motionBias + ); + float cellW = rowH * localCellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; + float lightFocus = hoverLightStrength * hoverLightMask * 0.12; + litLum = clamp(litLum + lightLift, 0.0, 1.0); + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, max(contrast - lightFocus, 0.25)); + + float darkness = 1.0 - litLum; + float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); + float groupedDarkness = 1.0 - groupedLum; + darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); + darkness = clamp( + (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), + 0.0, + 1.0 + ); + + float shadowMask = smoothstep(0.42, 0.96, darkness); + darkness = mix( + darkness, + mix(darkness, 1.0, shadowMask), + shadowCrush + ); + + float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); + float ink = mix(inkBase, 1.0, darkness); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dynamicBarHalfY = min( + dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), + 0.78 + ); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalfY - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float ellipseDist = sqrt( + (cdx * cdx) / max(capRX * capRX, 0.0001) + + (cdy * cdy) / max(capRY * capRY, 0.0001) + ); + inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const GLB_URL = '/illustrations/common/footer/footer.glb'; + +const LIGHT_INTENSITY = 4; +const FILL_LIGHT_INTENSITY = 1.33; +const AMBIENT_LIGHT_INTENSITY = 0.28; +const LIGHT_ANGLE_DEGREES = 167; +const LIGHT_HEIGHT = 2.1; + +const MATERIAL_ROUGHNESS = 0.42; +const MATERIAL_METALNESS = 0.16; + +const HALFTONE_ROWS = 124; +const HALFTONE_CONTRAST = 2.7; +const HALFTONE_POWER = 1; +const HALFTONE_SHADING = 2.9; +const HALFTONE_BASE_INK = 0.19; +const HALFTONE_MAX_BAR = 0.25; +const HALFTONE_CELL_RATIO = 2.2; +const HALFTONE_CUTOFF = 0.02; +const HALFTONE_DASH_COLOR = '#2a2a2a'; + +const DRAG_SENSITIVITY = 0.008; +const DRAG_FRICTION = 0.08; +const DRAG_MOMENTUM = true; +const BREATHE_AMOUNT = 0.04; +const BREATHE_SPEED = 0.8; + +const INITIAL_ROTATION_X = -0.5858908325312259; +const INITIAL_ROTATION_Y = 0.45733435420243096; +const INITIAL_ROTATION_Z = 0; +const INITIAL_TARGET_ROTATION_X = -0.5858908325312265; +const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313; +const INITIAL_TIME_ELAPSED = 287.372499999977; +const BASE_CAMERA_DISTANCE = 4; + +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +type FooterInteractionState = { + 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(): FooterInteractionState { + return { + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerX: 0, + pointerY: 0, + rotationX: INITIAL_ROTATION_X, + rotationY: INITIAL_ROTATION_Y, + rotationZ: INITIAL_ROTATION_Z, + targetRotationX: INITIAL_TARGET_ROTATION_X, + targetRotationY: INITIAL_TARGET_ROTATION_Y, + velocityX: 0, + velocityY: 0, + }; +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, }); } +function disposeMaterial(material: THREE.Material | THREE.Material[]) { + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + return; + } + + material.dispose(); +} + function disposeObjectSubtree(root: THREE.Object3D) { root.traverse((sceneObject) => { if (!(sceneObject instanceof THREE.Mesh)) { @@ -88,40 +352,243 @@ function disposeObjectSubtree(root: THREE.Object3D) { } sceneObject.geometry?.dispose(); - - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } + disposeMaterial(sceneObject.material); }); } -type MeshRestPose = { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - wobblePhase: number; -}; +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 1) { + return geometries[0]; + } -function applyFooterScanlineMaterials( - modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for ( + let vertexIndex = 0; + vertexIndex < geometryInfo.vertexCount; + vertexIndex += 1 + ) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, ) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { + const lightAngle = (angleDegrees * Math.PI) / 180; + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + + return loadingManager; +} + +function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = 2 / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { + root.updateMatrixWorld(true); + + const geometries: THREE.BufferGeometry[] = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { return; } - sceneObject.material = createFooterScanlineMaterial(lightDirection); + const geometry = object.geometry.clone(); - 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.footerVisualRest = rest; + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +async function loadFooterGeometry(modelUrl: string) { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error(`Unable to load footer model from ${modelUrl}.`); + } + + const buffer = await response.arrayBuffer(); + + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return await new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + '', + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + 'Footer model did not contain any mesh geometry.', + ), + ); + } catch (error) { + reject(error); + } finally { + disposeObjectSubtree(gltf.scene); + } + }, + reject, + ); + }); +} + +function createFooterMaterial(environmentTexture: THREE.Texture) { + return new THREE.MeshPhysicalMaterial({ + color: 0xd4d0c8, + roughness: MATERIAL_ROUGHNESS, + metalness: MATERIAL_METALNESS, + envMap: environmentTexture, + envMapIntensity: 0.25, + clearcoat: 0, + clearcoatRoughness: 0.08, + reflectivity: 0.5, + transmission: 0, }); } @@ -152,6 +619,7 @@ export function FooterBackground() { useEffect(() => { const container = mountReference.current; + if (!container) { return; } @@ -159,185 +627,365 @@ export function FooterBackground() { 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(2, 10, 5).normalize(); + const getWidth = () => Math.max(container.clientWidth, 1); + const getHeight = () => Math.max(container.clientHeight, 1); + const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight()); + const getVirtualWidth = () => + Math.max( + Math.round( + getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1)), + ), + 1, + ); - const scene = new THREE.Scene(); - const width = container.clientWidth; - const height = container.clientHeight; - - const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100); - camera.position.set(0, -1.35, 7.4); - camera.lookAt(new THREE.Vector3(0, 0.55, 0)); - - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); + const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }); renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.setPixelRatio(1); + renderer.setClearColor(0x000000, 0); + renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); + const canvas = renderer.domElement; - canvas.style.cursor = 'pointer'; + canvas.style.cursor = 'grab'; canvas.style.display = 'block'; canvas.style.height = '100%'; canvas.style.touchAction = 'none'; canvas.style.width = '100%'; container.appendChild(canvas); - const pivot = new THREE.Group(); - scene.add(pivot); + const environmentTexture = createEnvironmentTexture(renderer); + const scene3d = new THREE.Scene(); + scene3d.background = null; - const clock = new THREE.Clock(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath( - 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/', + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, ); + camera.position.z = BASE_CAMERA_DISTANCE; - const loader = new GLTFLoader(); - loader.setDRACOLoader(dracoLoader); + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHT_INTENSITY); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + scene3d.add(primaryLight); - loader.load( - GLB_URL, - (gltf) => { - if (cancelled) { - disposeObjectSubtree(gltf.scene); - return; - } + const fillLight = new THREE.DirectionalLight( + 0xffffff, + FILL_LIGHT_INTENSITY, + ); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); - 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 = 5 / maxAxis; + const ambientLight = new THREE.AmbientLight( + 0xffffff, + AMBIENT_LIGHT_INTENSITY, + ); + scene3d.add(ambientLight); - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - modelRoot.position.y += 0.42; + const material = createFooterMaterial(environmentTexture); + const mesh = new THREE.Mesh(new THREE.BufferGeometry(), material); + mesh.visible = false; + scene3d.add(mesh); - applyFooterScanlineMaterials(modelRoot, lightDirectionWorld); - pivot.add(modelRoot); + 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 renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - - const rotationDamp = 6.8; - const influence = pointer.inside ? 1 : 0.38; - targetRotation.y = pointer.x * 0.78 * influence; - targetRotation.x = pointer.y * 0.62 * influence; - - pivot.rotation.y = THREE.MathUtils.damp( - pivot.rotation.y, - targetRotation.y, - rotationDamp, - delta, - ); - pivot.rotation.x = THREE.MathUtils.damp( - pivot.rotation.x, - targetRotation.x, - rotationDamp, - delta, - ); - - const hoverLift = pointer.inside ? 1 : 0; - pivot.scale.setScalar( - THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta), - ); - - const mx = pointer.x * (pointer.inside ? 1 : 0.32); - const my = pointer.y * (pointer.inside ? 1 : 0.32); - - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const rest = sceneObject.userData.footerVisualRest as - | MeshRestPose - | undefined; - if (!rest) { - return; - } - - const phase = rest.wobblePhase; - const wobble = pointer.inside ? 1 : 0.36; - sceneObject.position.x = - rest.position.x + mx * 0.22 * Math.sin(phase * 1.8); - sceneObject.position.z = - rest.position.z + my * 0.19 * Math.cos(phase * 1.4); - sceneObject.position.y = - rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5); - - const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase); - sceneObject.quaternion.copy(rest.quaternion); - sceneObject.rotateY(twist); - }); - - renderer.render(scene, camera); - }; - - renderFrame(); + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, }, - undefined, - undefined, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + numRows: { value: HALFTONE_ROWS }, + glowStr: { value: 0 }, + contrast: { value: HALFTONE_CONTRAST }, + power: { value: HALFTONE_POWER }, + shading: { value: HALFTONE_SHADING }, + baseInk: { value: HALFTONE_BASE_INK }, + maxBar: { value: HALFTONE_MAX_BAR }, + cellRatio: { value: HALFTONE_CELL_RATIO }, + cutoff: { value: HALFTONE_CUTOFF }, + dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + distanceScale: { value: 1 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + dragFlowRadius: { value: 0.24 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), ); - const setPointerFromEvent = (event: PointerEvent) => { + 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 updatePointerPosition = (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 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 handlePointerEnter = () => { - pointer.inside = true; - }; - - const handlePointerLeave = () => { - pointer.inside = false; - pointer.x = 0; - pointer.y = 0; + const handlePointerDown = (event: PointerEvent) => { + updatePointerPosition(event); + interaction.dragging = false; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + interaction.velocityX = 0; + interaction.velocityY = 0; + canvas.style.cursor = 'default'; }; const handlePointerMove = (event: PointerEvent) => { - setPointerFromEvent(event); + updatePointerPosition(event); }; - canvas.addEventListener('pointerenter', handlePointerEnter); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointermove', handlePointerMove); + const handleWindowPointerMove = (event: PointerEvent) => { + updatePointerPosition(event); - const handleResize = () => { - if (!mountReference.current || cancelled) { + if (!interaction.dragging) { return; } - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); + const deltaX = (event.clientX - interaction.pointerX) * DRAG_SENSITIVITY; + const deltaY = (event.clientY - interaction.pointerY) * DRAG_SENSITIVITY; + interaction.velocityX = deltaY; + interaction.velocityY = deltaX; + interaction.targetRotationY += deltaX; + interaction.targetRotationX += deltaY; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; }; - window.addEventListener('resize', handleResize); + const handlePointerLeave = () => { + if (interaction.dragging) { + return; + } + + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + }; + + const handlePointerUp = () => { + interaction.dragging = false; + canvas.style.cursor = 'grab'; + }; + + const handleWindowBlur = () => { + handlePointerUp(); + handlePointerLeave(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointermove', handleWindowPointerMove); + window.addEventListener('blur', handleWindowBlur); + + loadFooterGeometry(GLB_URL) + .then((geometry) => { + if (cancelled) { + geometry.dispose(); + return; + } + + mesh.geometry.dispose(); + mesh.geometry = geometry; + mesh.visible = true; + }) + .catch((error) => { + console.error(error); + }); + + const clock = new THREE.Clock(); + + const renderFrame = () => { + if (cancelled) { + return; + } + + animationFrameId = window.requestAnimationFrame(renderFrame); + + const elapsedTime = INITIAL_TIME_ELAPSED + clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.mouseX, + 1 - interaction.mouseY, + ); + + if (!interaction.dragging && DRAG_MOMENTUM) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= 1 - DRAG_FRICTION; + interaction.velocityY *= 1 - DRAG_FRICTION; + } + + interaction.rotationX += + (interaction.targetRotationX - interaction.rotationX) * DRAG_FRICTION; + interaction.rotationY += + (interaction.targetRotationY - interaction.rotationY) * DRAG_FRICTION; + interaction.rotationZ += (0 - interaction.rotationZ) * 0.12; + + const meshScale = + 1 + Math.sin(elapsedTime * BREATHE_SPEED) * BREATHE_AMOUNT; + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, + ); + mesh.position.y = 0; + mesh.scale.setScalar(meshScale); + + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (BASE_CAMERA_DISTANCE - camera.position.z) * 0.12; + camera.lookAt(0, 0, 0); + setPrimaryLightPosition(primaryLight, LIGHT_ANGLE_DEGREES, LIGHT_HEIGHT); + + renderer.setRenderTarget(sceneTarget); + renderer.clear(); + renderer.render(scene3d, camera); + + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + }; + + renderFrame(); return () => { cancelled = true; - window.removeEventListener('resize', handleResize); - canvas.removeEventListener('pointerenter', handlePointerEnter); - canvas.removeEventListener('pointerleave', handlePointerLeave); + resizeObserver.disconnect(); canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointermove', handleWindowPointerMove); + window.removeEventListener('blur', handleWindowBlur); window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + mesh.geometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); renderer.dispose(); - dracoLoader.dispose(); if (canvas.parentNode === container) { container.removeChild(canvas); diff --git a/packages/twenty-website-new/src/illustrations/Testimonials/Hourglass.tsx b/packages/twenty-website-new/src/illustrations/Testimonials/Hourglass.tsx index 788a25c518..d03e4558fb 100644 --- a/packages/twenty-website-new/src/illustrations/Testimonials/Hourglass.tsx +++ b/packages/twenty-website-new/src/illustrations/Testimonials/Hourglass.tsx @@ -1,129 +1,479 @@ 'use client'; -import { theme } from '@/theme'; -import { styled } from '@linaria/react'; -import { useLayoutEffect, useRef } from 'react'; -import * as THREE from 'three'; +import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas'; +import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { theme } from '@/theme'; +import { styled } from '@linaria/react'; +import { useEffect, useState } from 'react'; +import * as THREE from 'three'; + +type HalftoneSourceMode = 'shape' | 'image'; +type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; +type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; + +interface HalftoneLightingSettings { + intensity: number; + fillIntensity: number; + ambientIntensity: number; + angleDegrees: number; + height: number; +} + +interface HalftoneMaterialSettings { + roughness: number; + metalness: number; +} + +interface HalftoneEffectSettings { + enabled: boolean; + numRows: number; + contrast: number; + power: number; + shading: number; + baseInk: number; + maxBar: number; + rowMerge: number; + cellRatio: number; + cutoff: number; + highlightOpen: number; + shadowGrouping: number; + shadowCrush: number; + dashColor: string; +} + +interface HalftoneBackgroundSettings { + transparent: boolean; + color: string; +} + +interface HalftoneAnimationSettings { + autoRotateEnabled: boolean; + breatheEnabled: boolean; + cameraParallaxEnabled: boolean; + followHoverEnabled: boolean; + followDragEnabled: boolean; + floatEnabled: boolean; + hoverLightEnabled: boolean; + dragFlowEnabled: boolean; + lightSweepEnabled: boolean; + rotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + breatheAmount: number; + breatheSpeed: number; + cameraParallaxAmount: number; + cameraParallaxEase: number; + driftAmount: number; + hoverRange: number; + hoverEase: number; + hoverReturn: boolean; + dragSens: number; + dragFriction: number; + dragMomentum: boolean; + rotateAxis: HalftoneRotateAxis; + rotatePreset: HalftoneRotatePreset; + rotateSpeed: number; + rotatePingPong: boolean; + floatAmplitude: number; + floatSpeed: number; + lightSweepHeightRange: number; + lightSweepRange: number; + lightSweepSpeed: number; + springDamping: number; + springReturnEnabled: boolean; + springStrength: number; + hoverLightIntensity: number; + hoverLightRadius: number; + dragFlowDecay: number; + dragFlowRadius: number; + dragFlowStrength: number; + hoverWarpStrength: number; + hoverWarpRadius: number; + dragWarpStrength: number; + waveEnabled: boolean; + waveSpeed: number; + waveAmount: number; +} + +interface HalftoneExportPose { + autoElapsed: number; + rotateElapsed: number; + rotationX: number; + rotationY: number; + rotationZ: number; + targetRotationX: number; + targetRotationY: number; + timeElapsed: number; +} + +interface HalftoneStudioSettings { + sourceMode: HalftoneSourceMode; + shapeKey: string; + lighting: HalftoneLightingSettings; + material: HalftoneMaterialSettings; + halftone: HalftoneEffectSettings; + background: HalftoneBackgroundSettings; + animation: HalftoneAnimationSettings; +} + +type HalftoneModelLoader = 'fbx' | 'glb'; + +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for ( + let vertexIndex = 0; + vertexIndex < geometryInfo.vertexCount; + vertexIndex += 1 + ) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for ( + let localIndex = 0; + localIndex < geometryInfo.indexCount; + localIndex += 1 + ) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const EMPTY_TEXTURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; + +function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { + geometry.computeBoundingBox(); + + let boundingBox = geometry.boundingBox; + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + + boundingBox?.getCenter(center); + boundingBox?.getSize(size); + geometry.translate(-center.x, -center.y, -center.z); + + const dimensions = [size.x, size.y, size.z]; + const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); + + if (thinnestAxis === 0) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); + } else if (thinnestAxis === 1) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + } + + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const radius = geometry.boundingSphere?.radius || 1; + const scale = 1.6 / radius; + geometry.scale(scale, scale, scale); + + geometry.computeBoundingBox(); + boundingBox = geometry.boundingBox; + center = new THREE.Vector3(); + boundingBox?.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function createLoadingManager() { + const loadingManager = new THREE.LoadingManager(); + loadingManager.setURLModifier((url) => + /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, + ); + + return loadingManager; +} + +function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { + root.updateMatrixWorld(true); + + const geometries: THREE.BufferGeometry[] = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +function parseFbxGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, +) { + const originalWarn = console.warn; + + console.warn = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse( + buffer, + resourcePath, + ); + + return extractMergedGeometry( + root, + `${label} did not contain any mesh geometry.`, + ); + } finally { + console.warn = originalWarn; + } +} + +function parseGlbGeometry( + buffer: ArrayBuffer, + resourcePath: string, + label: string, +) { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + resourcePath, + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + `${label} did not contain any mesh geometry.`, + ), + ); + } catch (error) { + reject(error); + } + }, + reject, + ); + }).finally(() => { + dracoLoader.dispose(); + }); +} + +async function loadImportedGeometry( + loader: HalftoneModelLoader, + file: File, + label: string, +) { + const buffer = await file.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, '', label); + } + + return parseGlbGeometry(buffer, '', label); +} const GLB_URL = '/illustrations/home/testimonials/hourglass.glb'; +const HOURGLASS_PREVIEW_DISTANCE = 4; -const scanlineVertexShader = /* glsl */ ` - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; - - void main() { - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPosition.xyz; - vWorldNormal = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * viewMatrix * worldPosition; - } -`; - -const scanlineFragmentShader = /* glsl */ ` - uniform vec3 uColor; - uniform vec3 uLightDir; - uniform float uStripeScale; - - varying vec3 vWorldPosition; - varying vec3 vWorldNormal; - - void main() { - vec3 normal = normalize(vWorldNormal); - vec3 lightDir = normalize(uLightDir); - float ndotl = max(dot(normal, lightDir), 0.06); - - float y = vWorldPosition.y * uStripeScale; - float cell = fract(y); - - float shadowWeight = mix(1.0, 0.5, ndotl); - float lineWidth = 0.58 * shadowWeight; - float edge = 0.035; - float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell); - - float highlight = pow(ndotl, 1.35); - float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0); - float dashMask = mix( - 1.0, - smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)), - highlight - ); - band *= dashMask; - - float speckle = fract( - sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453 - ); - band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85); - - if (band < 0.015) { - discard; - } - - vec3 lit = uColor * mix(0.72, 1.18, ndotl); - gl_FragColor = vec4(lit, band); - } -`; - -function createScanlineMaterial(lightDirection: THREE.Vector3) { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#1e5bff') }, - uLightDir: { value: lightDirection.clone() }, - uStripeScale: { value: 16.0 }, - }, - vertexShader: scanlineVertexShader, - fragmentShader: scanlineFragmentShader, +const HOURGLASS_SETTINGS: HalftoneStudioSettings = { + sourceMode: 'shape', + shapeKey: 'hourglass', + lighting: { + intensity: 3.4, + fillIntensity: 1.5, + ambientIntensity: 0.06, + angleDegrees: 260, + height: 0.8, + }, + material: { + roughness: 0.59, + metalness: 0.02, + }, + halftone: { + enabled: true, + numRows: 150, + contrast: 1.2, + power: 1.1, + shading: 3, + baseInk: 0.19, + maxBar: 0.35, + rowMerge: 0.08, + cellRatio: 2.2, + cutoff: 0.02, + highlightOpen: 0.04, + shadowGrouping: 0.18, + shadowCrush: 0.14, + dashColor: '#4A38F5', + }, + background: { transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, - }); -} - -function disposeObjectSubtree(root: THREE.Object3D) { - root.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - sceneObject.geometry?.dispose(); - - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } - }); -} - -type MeshRestPose = { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - wobblePhase: number; + color: 'transparent', + }, + animation: { + autoRotateEnabled: true, + breatheEnabled: false, + cameraParallaxEnabled: false, + followHoverEnabled: false, + followDragEnabled: true, + floatEnabled: false, + hoverLightEnabled: false, + dragFlowEnabled: false, + lightSweepEnabled: false, + rotateEnabled: false, + autoSpeed: 0.1, + autoWobble: 0.05, + breatheAmount: 0.04, + breatheSpeed: 0.8, + cameraParallaxAmount: 0.3, + cameraParallaxEase: 0.08, + driftAmount: 8, + hoverRange: 25, + hoverEase: 0.08, + hoverReturn: true, + dragSens: 0.003, + dragFriction: 0.02, + dragMomentum: true, + rotateAxis: 'y', + rotatePreset: 'axis', + rotateSpeed: 1, + rotatePingPong: false, + floatAmplitude: 0.16, + floatSpeed: 0.8, + lightSweepHeightRange: 0.7, + lightSweepRange: 29, + lightSweepSpeed: 0.2, + springDamping: 0.72, + springReturnEnabled: false, + springStrength: 0.18, + hoverLightIntensity: 0.8, + hoverLightRadius: 0.2, + dragFlowDecay: 0.08, + dragFlowRadius: 0.24, + dragFlowStrength: 1.8, + hoverWarpStrength: 3, + hoverWarpRadius: 0.15, + dragWarpStrength: 5, + waveEnabled: false, + waveSpeed: 1, + waveAmount: 2, + }, }; -function applyScanlineMaterials( - modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, -) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } +const HOURGLASS_INITIAL_POSE: HalftoneExportPose = { + autoElapsed: 51.68333333333168, + rotateElapsed: 0, + rotationX: 3.5421542652497933, + rotationY: 10.105974316283143, + rotationZ: 0, + targetRotationX: 3.575782604433917, + targetRotationY: 5.019307649616612, + timeElapsed: 411.9648000000736, +}; - sceneObject.material = createScanlineMaterial(lightDirection); - - const mesh = sceneObject; - const rest: MeshRestPose = { - position: mesh.position.clone(), - quaternion: mesh.quaternion.clone(), - wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7, - }; - mesh.userData.hourglassVisualRest = rest; - }); -} +const NO_OP = () => {}; const VisualFrame = styled.div` background-color: transparent; @@ -139,218 +489,61 @@ const VisualFrame = styled.div` } `; -const CanvasMount = styled.div` - display: block; - height: 100%; - inset: 0; - min-width: 0; - position: absolute; - width: 100%; -`; - export function Hourglass() { - const mountReference = useRef(null); - - useLayoutEffect(() => { - const container = mountReference.current; - if (!container) { - return; - } + const [geometry, setGeometry] = useState(null); + useEffect(() => { let cancelled = false; - let animationFrameId = 0; + let loadedGeometry: THREE.BufferGeometry | null = null; - const pointer = { x: 0, y: 0, inside: false }; - const targetRotation = { x: 0, y: 0 }; - const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize(); + void fetch(GLB_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error('Could not load the hourglass model.'); + } - const scene = new THREE.Scene(); - const width = container.clientWidth; - const height = container.clientHeight; + const blob = await response.blob(); - const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); - camera.position.set(0, 0, 5.05); - - const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); - renderer.outputColorSpace = THREE.SRGBColorSpace; - const canvas = renderer.domElement; - canvas.style.cursor = 'pointer'; - canvas.style.display = 'block'; - canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; - canvas.style.width = '100%'; - container.appendChild(canvas); - - const pivot = new THREE.Group(); - scene.add(pivot); - - const clock = new THREE.Clock(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath( - 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/', - ); - - const loader = new GLTFLoader(); - loader.setDRACOLoader(dracoLoader); - - loader.load( - GLB_URL, - (gltf) => { + return new File([blob], 'hourglass.glb', { + type: blob.type || 'model/gltf-binary', + }); + }) + .then((file) => loadImportedGeometry('glb', file, 'hourglass.glb')) + .then((nextGeometry) => { if (cancelled) { - disposeObjectSubtree(gltf.scene); + nextGeometry.dispose(); return; } - const modelRoot = gltf.scene; - const bounds = new THREE.Box3().setFromObject(modelRoot); - const center = bounds.getCenter(new THREE.Vector3()); - const size = bounds.getSize(new THREE.Vector3()); - const maxAxis = Math.max(size.x, size.y, size.z, 0.001); - const scale = 2.85 / maxAxis; - - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - - applyScanlineMaterials(modelRoot, lightDirectionWorld); - pivot.add(modelRoot); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - - const rotationDamp = 6.8; - const influence = pointer.inside ? 1 : 0.38; - targetRotation.y = pointer.x * 0.78 * influence; - targetRotation.x = pointer.y * 0.62 * influence; - - pivot.rotation.y = THREE.MathUtils.damp( - pivot.rotation.y, - targetRotation.y, - rotationDamp, - delta, - ); - pivot.rotation.x = THREE.MathUtils.damp( - pivot.rotation.x, - targetRotation.x, - rotationDamp, - delta, - ); - - const hoverLift = pointer.inside ? 1 : 0; - pivot.scale.setScalar( - THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta), - ); - - const mx = pointer.x * (pointer.inside ? 1 : 0.32); - const my = pointer.y * (pointer.inside ? 1 : 0.32); - - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const rest = sceneObject.userData.hourglassVisualRest as - | MeshRestPose - | undefined; - if (!rest) { - return; - } - - const phase = rest.wobblePhase; - const wobble = pointer.inside ? 1 : 0.36; - sceneObject.position.x = - rest.position.x + mx * 0.22 * Math.sin(phase * 1.8); - sceneObject.position.z = - rest.position.z + my * 0.19 * Math.cos(phase * 1.4); - sceneObject.position.y = - rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5); - - const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase); - sceneObject.quaternion.copy(rest.quaternion); - sceneObject.rotateY(twist); - }); - - renderer.render(scene, camera); - }; - - renderFrame(); - }, - undefined, - undefined, - ); - - const setPointerFromEvent = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1); - pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1); - pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1); - }; - - const handlePointerEnter = () => { - pointer.inside = true; - }; - - const handlePointerLeave = () => { - pointer.inside = false; - pointer.x = 0; - pointer.y = 0; - }; - - const handlePointerMove = (event: PointerEvent) => { - setPointerFromEvent(event); - }; - - canvas.addEventListener('pointerenter', handlePointerEnter); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointermove', handlePointerMove); - - const handleResize = () => { - if (!mountReference.current || cancelled) { - return; - } - - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - if (nextWidth < 1 || nextHeight < 1) { - return; - } - - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); - }; - - window.addEventListener('resize', handleResize); + loadedGeometry = nextGeometry; + setGeometry(nextGeometry); + }) + .catch((error) => { + console.error(error); + }); return () => { cancelled = true; - window.removeEventListener('resize', handleResize); - canvas.removeEventListener('pointerenter', handlePointerEnter); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointermove', handlePointerMove); - window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); - renderer.dispose(); - dracoLoader.dispose(); - if (canvas.parentNode === container) { - container.removeChild(canvas); + if (loadedGeometry) { + loadedGeometry.dispose(); } }; }, []); return ( - + {geometry ? ( + + ) : null} ); } diff --git a/packages/twenty-website-new/src/illustrations/ThreeCards/Diamond.tsx b/packages/twenty-website-new/src/illustrations/ThreeCards/Diamond.tsx index dfdf990a28..0b23f2da39 100644 --- a/packages/twenty-website-new/src/illustrations/ThreeCards/Diamond.tsx +++ b/packages/twenty-website-new/src/illustrations/ThreeCards/Diamond.tsx @@ -1,92 +1,383 @@ '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'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -const GLB_URL = '/illustrations/home/three-cards/diamond.glb'; +const VIRTUAL_RENDER_HEIGHT = 768; -const halftoneVertexShader = /* glsl */ ` - varying vec3 vWorldNormal; +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; void main() { - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldNormal = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * viewMatrix * worldPosition; + 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 */ ` - uniform vec3 uColor; - uniform vec3 uLightDir; - uniform vec2 uResolution; - uniform float uNumRows; + precision highp float; - varying vec3 vWorldNormal; + 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() { - vec3 normal = normalize(vWorldNormal); - vec3 lightDir = normalize(uLightDir); - float ndotl = max(dot(normal, lightDir), 0.0); - float lum = mix(0.35, 1.0, ndotl); + // 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 rowH = uResolution.y / uNumRows; - float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH); + 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 cellW = rowH * 2.2; - float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW; + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; - float fill = pow(lum, 0.45) * 0.95; + 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; - float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum)); + 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 capR = dynamicBarHalf * rowH; + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; float inDash = 0.0; if (dx2 <= bodyHalfW) { - float edgeDist = dynamicBarHalf - dy; + float edgeDist = dynamicBarHalfY - 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); + 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); } - if (inDash < 0.01) { - discard; - } + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); - gl_FragColor = vec4(uColor, inDash); + 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 + #include } `; -function createHalftoneDashMaterial( - lightDirection: THREE.Vector3, - resolution: THREE.Vector2, -) { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#1e5bff') }, - uLightDir: { value: lightDirection.clone() }, - uResolution: { value: resolution.clone() }, - uNumRows: { value: 65 }, - }, - vertexShader: halftoneVertexShader, - fragmentShader: halftoneFragmentShader, - transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, +const GLB_URL = '/illustrations/home/three-cards/diamond.glb'; + +const LIGHTING = { + ambientIntensity: 0.08, + angleDegrees: 45, + fillIntensity: 0.15, + height: 2, + intensity: 1.5, +}; + +const MATERIAL = { + metalness: 0.16, + roughness: 0.42, +}; + +const HALFTONE = { + baseInk: 0.16, + cellRatio: 2.2, + contrast: 1.3, + cutoff: 0.02, + dashColor: '#4A38F5', + maxBar: 0.24, + numRows: 45, + power: 1.1, + shading: 1.6, +}; + +const ANIMATION: { + autoRotateEnabled: boolean; + autoSpeed: number; + autoWobble: number; + dragSens: number; + followDragEnabled: boolean; + followHoverEnabled: boolean; + hoverEase: number; + hoverRange: number; + hoverReturn: boolean; + rotateAxis: 'x' | 'y' | 'z' | 'xy'; + rotateEnabled: boolean; + rotatePingPong: boolean; + rotateSpeed: number; +} = { + autoRotateEnabled: true, + autoSpeed: 0.3, + autoWobble: 0.3, + dragSens: 0.008, + followDragEnabled: false, + followHoverEnabled: false, + hoverEase: 0.08, + hoverRange: 25, + hoverReturn: true, + rotateAxis: 'y', + rotateEnabled: false, + rotatePingPong: false, + rotateSpeed: 1, +}; + +const INITIAL_POSE = { + autoElapsed: 16.98333333333365, + rotateElapsed: 0, + rotationX: -0.06446908045975865, + rotationY: 5.037500000000101, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, +}; + +const BASE_CAMERA_DISTANCE = 5.05; +const MODEL_SCALE_TARGET = 2.75; + +function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +type DiamondInteractionState = { + 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; +}; + +function createInteractionState(): DiamondInteractionState { + return { + autoElapsed: INITIAL_POSE.autoElapsed, + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerX: 0, + pointerY: 0, + rotateElapsed: INITIAL_POSE.rotateElapsed, + rotationX: INITIAL_POSE.rotationX, + rotationY: INITIAL_POSE.rotationY, + rotationZ: INITIAL_POSE.rotationZ, + targetRotationX: INITIAL_POSE.targetRotationX, + targetRotationY: INITIAL_POSE.targetRotationY, + velocityX: 0, + velocityY: 0, + }; +} + +function createRenderTarget(width: number, height: number) { + return new THREE.WebGLRenderTarget(width, height, { + format: THREE.RGBAFormat, + magFilter: THREE.LinearFilter, + minFilter: THREE.LinearFilter, }); } +function disposeMaterial(material: THREE.Material | THREE.Material[]) { + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + return; + } + + material.dispose(); +} + function disposeObjectSubtree(root: THREE.Object3D) { root.traverse((sceneObject) => { if (!(sceneObject instanceof THREE.Mesh)) { @@ -94,44 +385,54 @@ function disposeObjectSubtree(root: THREE.Object3D) { } sceneObject.geometry?.dispose(); - - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); - } + disposeMaterial(sceneObject.material); }); } -type MeshRestPose = { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - wobblePhase: number; -}; +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, +) { + const lightAngle = THREE.MathUtils.degToRad(angleDegrees); + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} -function applyHalftoneDashMaterials( +function normalizeModelRoot(modelRoot: THREE.Object3D) { + 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); + + modelRoot.position.sub(center); + modelRoot.scale.setScalar(MODEL_SCALE_TARGET / maxAxis); +} + +function applyPhysicalMaterial( modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, - resolution: THREE.Vector2, + environmentTexture: THREE.Texture, ) { modelRoot.traverse((sceneObject) => { if (!(sceneObject instanceof THREE.Mesh)) { return; } - sceneObject.material = createHalftoneDashMaterial( - lightDirection, - resolution, - ); - - 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.diamondMeshRest = rest; + disposeMaterial(sceneObject.material); + sceneObject.material = new THREE.MeshPhysicalMaterial({ + clearcoat: 0, + clearcoatRoughness: 0.08, + color: 0xd4d0c8, + envMap: environmentTexture, + envMapIntensity: 0.25, + metalness: MATERIAL.metalness, + reflectivity: 0.5, + roughness: MATERIAL.roughness, + transmission: 0, + }); }); } @@ -147,42 +448,339 @@ export function Diamond() { useEffect(() => { const container = mountReference.current; + if (!container) { return; } - let cancelled = false; + 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 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); + let cancelled = false; const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false }); - renderer.setPixelRatio(1); - renderer.setSize(width, height); - renderer.setClearColor(0x000000, 0); 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%'; - canvas.style.cursor = 'pointer'; 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 = BASE_CAMERA_DISTANCE; + + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHTING.intensity); + setPrimaryLightPosition(primaryLight, LIGHTING.angleDegrees, LIGHTING.height); + 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 pivot = new THREE.Group(); - scene.add(pivot); + scene3d.add(pivot); + + 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 }, + }, + fragmentShader: blurFragmentShader, + vertexShader: passThroughVertexShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + dir: { value: new THREE.Vector2(0, 1) }, + res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, + tInput: { value: null }, + }, + fragmentShader: blurFragmentShader, + vertexShader: passThroughVertexShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + baseInk: { value: HALFTONE.baseInk }, + cellRatio: { value: HALFTONE.cellRatio }, + contrast: { value: HALFTONE.contrast }, + cropToBounds: { value: 0 }, + cutoff: { value: HALFTONE.cutoff }, + dashColor: { value: new THREE.Color(HALFTONE.dashColor) }, + distanceScale: { value: 1 }, + dragFlowRadius: { value: 0 }, + dragFlowStrength: { value: 0 }, + dragOffset: { value: new THREE.Vector2(0, 0) }, + glowStr: { value: 0 }, + hoverFlowRadius: { value: 0 }, + hoverFlowStrength: { value: 0 }, + hoverLightRadius: { value: 0 }, + hoverLightStrength: { value: 0 }, + interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, + interactionVelocity: { value: new THREE.Vector2(0, 0) }, + maxBar: { value: HALFTONE.maxBar }, + numRows: { value: HALFTONE.numRows }, + power: { value: HALFTONE.power }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + shading: { value: HALFTONE.shading }, + tGlow: { value: blurTargetB.texture }, + tScene: { value: sceneTarget.texture }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 0 }, + }, + fragmentShader: halftoneFragmentShader, + vertexShader: passThroughVertexShader, + }); + + 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; + + if ( + !interaction.dragging || + (!ANIMATION.followDragEnabled && !ANIMATION.autoRotateEnabled) + ) { + return; + } + + const deltaX = (event.clientX - interaction.pointerX) * ANIMATION.dragSens; + const deltaY = (event.clientY - interaction.pointerY) * 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 = () => { + if (cancelled) { + return; + } + + animationFrameId = window.requestAnimationFrame(renderFrame); + + const delta = 1 / 60; + const elapsedTime = clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + + let baseRotationX = 0; + let baseRotationY = 0; + let baseRotationZ = 0; + + if (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 * ANIMATION.autoSpeed; + baseRotationX += + Math.sin(interaction.autoElapsed * 0.2) * ANIMATION.autoWobble; + } + + if (ANIMATION.rotateEnabled) { + interaction.rotateElapsed += delta; + const rotateAngle = ANIMATION.rotatePingPong + ? Math.sin(interaction.rotateElapsed * ANIMATION.rotateSpeed) * Math.PI + : interaction.rotateElapsed * ANIMATION.rotateSpeed; + + if ( + ANIMATION.rotateAxis === 'x' || + ANIMATION.rotateAxis === 'xy' + ) { + baseRotationX += rotateAngle; + } + + if ( + ANIMATION.rotateAxis === 'y' || + ANIMATION.rotateAxis === 'xy' + ) { + baseRotationY += rotateAngle; + } + + if (ANIMATION.rotateAxis === 'z') { + baseRotationZ = rotateAngle; + } + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (ANIMATION.followHoverEnabled) { + const rangeRadians = THREE.MathUtils.degToRad(ANIMATION.hoverRange); + + if ( + ANIMATION.hoverReturn || + interaction.mouseX !== 0.5 || + interaction.mouseY !== 0.5 + ) { + targetX += (interaction.mouseY - 0.5) * rangeRadians; + targetY += (interaction.mouseX - 0.5) * rangeRadians; + } + + easing = ANIMATION.hoverEase; + } + + if (ANIMATION.followDragEnabled) { + targetX += interaction.targetRotationX; + targetY += interaction.targetRotationY; + easing = 0.08; + } + + if ( + ANIMATION.autoRotateEnabled && + !ANIMATION.followHoverEnabled && + !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) * 0.12; + + pivot.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(); + const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath( 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/', @@ -200,159 +798,34 @@ export function Diamond() { } 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 / maxAxis; - - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - - const canvasResolution = new THREE.Vector2( - renderer.domElement.width, - renderer.domElement.height, - ); - applyHalftoneDashMaterials( - modelRoot, - lightDirectionWorld, - canvasResolution, - ); + normalizeModelRoot(modelRoot); + applyPhysicalMaterial(modelRoot, environmentTexture); pivot.add(modelRoot); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - - const rotationDamp = 6.8; - const influence = pointer.inside ? 1 : 0.38; - targetRotation.y = pointer.x * 0.78 * influence; - targetRotation.x = pointer.y * 0.62 * influence; - - pivot.rotation.y = THREE.MathUtils.damp( - pivot.rotation.y, - targetRotation.y, - rotationDamp, - delta, - ); - pivot.rotation.x = THREE.MathUtils.damp( - pivot.rotation.x, - targetRotation.x, - rotationDamp, - delta, - ); - - const hoverLift = pointer.inside ? 1 : 0; - pivot.scale.setScalar( - THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta), - ); - - const mx = pointer.x * (pointer.inside ? 1 : 0.32); - const my = pointer.y * (pointer.inside ? 1 : 0.32); - - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const rest = sceneObject.userData.diamondMeshRest as - | MeshRestPose - | undefined; - if (!rest) { - return; - } - - const phase = rest.wobblePhase; - const wobble = pointer.inside ? 1 : 0.36; - sceneObject.position.x = - rest.position.x + mx * 0.22 * Math.sin(phase * 1.8); - sceneObject.position.z = - rest.position.z + my * 0.19 * Math.cos(phase * 1.4); - sceneObject.position.y = - rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5); - - const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase); - sceneObject.quaternion.copy(rest.quaternion); - sceneObject.rotateY(twist); - }); - - renderer.render(scene, camera); - }; - - renderFrame(); }, undefined, - undefined, - ); - - const setPointerFromEvent = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1); - pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1); - pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1); - }; - - const handlePointerEnter = () => { - pointer.inside = true; - }; - - const handlePointerLeave = () => { - pointer.inside = false; - pointer.x = 0; - pointer.y = 0; - }; - - const handlePointerMove = (event: PointerEvent) => { - setPointerFromEvent(event); - }; - - canvas.addEventListener('pointerenter', handlePointerEnter); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointermove', handlePointerMove); - - const handleResize = () => { - if (!mountReference.current || cancelled) { - return; - } - - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - if (nextWidth < 1 || nextHeight < 1) { - return; - } - - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); - - const rw = renderer.domElement.width; - const rh = renderer.domElement.height; - pivot.traverse((sceneObject) => { - if ( - sceneObject instanceof THREE.Mesh && - sceneObject.material instanceof THREE.ShaderMaterial && - sceneObject.material.uniforms.uResolution - ) { - sceneObject.material.uniforms.uResolution.value.set(rw, rh); + (error) => { + if (!cancelled) { + console.error(error); } - }); - }; - - window.addEventListener('resize', handleResize); + }, + ); return () => { cancelled = true; - window.removeEventListener('resize', handleResize); - canvas.removeEventListener('pointerenter', handlePointerEnter); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointermove', handlePointerMove); + resizeObserver.disconnect(); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + canvas.removeEventListener('pointerdown', handlePointerDown); window.cancelAnimationFrame(animationFrameId); - disposeObjectSubtree(scene); + disposeObjectSubtree(scene3d); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); renderer.dispose(); dracoLoader.dispose(); diff --git a/packages/twenty-website-new/src/illustrations/ThreeCards/Flash.tsx b/packages/twenty-website-new/src/illustrations/ThreeCards/Flash.tsx index ae8370c902..88bd7a1b84 100644 --- a/packages/twenty-website-new/src/illustrations/ThreeCards/Flash.tsx +++ b/packages/twenty-website-new/src/illustrations/ThreeCards/Flash.tsx @@ -3,46 +3,167 @@ 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 GLB_URL = '/illustrations/home/three-cards/flash.glb'; +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; -const halftoneVertexShader = /* glsl */ ` - varying vec3 vWorldNormal; +const GLB_URL = '/illustrations/home/three-cards/flash.glb'; +const VIRTUAL_RENDER_HEIGHT = 768; + +const LIGHTING = { + intensity: 4, + fillIntensity: 1.31, + ambientIntensity: 0.1, + angleDegrees: 25, + height: 2, +}; + +const MATERIAL = { + roughness: 0.42, + metalness: 0.16, +}; + +const HALFTONE = { + numRows: 48, + contrast: 2.7, + power: 1.1, + shading: 3, + baseInk: 0.19, + maxBar: 0.23, + cellRatio: 2, + cutoff: 0.02, + dashColor: '#4A38F5', + waveAmount: 0, + waveSpeed: 0, +}; + +const ANIMATION = { + autoSpeed: 0.1, + autoWobble: 0.3, + dragFriction: 0.08, + dragMomentum: true, + dragSens: 0.008, + followDragEnabled: true, + followHoverEnabled: true, + hoverEase: 0.08, + hoverRange: 25, + hoverReturn: true, +}; + +const INITIAL_POSE = { + autoElapsed: 16.16666666666703, + rotationX: -0.09184084195435831, + rotationY: 1.7614744750399298, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, + timeElapsed: 325.47089999996314, +}; + +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; void main() { - vec4 worldPosition = modelMatrix * vec4(position, 1.0); - vWorldNormal = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * viewMatrix * worldPosition; + 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 */ ` - uniform vec3 uColor; - uniform vec3 uLightDir; - uniform vec2 uResolution; - uniform float uNumRows; + precision highp float; - varying vec3 vWorldNormal; + 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; + uniform float waveAmount; + uniform float waveSpeed; + + varying vec2 vUv; void main() { - vec3 normal = normalize(vWorldNormal); - vec3 lightDir = normalize(uLightDir); - float ndotl = max(dot(normal, lightDir), 0.0); - float lum = mix(0.35, 1.0, ndotl); - - float rowH = uResolution.y / uNumRows; - float rowFrac = gl_FragCoord.y / rowH - floor(gl_FragCoord.y / rowH); + 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 * 2.2; - float cellFrac = (gl_FragCoord.x - floor(gl_FragCoord.x / cellW) * cellW) / cellW; + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = gl_FragCoord.x + waveOffset; - float fill = pow(lum, 0.45) * 0.95; + float cellW = rowH * cellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; - float dynamicBarHalf = mix(0.15, 0.34, smoothstep(0.05, 0.8, lum)); + 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); @@ -59,80 +180,653 @@ const halftoneFragmentShader = /* glsl */ ` inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d); } - if (inDash < 0.01) { - discard; - } + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); - gl_FragColor = vec4(uColor, inDash); + 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 + #include } `; -function createHalftoneDashMaterial( - lightDirection: THREE.Vector3, - resolution: THREE.Vector2, -) { - return new THREE.ShaderMaterial({ - uniforms: { - uColor: { value: new THREE.Color('#1e5bff') }, - uLightDir: { value: lightDirection.clone() }, - uResolution: { value: resolution.clone() }, - uNumRows: { value: 65 }, - }, - vertexShader: halftoneVertexShader, - fragmentShader: halftoneFragmentShader, - transparent: true, - depthWrite: true, - depthTest: true, - side: THREE.DoubleSide, - }); +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 disposeObjectSubtree(root: THREE.Object3D) { - root.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { +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; } - sceneObject.geometry?.dispose(); + const geometry = object.geometry.clone(); - const material = sceneObject.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material?.dispose(); + 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 { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + '', + (gltf) => { + try { + resolve(extractMergedGeometry(gltf.scene)); + } catch (error) { + reject(error); + } + }, + reject, + ); + }).finally(() => { + dracoLoader.dispose(); }); } -type MeshRestPose = { - position: THREE.Vector3; - quaternion: THREE.Quaternion; - wobblePhase: number; +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, { + format: THREE.RGBAFormat, + magFilter: THREE.LinearFilter, + minFilter: THREE.LinearFilter, + }); +} + +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 applyHalftoneDashMaterials( - modelRoot: THREE.Object3D, - lightDirection: THREE.Vector3, - resolution: THREE.Vector2, +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, + }; +} + +function setPrimaryLightPosition( + light: THREE.DirectionalLight, + angleDegrees: number, + height: number, ) { - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { + const lightAngle = THREE.MathUtils.degToRad(angleDegrees); + light.position.set( + Math.cos(lightAngle) * 5, + height, + Math.sin(lightAngle) * 5, + ); +} + +async function mountFlashCanvas(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({ alpha: true, antialias: false }); + 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 baseCameraDistance = 4; + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, + ); + camera.position.z = baseCameraDistance; + + const primaryLight = new THREE.DirectionalLight(0xffffff, LIGHTING.intensity); + setPrimaryLightPosition(primaryLight, LIGHTING.angleDegrees, LIGHTING.height); + 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({ + clearcoat: 0, + clearcoatRoughness: 0.08, + color: 0xd4d0c8, + envMap: environmentTexture, + envMapIntensity: 0.25, + metalness: MATERIAL.metalness, + reflectivity: 0.5, + roughness: 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 }, + }, + fragmentShader: blurFragmentShader, + vertexShader: passThroughVertexShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + dir: { value: new THREE.Vector2(0, 1) }, + res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, + tInput: { value: null }, + }, + fragmentShader: blurFragmentShader, + vertexShader: passThroughVertexShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + baseInk: { value: HALFTONE.baseInk }, + cellRatio: { value: HALFTONE.cellRatio }, + contrast: { value: HALFTONE.contrast }, + cutoff: { value: HALFTONE.cutoff }, + dashColor: { value: new THREE.Color(HALFTONE.dashColor) }, + glowStr: { value: 0 }, + maxBar: { value: HALFTONE.maxBar }, + numRows: { value: HALFTONE.numRows }, + power: { value: HALFTONE.power }, + resolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + shading: { value: HALFTONE.shading }, + tGlow: { value: blurTargetB.texture }, + tScene: { value: sceneTarget.texture }, + time: { value: 0 }, + waveAmount: { value: HALFTONE.waveAmount }, + waveSpeed: { value: HALFTONE.waveSpeed }, + }, + fragmentShader: halftoneFragmentShader, + vertexShader: passThroughVertexShader, + }); + + 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 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); + }; + + const handleWindowPointerMove = (event: PointerEvent) => { + updatePointerPosition(event); + + if (!interaction.dragging || !ANIMATION.followDragEnabled) { return; } - sceneObject.material = createHalftoneDashMaterial( - lightDirection, - resolution, + const deltaX = (event.clientX - interaction.pointerX) * ANIMATION.dragSens; + const deltaY = (event.clientY - interaction.pointerY) * ANIMATION.dragSens; + + 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; + canvas.style.cursor = 'grab'; + }; + + const handleWindowBlur = () => { + handlePointerUp(); + handlePointerLeave(); + }; + + canvas.addEventListener('pointerdown', handlePointerDown); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointermove', handlePointerMove); + window.addEventListener('blur', handleWindowBlur); + window.addEventListener('pointermove', handleWindowPointerMove); + window.addEventListener('pointerup', handlePointerUp); + + const clock = new THREE.Clock(); + let animationFrameId = 0; + + const renderFrame = () => { + animationFrameId = window.requestAnimationFrame(renderFrame); + + const delta = Math.min(clock.getDelta(), 0.1); + const elapsedTime = INITIAL_POSE.timeElapsed + clock.getElapsedTime(); + + halftoneMaterial.uniforms.time.value = elapsedTime; + + if (!interaction.dragging) { + interaction.autoElapsed += delta; + + if (ANIMATION.dragMomentum) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= 1 - ANIMATION.dragFriction; + interaction.velocityY *= 1 - ANIMATION.dragFriction; + } + } + + const baseRotationX = + Math.sin(interaction.autoElapsed * 0.2) * ANIMATION.autoWobble; + const baseRotationY = interaction.autoElapsed * ANIMATION.autoSpeed; + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (ANIMATION.followHoverEnabled) { + const rangeRadians = THREE.MathUtils.degToRad(ANIMATION.hoverRange); + + if ( + ANIMATION.hoverReturn || + interaction.mouseX !== 0.5 || + interaction.mouseY !== 0.5 + ) { + targetX += (interaction.mouseY - 0.5) * rangeRadians; + targetY += (interaction.mouseX - 0.5) * rangeRadians; + } + + easing = ANIMATION.hoverEase; + } + + if (ANIMATION.followDragEnabled) { + targetX += interaction.targetRotationX; + targetY += interaction.targetRotationY; + easing = ANIMATION.dragFriction; + } + + interaction.rotationX += (targetX - interaction.rotationX) * easing; + interaction.rotationY += (targetY - interaction.rotationY) * easing; + interaction.rotationZ += (0 - interaction.rotationZ) * 0.12; + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, ); - 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.flashMeshRest = rest; - }); + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; + camera.lookAt(0, 0, 0); + setPrimaryLightPosition( + primaryLight, + LIGHTING.angleDegrees, + LIGHTING.height, + ); + + 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(); + canvas.removeEventListener('pointerdown', handlePointerDown); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('blur', handleWindowBlur); + window.removeEventListener('pointermove', handleWindowPointerMove); + window.removeEventListener('pointerup', handlePointerUp); + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + fullScreenGeometry.dispose(); + mesh.geometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + environmentTexture.dispose(); + renderer.dispose(); + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; } const StyledVisualMount = styled.div` @@ -147,218 +841,15 @@ export function Flash() { 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: false }); - renderer.setPixelRatio(1); - 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 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 / maxAxis; - - modelRoot.position.sub(center); - modelRoot.scale.setScalar(scale); - - const canvasResolution = new THREE.Vector2( - renderer.domElement.width, - renderer.domElement.height, - ); - applyHalftoneDashMaterials( - modelRoot, - lightDirectionWorld, - canvasResolution, - ); - pivot.add(modelRoot); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - const delta = Math.min(clock.getDelta(), 0.1); - - const rotationDamp = 6.8; - const influence = pointer.inside ? 1 : 0.38; - targetRotation.y = pointer.x * 0.78 * influence; - targetRotation.x = pointer.y * 0.62 * influence; - - pivot.rotation.y = THREE.MathUtils.damp( - pivot.rotation.y, - targetRotation.y, - rotationDamp, - delta, - ); - pivot.rotation.x = THREE.MathUtils.damp( - pivot.rotation.x, - targetRotation.x, - rotationDamp, - delta, - ); - - const hoverLift = pointer.inside ? 1 : 0; - pivot.scale.setScalar( - THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta), - ); - - const mx = pointer.x * (pointer.inside ? 1 : 0.32); - const my = pointer.y * (pointer.inside ? 1 : 0.32); - - modelRoot.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - const rest = sceneObject.userData.flashMeshRest as - | MeshRestPose - | undefined; - if (!rest) { - return; - } - - const phase = rest.wobblePhase; - const wobble = pointer.inside ? 1 : 0.36; - sceneObject.position.x = - rest.position.x + mx * 0.22 * Math.sin(phase * 1.8); - sceneObject.position.z = - rest.position.z + my * 0.19 * Math.cos(phase * 1.4); - sceneObject.position.y = - rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5); - - const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase); - sceneObject.quaternion.copy(rest.quaternion); - sceneObject.rotateY(twist); - }); - - renderer.render(scene, camera); - }; - - renderFrame(); - }, - undefined, - undefined, - ); - - const setPointerFromEvent = (event: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1; - const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1); - pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1); - pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1); - }; - - const handlePointerEnter = () => { - pointer.inside = true; - }; - - const handlePointerLeave = () => { - pointer.inside = false; - pointer.x = 0; - pointer.y = 0; - }; - - const handlePointerMove = (event: PointerEvent) => { - setPointerFromEvent(event); - }; - - canvas.addEventListener('pointerenter', handlePointerEnter); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointermove', handlePointerMove); - - const handleResize = () => { - if (!mountReference.current || cancelled) { - return; - } - - const nextWidth = mountReference.current.clientWidth; - const nextHeight = mountReference.current.clientHeight; - if (nextWidth < 1 || nextHeight < 1) { - return; - } - - camera.aspect = nextWidth / nextHeight; - camera.updateProjectionMatrix(); - renderer.setSize(nextWidth, nextHeight); - - const rw = renderer.domElement.width; - const rh = renderer.domElement.height; - pivot.traverse((sceneObject) => { - if ( - sceneObject instanceof THREE.Mesh && - sceneObject.material instanceof THREE.ShaderMaterial && - sceneObject.material.uniforms.uResolution - ) { - sceneObject.material.uniforms.uResolution.value.set(rw, rh); - } - }); - }; - - window.addEventListener('resize', handleResize); + const unmount = mountFlashCanvas(container); 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); - } + void Promise.resolve(unmount).then((dispose) => dispose?.()); }; }, []); diff --git a/packages/twenty-website-new/src/illustrations/ThreeCards/Sun.tsx b/packages/twenty-website-new/src/illustrations/ThreeCards/Sun.tsx new file mode 100644 index 0000000000..6522bfa393 --- /dev/null +++ b/packages/twenty-website-new/src/illustrations/ThreeCards/Sun.tsx @@ -0,0 +1,753 @@ +'use client'; + +import { styled } from '@linaria/react'; +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const GLB_URL = '/illustrations/home/three-cards/sun.glb'; +const VIRTUAL_RENDER_HEIGHT = 768; + +const LIGHTING = { + intensity: 1.5, + fillIntensity: 0.15, + ambientIntensity: 0.08, + angleDegrees: 45, + height: 2, +}; + +const MATERIAL = { + roughness: 0.42, + metalness: 0.16, +}; + +const HALFTONE = { + numRows: 99, + contrast: 1.3, + power: 1.1, + shading: 1.6, + baseInk: 0.16, + maxBar: 0.24, + cellRatio: 2.2, + cutoff: 0.02, + dashColor: '#4A38F5', +}; + +const AUTO_ROTATE = { + speed: 0.3, + wobble: 0.3, +}; + +// Start from the same hero angle used in the exported homepage asset. +const INITIAL_POSE = { + autoElapsed: 4, + rotationX: 0.215, + rotationY: 1.2, + rotationZ: 0, + targetRotationX: 0, + targetRotationY: 0, +}; + +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +const blurFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; + + void main() { + vec4 sum = vec4(0.0); + vec2 px = dir / res; + + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; + + sum += texture2D(tInput, vUv) * w[0]; + + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; + } + + gl_FragColor = sum; + } +`; + +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float cellRatio; + uniform float cutoff; + uniform vec3 dashColor; + uniform float time; + + varying vec2 vUv; + + void main() { + float rowH = resolution.y / numRows; + float row = floor(gl_FragCoord.y / rowH); + float rowFrac = gl_FragCoord.y / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float cellW = rowH * cellRatio; + float cellIdx = floor(gl_FragCoord.x / cellW); + float cellFrac = (gl_FragCoord.x - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, contrast); + + float ink = mix(baseInk, 1.0, 1.0 - litLum); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capR = dynamicBarHalf * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalf - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float d = sqrt(cdx * cdx + cdy * cdy); + inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +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 { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((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(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + const unmount = mountSunCanvas(container); + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, []); + + return ; +} diff --git a/packages/twenty-website-new/src/illustrations/ThreeCards/Wheelx.tsx b/packages/twenty-website-new/src/illustrations/ThreeCards/Wheelx.tsx new file mode 100644 index 0000000000..c38bd0eb5e --- /dev/null +++ b/packages/twenty-website-new/src/illustrations/ThreeCards/Wheelx.tsx @@ -0,0 +1,1103 @@ +// @ts-nocheck +'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 { 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'; + +const DRACO_DECODER_PATH = + 'https://www.gstatic.com/draco/versioned/decoders/1.5.6/'; + +const MODEL_URL = '/illustrations/home/three-cards/wheelx.fbx'; + + +const settings = { + "shapeKey": "userUpload_1775685081103", + "lighting": { + "intensity": 1.5, + "fillIntensity": 0.15, + "ambientIntensity": 0.08, + "angleDegrees": 45, + "height": 2 + }, + "material": { + "roughness": 0.42, + "metalness": 0.16 + }, + "halftone": { + "enabled": true, + "numRows": 45, + "contrast": 1.3, + "power": 1.1, + "shading": 1.6, + "baseInk": 0.16, + "maxBar": 0.24, + "cellRatio": 2.2, + "cutoff": 0.02, + "dashColor": "#4A38F5" + }, + "background": { + "transparent": false, + "color": "#000000" + }, + "animation": { + "autoRotateEnabled": true, + "followHoverEnabled": false, + "followDragEnabled": false, + "rotateEnabled": false, + "autoSpeed": 0.3, + "autoWobble": 0.3, + "hoverRange": 25, + "hoverEase": 0.08, + "hoverReturn": true, + "dragSens": 0.008, + "dragFriction": 0.08, + "dragMomentum": true, + "rotateAxis": "y", + "rotateSpeed": 1, + "rotatePingPong": false + } +}; +const shape = { + "filename": "wheelx.fbx", + "key": "userUpload_1775685081103", + "kind": "imported", + "label": "wheelx.fbx", + "loader": "fbx" +}; +const initialPose = { + "autoElapsed": 16.98333333333365, + "rotateElapsed": 0, + "rotationX": -0.06446908045975865, + "rotationY": 5.037500000000101, + "rotationZ": 0, + "targetRotationX": 0, + "targetRotationY": 0 +}; +const VIRTUAL_RENDER_HEIGHT = 768; +const passThroughVertexShader = "\n varying vec2 vUv;\n\n void main() {\n vUv = uv;\n gl_Position = vec4(position, 1.0);\n }\n"; +const blurFragmentShader = "\n precision highp float;\n\n uniform sampler2D tInput;\n uniform vec2 dir;\n uniform vec2 res;\n\n varying vec2 vUv;\n\n void main() {\n vec4 sum = vec4(0.0);\n vec2 px = dir / res;\n\n float w[5];\n w[0] = 0.227027;\n w[1] = 0.1945946;\n w[2] = 0.1216216;\n w[3] = 0.054054;\n w[4] = 0.016216;\n\n sum += texture2D(tInput, vUv) * w[0];\n\n for (int i = 1; i < 5; i++) {\n float fi = float(i) * 3.0;\n sum += texture2D(tInput, vUv + px * fi) * w[i];\n sum += texture2D(tInput, vUv - px * fi) * w[i];\n }\n\n gl_FragColor = sum;\n }\n"; +const halftoneFragmentShader = "\n precision highp float;\n\n uniform sampler2D tScene;\n uniform sampler2D tGlow;\n uniform vec2 resolution;\n uniform float numRows;\n uniform float glowStr;\n uniform float contrast;\n uniform float power;\n uniform float shading;\n uniform float baseInk;\n uniform float maxBar;\n uniform float cellRatio;\n uniform float cutoff;\n uniform vec3 dashColor;\n uniform float time;\n\n varying vec2 vUv;\n\n void main() {\n float rowH = resolution.y / numRows;\n float row = floor(gl_FragCoord.y / rowH);\n float rowFrac = gl_FragCoord.y / rowH - row;\n float rowV = (row + 0.5) * rowH / resolution.y;\n float dy = abs(rowFrac - 0.5);\n\n float cellW = rowH * cellRatio;\n float cellIdx = floor(gl_FragCoord.x / cellW);\n float cellFrac = (gl_FragCoord.x - cellIdx * cellW) / cellW;\n float cellU = (cellIdx + 0.5) * cellW / resolution.x;\n\n vec2 sampleUv = vec2(\n clamp(cellU, 0.0, 1.0),\n clamp(rowV, 0.0, 1.0)\n );\n\n vec4 sceneSample = texture2D(tScene, sampleUv);\n vec4 glowCell = texture2D(tGlow, sampleUv);\n\n float mask = smoothstep(0.02, 0.08, sceneSample.a);\n float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114));\n float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114));\n float detail = lum - avgLum;\n\n float litLum = lum + max(detail, 0.0) * shading\n - max(-detail, 0.0) * shading * 0.55;\n litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0);\n litLum = pow(litLum, contrast);\n\n float ink = mix(baseInk, 1.0, 1.0 - litLum);\n float fill = pow(ink, 1.05) * power;\n fill = clamp(fill, 0.0, 1.0) * mask;\n\n float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink));\n float dx2 = abs(cellFrac - 0.5);\n float halfFill = fill * 0.5;\n float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0);\n float capR = dynamicBarHalf * rowH;\n\n float inDash = 0.0;\n if (dx2 <= bodyHalfW) {\n float edgeDist = dynamicBarHalf - dy;\n inDash = smoothstep(-0.03, 0.03, edgeDist);\n } else {\n float cdx = (dx2 - bodyHalfW) * cellW;\n float cdy = dy * rowH;\n float d = sqrt(cdx * cdx + cdy * cdy);\n inDash = 1.0 - smoothstep(capR - 1.5, capR + 1.5, d);\n }\n\n inDash *= step(0.001, ink) * mask;\n inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1);\n\n vec4 glow = texture2D(tGlow, vUv);\n float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114));\n float halo = glowLum * glowStr * 0.25 * (1.0 - inDash);\n float sharp = smoothstep(0.3, 0.5, inDash + halo);\n vec3 color = dashColor * sharp;\n\n gl_FragColor = vec4(color, sharp);\n\n #include \n #include \n }\n"; + + +function makePolarShape(radiusFunction, segments = 320) { + const shape = new THREE.Shape(); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * Math.PI * 2; + const radius = radiusFunction(angle); + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (segmentIndex === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + return shape; +} + +function makeReliefGeometry(shape, options = {}) { + const { + bevelSegments = 8, + bevelSize = 0.08, + bevelThickness = 0.1, + depth = 0.58, + waveDepth = 0.016, + waves = 8, + } = options; + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 2, + bevelEnabled: true, + bevelThickness, + bevelSize, + bevelSegments, + curveSegments: 96, + }); + + geometry.center(); + + const position = geometry.attributes.position; + let maxRadius = 0; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + maxRadius = Math.max( + maxRadius, + Math.hypot(position.getX(vertexIndex), position.getY(vertexIndex)), + ); + } + + const fullDepth = depth + bevelThickness * 2; + + for (let vertexIndex = 0; vertexIndex < position.count; vertexIndex += 1) { + const x = position.getX(vertexIndex); + const y = position.getY(vertexIndex); + const z = position.getZ(vertexIndex); + const radius = Math.hypot(x, y) / maxRadius; + const angle = Math.atan2(y, x); + const faceAmount = Math.min(1, Math.abs(z) / (fullDepth * 0.5)); + const rimLift = Math.exp(-Math.pow((radius - 0.84) / 0.12, 2)); + const innerDish = Math.exp(-Math.pow((radius - 0.42) / 0.2, 2)); + const wave = + Math.cos(angle * waves) * + Math.exp(-Math.pow((radius - 0.72) / 0.16, 2)) * + waveDepth; + const relief = faceAmount * (0.14 * rimLift - 0.055 * innerDish + wave); + + position.setZ(vertexIndex, z + (z >= 0 ? 1 : -1) * relief); + } + + position.needsUpdate = true; + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return geometry; +} + +function mergeGeometries(geometries) { + if (geometries.length === 1) { + return geometries[0]; + } + + let totalVertices = 0; + let totalIndices = 0; + let hasUv = false; + + const geometryInfos = geometries.map((geometry) => { + const position = geometry.attributes.position; + const normal = geometry.attributes.normal; + const uv = geometry.attributes.uv ?? null; + const index = geometry.index; + const indexCount = index ? index.count : position.count; + + totalVertices += position.count; + totalIndices += indexCount; + hasUv = hasUv || uv !== null; + + return { + index, + indexCount, + normal, + position, + uv, + vertexCount: position.count, + }; + }); + + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; + const indices = new Uint32Array(totalIndices); + + let vertexOffset = 0; + let indexOffset = 0; + + for (const geometryInfo of geometryInfos) { + for (let vertexIndex = 0; vertexIndex < geometryInfo.vertexCount; vertexIndex += 1) { + const positionOffset = (vertexOffset + vertexIndex) * 3; + positions[positionOffset] = geometryInfo.position.getX(vertexIndex); + positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); + positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); + normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); + normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); + normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); + + if (uvs !== null) { + const uvOffset = (vertexOffset + vertexIndex) * 2; + uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; + uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; + } + } + + if (geometryInfo.index) { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = + geometryInfo.index.getX(localIndex) + vertexOffset; + } + } else { + for (let localIndex = 0; localIndex < geometryInfo.indexCount; localIndex += 1) { + indices[indexOffset + localIndex] = localIndex + vertexOffset; + } + } + + vertexOffset += geometryInfo.vertexCount; + indexOffset += geometryInfo.indexCount; + } + + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); + + if (uvs !== null) { + merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + } + + merged.setIndex(new THREE.BufferAttribute(indices, 1)); + + return merged; +} + +function makeArrowTarget() { + const targetParts = []; + const arrowParts = []; + const baseRadius = 1.35; + const baseDepth = 0.32; + const bevel = 0.12; + const points = []; + const segments = 16; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = Math.PI / 2 + (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + -baseDepth / 2 + bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(baseRadius, baseDepth / 2 - bevel)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = (segmentIndex / segments) * (Math.PI / 2); + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + baseDepth / 2 - bevel + Math.sin(angle) * bevel, + ), + ); + } + + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + targetParts.push(disc); + + for (const radius of [0.45, 0.85, 1.22]) { + const ring = new THREE.TorusGeometry(radius, 0.14, 16, 64); + ring.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.04), + ); + targetParts.push(ring); + } + + const bump = new THREE.SphereGeometry(0.32, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2); + bump.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2)); + bump.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2)); + targetParts.push(bump); + + const shaftLength = 1.5; + const shaftRadius = 0.05; + const shaft = new THREE.CylinderGeometry(shaftRadius, shaftRadius, shaftLength, 10, 1); + shaft.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength / 2, 0)); + arrowParts.push(shaft); + + const head = new THREE.ConeGeometry(0.12, 0.35, 10); + head.applyMatrix4(new THREE.Matrix4().makeTranslation(0, -0.15, 0)); + arrowParts.push(head); + + for (let finIndex = 0; finIndex < 3; finIndex += 1) { + const finShape = new THREE.Shape(); + finShape.moveTo(0, 0); + finShape.lineTo(0.22, 0.25); + finShape.lineTo(0, 0.5); + finShape.lineTo(0, 0); + + const finGeometry = new THREE.ExtrudeGeometry(finShape, { + depth: 0.012, + bevelEnabled: false, + }); + + finGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0.05, 0, -0.006)); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeRotationY((finIndex * Math.PI * 2) / 3), + ); + finGeometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, shaftLength - 0.45, 0), + ); + arrowParts.push(finGeometry); + } + + const nock = new THREE.SphereGeometry(0.065, 8, 8); + nock.applyMatrix4(new THREE.Matrix4().makeTranslation(0, shaftLength + 0.03, 0)); + arrowParts.push(nock); + + const aim = new THREE.Matrix4().makeRotationX(Math.PI / 2.15); + const tilt = new THREE.Matrix4().makeRotationZ(Math.PI / 5); + const shift = new THREE.Matrix4().makeTranslation(0.15, 0.15, 0.12); + + for (const geometry of arrowParts) { + geometry.applyMatrix4(aim); + geometry.applyMatrix4(tilt); + geometry.applyMatrix4(shift); + } + + const merged = mergeGeometries([...targetParts, ...arrowParts]); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function makeDollarCoin() { + const parts = []; + const baseRadius = 1.3; + const baseDepth = 0.45; + const bevel = 0.18; + const points = []; + const segments = 20; + + points.push(new THREE.Vector2(0, -baseDepth / 2)); + points.push(new THREE.Vector2(baseRadius - bevel, -baseDepth / 2)); + + for (let segmentIndex = 0; segmentIndex <= segments; segmentIndex += 1) { + const angle = -Math.PI / 2 + (segmentIndex / segments) * Math.PI; + points.push( + new THREE.Vector2( + baseRadius - bevel + Math.cos(angle) * bevel, + Math.sin(angle) * (baseDepth / 2), + ), + ); + } + + points.push(new THREE.Vector2(baseRadius - bevel, baseDepth / 2)); + points.push(new THREE.Vector2(0, baseDepth / 2)); + + const disc = new THREE.LatheGeometry(points, 64); + disc.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); + parts.push(disc); + + const frontRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + frontRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 - 0.01), + ); + parts.push(frontRim); + + const backRim = new THREE.TorusGeometry(baseRadius - 0.22, 0.05, 12, 64); + backRim.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 - 0.01)), + ); + parts.push(backRim); + + const createDollarSign = () => { + const geometries = []; + const tubeRadius = 0.1; + const curveRadius = 0.28; + const verticalOffset = 0.22; + + const bar = new THREE.CylinderGeometry(0.05, 0.05, 1.3, 12); + geometries.push(bar); + + const topArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + topArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.05, verticalOffset, 0), + ); + geometries.push(topArc); + + const bottomArc = new THREE.TorusGeometry(curveRadius, tubeRadius, 16, 32, Math.PI); + bottomArc.applyMatrix4(new THREE.Matrix4().makeRotationZ(-Math.PI / 2)); + bottomArc.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.05, -verticalOffset, 0), + ); + geometries.push(bottomArc); + + const topSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + topSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + topSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(0.16, verticalOffset + curveRadius, 0), + ); + geometries.push(topSerif); + + const bottomSerif = new THREE.CylinderGeometry(tubeRadius, tubeRadius, 0.22, 12); + bottomSerif.applyMatrix4(new THREE.Matrix4().makeRotationZ(Math.PI / 2)); + bottomSerif.applyMatrix4( + new THREE.Matrix4().makeTranslation(-0.16, -verticalOffset - curveRadius, 0), + ); + geometries.push(bottomSerif); + + const diagonalLength = Math.sqrt(0.1 * 0.1 + (verticalOffset * 2) ** 2); + const diagonalAngle = Math.atan2(verticalOffset * 2, 0.1); + const diagonal = new THREE.CylinderGeometry(tubeRadius, tubeRadius, diagonalLength + 0.12, 12); + diagonal.applyMatrix4( + new THREE.Matrix4().makeRotationZ(diagonalAngle - Math.PI / 2), + ); + geometries.push(diagonal); + + return geometries; + }; + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, baseDepth / 2 + 0.01), + ); + parts.push(geometry); + } + + for (const geometry of createDollarSign()) { + geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI)); + geometry.applyMatrix4( + new THREE.Matrix4().makeTranslation(0, 0, -(baseDepth / 2 + 0.01)), + ); + parts.push(geometry); + } + + const merged = mergeGeometries(parts); + merged.computeVertexNormals(); + merged.computeBoundingSphere(); + + return merged; +} + +function createBuiltinGeometry(shapeKey) { + switch (shapeKey) { + case 'torusKnot': + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + case 'sphere': + return new THREE.SphereGeometry(1.4, 64, 64); + case 'torus': + return new THREE.TorusGeometry(1, 0.45, 64, 100); + case 'icosahedron': + return new THREE.IcosahedronGeometry(1.4, 4); + case 'box': + return new THREE.BoxGeometry(2.1, 2.1, 2.1, 6, 6, 6); + case 'cone': + return new THREE.ConeGeometry(1.2, 2.4, 64, 10); + case 'cylinder': + return new THREE.CylinderGeometry(1, 1, 2.3, 64, 10); + case 'octahedron': + return new THREE.OctahedronGeometry(1.5, 2); + case 'dodecahedron': + return new THREE.DodecahedronGeometry(1.35, 1); + case 'tetrahedron': + return new THREE.TetrahedronGeometry(1.7, 1); + case 'sunCoin': + return makeReliefGeometry( + makePolarShape( + (angle) => 1 + 0.17 * Math.pow(0.5 + 0.5 * Math.cos(angle * 12), 1.5), + ), + { depth: 0.62, waves: 12, waveDepth: 0.018 }, + ); + case 'lotusCoin': + return makeReliefGeometry( + makePolarShape((angle) => 0.88 + 0.3 * Math.pow(Math.sin(angle * 4), 2)), + { depth: 0.64, waves: 8, waveDepth: 0.014 }, + ); + case 'arrowTarget': + return makeArrowTarget(); + case 'dollarCoin': + return makeDollarCoin(); + default: + return new THREE.TorusKnotGeometry(1, 0.35, 200, 32); + } +} + + + +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 normalizeImportedGeometry(geometry) { + 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, emptyMessage) { + root.updateMatrixWorld(true); + const geometries = []; + + root.traverse((object) => { + if (!(object instanceof THREE.Mesh) || !object.geometry) { + return; + } + + const geometry = object.geometry.clone(); + + if (!geometry.attributes.normal) { + geometry.computeVertexNormals(); + } + + geometry.applyMatrix4(object.matrixWorld); + geometries.push(geometry); + }); + + if (geometries.length === 0) { + throw new Error(emptyMessage); + } + + return normalizeImportedGeometry(mergeGeometries(geometries)); +} + +function parseFbxGeometry(buffer, label) { + const originalWarn = console.warn; + + console.warn = (...args) => { + if (typeof args[0] === 'string' && args[0].startsWith('THREE.FBXLoader:')) { + return; + } + + originalWarn(...args); + }; + + try { + const root = new FBXLoader(createLoadingManager()).parse(buffer, ''); + return extractMergedGeometry(root, label + ' did not contain any mesh geometry.'); + } finally { + console.warn = originalWarn; + } +} + +function parseGlbGeometry(buffer, label) { + const dracoLoader = new DRACOLoader(); + dracoLoader.setDecoderPath(DRACO_DECODER_PATH); + + const gltfLoader = new GLTFLoader(createLoadingManager()); + gltfLoader.setDRACOLoader(dracoLoader); + + return new Promise((resolve, reject) => { + gltfLoader.parse( + buffer, + '', + (gltf) => { + try { + resolve( + extractMergedGeometry( + gltf.scene, + label + ' did not contain any mesh geometry.', + ), + ); + } catch (error) { + reject(error); + } + }, + reject, + ); + }).finally(() => { + dracoLoader.dispose(); + }); +} + +async function loadImportedGeometryFromUrl(loader, modelUrl, label) { + const response = await fetch(modelUrl); + + if (!response.ok) { + throw new Error('Unable to load ' + label + ' from ' + modelUrl + '.'); + } + + const buffer = await response.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, label); + } + + return parseGlbGeometry(buffer, label); +} + + +function createRenderTarget(width, height) { + return new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + }); +} + +function createInteractionState() { + return { + autoElapsed: initialPose.autoElapsed, + dragging: false, + mouseX: 0.5, + mouseY: 0.5, + pointerX: 0, + pointerY: 0, + rotateElapsed: initialPose.rotateElapsed, + rotationX: initialPose.rotationX, + rotationY: initialPose.rotationY, + rotationZ: initialPose.rotationZ, + targetRotationX: initialPose.targetRotationX, + targetRotationY: initialPose.targetRotationY, + velocityX: 0, + velocityY: 0, + }; +} + +function resetInteractionState(interactionState) { + interactionState.dragging = false; + interactionState.targetRotationX = 0; + interactionState.targetRotationY = 0; + interactionState.velocityX = 0; + interactionState.velocityY = 0; + interactionState.autoElapsed = 0; +} + +async function createGeometry(modelUrl) { + if (shape.kind === 'imported' && shape.loader && modelUrl) { + return loadImportedGeometryFromUrl(shape.loader, modelUrl, shape.label); + } + + return createBuiltinGeometry(shape.key); +} + + + +async function mountHalftoneCanvas(options) { + const { + container, + modelUrl, + onError, + } = options; + + 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; + + try { + geometry = await createGeometry(modelUrl); + } catch (error) { + onError?.(error); + geometry = createBuiltinGeometry('torusKnot'); + } + + 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 primaryLight = new THREE.DirectionalLight(0xffffff, settings.lighting.intensity); + const lightAngle = (settings.lighting.angleDegrees * Math.PI) / 180; + primaryLight.position.set( + Math.cos(lightAngle) * 5, + settings.lighting.height, + Math.sin(lightAngle) * 5, + ); + 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({ + color: 0xd4d0c8, + roughness: settings.material.roughness, + metalness: settings.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: settings.halftone.numRows }, + glowStr: { value: 0 }, + contrast: { value: settings.halftone.contrast }, + power: { value: settings.halftone.power }, + shading: { value: settings.halftone.shading }, + baseInk: { value: settings.halftone.baseInk }, + maxBar: { value: settings.halftone.maxBar }, + cellRatio: { value: settings.halftone.cellRatio }, + cutoff: { value: settings.halftone.cutoff }, + dashColor: { value: new THREE.Color(settings.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 autoRotateEnabled = settings.animation.autoRotateEnabled; + const followHoverEnabled = settings.animation.followHoverEnabled; + const followDragEnabled = settings.animation.followDragEnabled; + const rotateEnabled = settings.animation.rotateEnabled; + + 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) => { + interaction.dragging = true; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + interaction.velocityX = 0; + interaction.velocityY = 0; + canvas.style.cursor = 'grabbing'; + }; + + const handlePointerMove = (event) => { + interaction.mouseX = event.clientX / window.innerWidth; + interaction.mouseY = event.clientY / window.innerHeight; + + if ( + !interaction.dragging || + (!followDragEnabled && !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(); + let animationFrameId = 0; + + const renderFrame = () => { + animationFrameId = window.requestAnimationFrame(renderFrame); + + const delta = 1 / 60; + const elapsedTime = clock.getElapsedTime(); + halftoneMaterial.uniforms.time.value = elapsedTime; + + let baseRotationX = 0; + let baseRotationY = 0; + let baseRotationZ = 0; + + if (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 (rotateEnabled) { + interaction.rotateElapsed += delta; + const rotateAngle = settings.animation.rotatePingPong + ? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) * Math.PI + : interaction.rotateElapsed * settings.animation.rotateSpeed; + + if (settings.animation.rotateAxis === 'x' || settings.animation.rotateAxis === 'xy') { + baseRotationX += rotateAngle; + } + + if (settings.animation.rotateAxis === 'y' || settings.animation.rotateAxis === 'xy') { + baseRotationY += rotateAngle; + } + + if (settings.animation.rotateAxis === 'z') { + baseRotationZ = rotateAngle; + } + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (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 (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 (autoRotateEnabled && !followHoverEnabled && !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); + } + }; +} + +const StyledVisualMount = styled.div` + display: block; + height: 100%; + min-width: 0; + width: 100%; +`; + +export function Wheelx() { + const mountReference = useRef(null); + + useEffect(() => { + const container = mountReference.current; + + if (!container) { + return; + } + + const unmount = mountHalftoneCanvas({ + container, + modelUrl: MODEL_URL, + onError: (error) => { + console.error(error); + }, + }); + + return () => { + void Promise.resolve(unmount).then((dispose) => dispose?.()); + }; + }, []); + + return ; +} diff --git a/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx b/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx index 6eb4d03183..1c7beae3a0 100644 --- a/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx +++ b/packages/twenty-website-new/src/illustrations/illustrations-registry.tsx @@ -22,6 +22,8 @@ import { Lock } from './ThreeCards/Lock'; import { Programming } from './ThreeCards/Programming'; import { SingleScreen } from './ThreeCards/SingleScreen'; import { Speed } from './ThreeCards/Speed'; +import { Sun } from './ThreeCards/Sun'; +import { Wheelx } from './ThreeCards/Wheelx'; import { Logo as WhyTwentyStepperLogo } from './WhyTwentyStepper/Logo'; export const THREE_CARDS_ILLUSTRATIONS = { @@ -34,6 +36,8 @@ export const THREE_CARDS_ILLUSTRATIONS = { programming: Programming, singleScreen: SingleScreen, speed: Speed, + sun: Sun, + wheelx: Wheelx, } as const satisfies Record; export type ThreeCardsIllustrationId = keyof typeof THREE_CARDS_ILLUSTRATIONS; diff --git a/packages/twenty-website-new/src/sections/Problem/components/Visual/LolOverlay.tsx b/packages/twenty-website-new/src/sections/Problem/components/Visual/LolOverlay.tsx new file mode 100644 index 0000000000..e9e3d02714 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Problem/components/Visual/LolOverlay.tsx @@ -0,0 +1,695 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +const VIRTUAL_RENDER_HEIGHT = 768; + +const passThroughVertexShader = /* glsl */ ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = vec4(position, 1.0); + } +`; + +const blurFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tInput; + uniform vec2 dir; + uniform vec2 res; + + varying vec2 vUv; + + void main() { + vec4 sum = vec4(0.0); + vec2 px = dir / res; + + float w[5]; + w[0] = 0.227027; + w[1] = 0.1945946; + w[2] = 0.1216216; + w[3] = 0.054054; + w[4] = 0.016216; + + sum += texture2D(tInput, vUv) * w[0]; + + for (int i = 1; i < 5; i++) { + float fi = float(i) * 3.0; + sum += texture2D(tInput, vUv + px * fi) * w[i]; + sum += texture2D(tInput, vUv - px * fi) * w[i]; + } + + gl_FragColor = sum; + } +`; + +const imagePassthroughFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tImage; + uniform vec2 imageSize; + uniform vec2 viewportSize; + uniform float zoom; + + varying vec2 vUv; + + void main() { + float imageAspect = imageSize.x / imageSize.y; + float viewAspect = viewportSize.x / viewportSize.y; + + vec2 uv = vUv; + + // Contain: show full image, letterbox/pillarbox as needed + if (imageAspect > viewAspect) { + float scale = viewAspect / imageAspect; + uv.y = (uv.y - 0.5) / scale + 0.5; + } else { + float scale = imageAspect / viewAspect; + uv.x = (uv.x - 0.5) / scale + 0.5; + } + + uv = (uv - 0.5) / zoom + 0.5; + + float inBounds = step(0.0, uv.x) * step(uv.x, 1.0) + * step(0.0, uv.y) * step(uv.y, 1.0); + + vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0)); + + gl_FragColor = vec4(color.rgb, inBounds); + } +`; + +const halftoneFragmentShader = /* glsl */ ` + precision highp float; + + uniform sampler2D tScene; + uniform sampler2D tGlow; + uniform vec2 resolution; + uniform float numRows; + uniform float glowStr; + uniform float contrast; + uniform float power; + uniform float shading; + uniform float baseInk; + uniform float maxBar; + uniform float rowMerge; + uniform float cellRatio; + uniform float cutoff; + uniform float highlightOpen; + uniform float shadowGrouping; + uniform float shadowCrush; + uniform vec3 dashColor; + uniform float time; + uniform float waveAmount; + uniform float waveSpeed; + uniform float distanceScale; + uniform vec2 interactionUv; + uniform vec2 interactionVelocity; + uniform vec2 dragOffset; + uniform float hoverLightStrength; + uniform float hoverLightRadius; + uniform float hoverFlowStrength; + uniform float hoverFlowRadius; + uniform float dragFlowStrength; + uniform float dragFlowRadius; + uniform float cropToBounds; + + varying vec2 vUv; + + void main() { + // Crop to image bounds: discard fragments outside source image (image mode only) + if (cropToBounds > 0.5) { + vec4 boundsCheck = texture2D(tScene, vUv); + if (boundsCheck.a < 0.01) { + gl_FragColor = vec4(0.0); + return; + } + } + + float baseRowH = resolution.y / (numRows * distanceScale); + vec2 pointerPx = interactionUv * resolution; + vec2 fragDelta = gl_FragCoord.xy - pointerPx; + float fragDist = length(fragDelta); + vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); + float velocityMagnitude = length(interactionVelocity); + vec2 motionDir = velocityMagnitude > 0.001 + ? interactionVelocity / velocityMagnitude + : vec2(0.0, 0.0); + float motionBias = velocityMagnitude > 0.001 + ? dot(-radialDir, motionDir) * 0.5 + 0.5 + : 0.5; + + float hoverLightMask = 0.0; + if (hoverLightStrength > 0.0) { + float lightRadiusPx = hoverLightRadius * resolution.y; + hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); + } + + float hoverFlowMask = 0.0; + if (hoverFlowStrength > 0.0) { + float hoverRadiusPx = hoverFlowRadius * resolution.y; + hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist); + } + + float dragFlowMask = 0.0; + if (dragFlowStrength > 0.0) { + float dragRadiusPx = dragFlowRadius * resolution.y; + dragFlowMask = smoothstep(dragRadiusPx, 0.0, fragDist); + } + + vec2 hoverDisplacement = + radialDir * hoverFlowStrength * hoverFlowMask * baseRowH * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * baseRowH * 1.15; + vec2 dragDisplacement = dragOffset * dragFlowMask * dragFlowStrength * 0.8; + vec2 effectCoord = gl_FragCoord.xy + hoverDisplacement + dragDisplacement; + + float densityBoost = + hoverFlowStrength * hoverFlowMask * 0.22 + + dragFlowStrength * dragFlowMask * 0.16; + float rowH = baseRowH / (1.0 + densityBoost); + + float offsetY = effectCoord.y; + float row = floor(offsetY / rowH); + float rowFrac = offsetY / rowH - row; + float rowV = (row + 0.5) * rowH / resolution.y; + float dy = abs(rowFrac - 0.5); + + float waveOffset = waveAmount * sin(time * waveSpeed + row * 0.5) * rowH; + float effectiveX = effectCoord.x + waveOffset; + + float localCellRatio = cellRatio * ( + 1.0 + + hoverFlowStrength * hoverFlowMask * 0.08 + + dragFlowStrength * dragFlowMask * 0.1 * motionBias + ); + float cellW = rowH * localCellRatio; + float cellIdx = floor(effectiveX / cellW); + float cellFrac = (effectiveX - cellIdx * cellW) / cellW; + float cellU = (cellIdx + 0.5) * cellW / resolution.x; + + vec2 sampleUv = vec2( + clamp(cellU, 0.0, 1.0), + clamp(rowV, 0.0, 1.0) + ); + + vec4 sceneSample = texture2D(tScene, sampleUv); + vec4 glowCell = texture2D(tGlow, sampleUv); + + float mask = smoothstep(0.02, 0.08, sceneSample.a); + float lum = dot(sceneSample.rgb, vec3(0.299, 0.587, 0.114)); + float avgLum = dot(glowCell.rgb, vec3(0.299, 0.587, 0.114)); + float detail = lum - avgLum; + + float litLum = lum + max(detail, 0.0) * shading + - max(-detail, 0.0) * shading * 0.55; + float lightLift = + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.34; + float lightFocus = hoverLightStrength * hoverLightMask * 0.12; + litLum = clamp(litLum + lightLift, 0.0, 1.0); + litLum = clamp((litLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + litLum = pow(litLum, max(contrast - lightFocus, 0.25)); + + float darkness = 1.0 - litLum; + float groupedLum = clamp((avgLum - cutoff) / max(1.0 - cutoff, 0.001), 0.0, 1.0); + groupedLum = pow(groupedLum, max(contrast * 0.9, 0.25)); + float groupedDarkness = 1.0 - groupedLum; + darkness = mix(darkness, max(darkness, groupedDarkness), shadowGrouping); + darkness = clamp( + (darkness - highlightOpen) / max(1.0 - highlightOpen, 0.001), + 0.0, + 1.0 + ); + + float shadowMask = smoothstep(0.42, 0.96, darkness); + darkness = mix( + darkness, + mix(darkness, 1.0, shadowMask), + shadowCrush + ); + + float inkBase = baseInk * smoothstep(0.03, 0.24, darkness); + float ink = mix(inkBase, 1.0, darkness); + float fill = pow(ink, 1.05) * power; + fill = clamp(fill, 0.0, 1.0) * mask; + + float dynamicBarHalf = mix(0.08, maxBar, smoothstep(0.03, 0.85, ink)); + float dynamicBarHalfY = min( + dynamicBarHalf + rowMerge * smoothstep(0.42, 0.98, ink), + 0.78 + ); + float dx2 = abs(cellFrac - 0.5); + float halfFill = fill * 0.5; + float bodyHalfW = max(halfFill - dynamicBarHalf * (rowH / cellW), 0.0); + float capRX = dynamicBarHalf * rowH; + float capRY = dynamicBarHalfY * rowH; + + float inDash = 0.0; + if (dx2 <= bodyHalfW) { + float edgeDist = dynamicBarHalfY - dy; + inDash = smoothstep(-0.03, 0.03, edgeDist); + } else { + float cdx = (dx2 - bodyHalfW) * cellW; + float cdy = dy * rowH; + float ellipseDist = sqrt( + (cdx * cdx) / max(capRX * capRX, 0.0001) + + (cdy * cdy) / max(capRY * capRY, 0.0001) + ); + inDash = 1.0 - smoothstep(1.0 - 0.08, 1.0 + 0.08, ellipseDist); + } + + inDash *= step(0.001, ink) * mask; + inDash *= 1.0 + 0.03 * sin(time * 0.8 + row * 0.1); + + vec4 glow = texture2D(tGlow, vUv); + float glowLum = dot(glow.rgb, vec3(0.299, 0.587, 0.114)); + float halo = glowLum * glowStr * 0.25 * (1.0 - inDash); + float sharp = smoothstep(0.3, 0.5, inDash + halo); + vec3 color = dashColor * sharp; + + gl_FragColor = vec4(color, sharp); + + #include + #include + } +`; + +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((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(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 ( +
+ ); +} diff --git a/packages/twenty-website-new/src/sections/Problem/components/Visual/Visual.tsx b/packages/twenty-website-new/src/sections/Problem/components/Visual/Visual.tsx index 7b2eb49ff7..d00b582069 100644 --- a/packages/twenty-website-new/src/sections/Problem/components/Visual/Visual.tsx +++ b/packages/twenty-website-new/src/sections/Problem/components/Visual/Visual.tsx @@ -1,5 +1,6 @@ import { theme } from '@/theme'; import { styled } from '@linaria/react'; +import Monolith from './monolith'; const DESKTOP_PATH = 'M672 395.498V701a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h664a4 4 0 0 1 4 4v65.155c0 2.363-.837 4.65-2.361 6.454l-23.603 27.936a10 10 0 0 0-2.362 6.453v242.614c0 2.245.756 4.424 2.145 6.188l24.036 30.51a10 10 0 0 1 2.145 6.188'; @@ -21,34 +22,39 @@ const StyledVisual = styled.div` `; const StyledMasked = styled.div` - background-color: ${theme.colors.secondary.background[100]}; + background-color: ${theme.colors.primary.background[100]}; height: 100%; + isolation: isolate; + -webkit-mask-image: ${mobileMask}; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; mask-image: ${mobileMask}; + mask-position: center; mask-repeat: no-repeat; mask-size: 100% 100%; + overflow: hidden; + position: relative; width: 100%; @media (min-width: ${theme.breakpoints.md}px) { + -webkit-mask-image: ${desktopMask}; mask-image: ${desktopMask}; } `; -const StyledImage = styled.img` - display: block; - height: 100%; - object-fit: cover; - width: 100%; +const StyledHalftoneLayer = styled.div` + inset: 0; + position: absolute; `; export function Visual() { return ( - ); diff --git a/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx b/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx new file mode 100644 index 0000000000..0d21ae04f9 --- /dev/null +++ b/packages/twenty-website-new/src/sections/Problem/components/Visual/monolith.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { styled } from '@linaria/react'; +import NextImage from 'next/image'; +import { useEffect, useRef } from 'react'; + +const IMAGE_SRC = '/images/home/problem/monolith2.jpg'; +const PATTERN_SRC = '/images/home/problem/halftone-pattern.png'; + +const BASE_SCALE = 1.08; +const HOVER_SCALE = 1.12; +const PATTERN_SCALE = 1.15; +const EASE = 0.12; +const BASE_RANGE_X = 12; +const BASE_RANGE_Y = 10; +const PATTERN_RANGE_X = 18; +const PATTERN_RANGE_Y = 14; +const PATTERN_IDLE_OPACITY = 0.52; +const PATTERN_ACTIVE_OPACITY = 0.72; + +type MotionState = { + baseOffsetX: number; + baseOffsetY: number; + baseScale: number; + glowOpacity: number; + glowX: number; + glowY: number; + patternOffsetX: number; + patternOffsetY: number; + patternOpacity: number; + patternScale: number; +}; + +const StyledRoot = styled.div` + --base-offset-x: 0px; + --base-offset-y: 0px; + --base-scale: ${BASE_SCALE}; + --pattern-offset-x: 0px; + --pattern-offset-y: 0px; + --pattern-opacity: ${PATTERN_IDLE_OPACITY}; + --pattern-scale: ${PATTERN_SCALE}; + --glow-opacity: 0; + --glow-x: 50%; + --glow-y: 38%; + + background: + radial-gradient( + circle at 50% 22%, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0) 38% + ), + linear-gradient( + 180deg, + rgba(24, 10, 14, 0.04) 0%, + rgba(24, 10, 14, 0.02) 42%, + rgba(24, 10, 14, 0.12) 100% + ); + height: 100%; + overflow: hidden; + position: relative; + width: 100%; +`; + +const StyledBaseLayer = styled.div` + inset: -8%; + position: absolute; + transform: translate3d(var(--base-offset-x), var(--base-offset-y), 0) + scale(var(--base-scale)); + transform-origin: center center; + will-change: transform; +`; + +const StyledPatternLayer = styled.div` + inset: -8%; + opacity: var(--pattern-opacity); + pointer-events: none; + position: absolute; + transform: translate3d(var(--pattern-offset-x), var(--pattern-offset-y), 0) + scale(var(--pattern-scale)); + transform-origin: center center; + will-change: transform, opacity; +`; + +const StyledGlow = styled.div` + background: radial-gradient( + circle at var(--glow-x) var(--glow-y), + rgba(255, 255, 255, 0.26) 0%, + rgba(255, 255, 255, 0.08) 16%, + rgba(255, 255, 255, 0) 38% + ); + inset: -16%; + mix-blend-mode: screen; + opacity: var(--glow-opacity); + pointer-events: none; + position: absolute; + will-change: opacity; +`; + +const StyledShade = styled.div` + background: + linear-gradient( + 180deg, + rgba(18, 7, 10, 0.02) 0%, + rgba(18, 7, 10, 0.02) 30%, + rgba(18, 7, 10, 0.16) 100% + ), + linear-gradient( + 90deg, + rgba(18, 7, 10, 0.1) 0%, + rgba(18, 7, 10, 0) 18%, + rgba(18, 7, 10, 0) 82%, + rgba(18, 7, 10, 0.08) 100% + ); + inset: 0; + pointer-events: none; + position: absolute; +`; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function easeValue(current: number, target: number) { + return current + (target - current) * EASE; +} + +function applyMotionStyles(element: HTMLDivElement, state: MotionState) { + element.style.setProperty( + '--base-offset-x', + `${state.baseOffsetX.toFixed(2)}px`, + ); + element.style.setProperty( + '--base-offset-y', + `${state.baseOffsetY.toFixed(2)}px`, + ); + element.style.setProperty('--base-scale', state.baseScale.toFixed(4)); + element.style.setProperty( + '--pattern-offset-x', + `${state.patternOffsetX.toFixed(2)}px`, + ); + element.style.setProperty( + '--pattern-offset-y', + `${state.patternOffsetY.toFixed(2)}px`, + ); + element.style.setProperty('--pattern-scale', state.patternScale.toFixed(4)); + element.style.setProperty( + '--pattern-opacity', + state.patternOpacity.toFixed(4), + ); + element.style.setProperty('--glow-x', `${state.glowX.toFixed(2)}%`); + element.style.setProperty('--glow-y', `${state.glowY.toFixed(2)}%`); + element.style.setProperty('--glow-opacity', state.glowOpacity.toFixed(4)); +} + +export default function Monolith() { + const rootRef = useRef(null); + + useEffect(() => { + const element = rootRef.current; + + if (!element) { + return; + } + + const current: MotionState = { + baseOffsetX: 0, + baseOffsetY: 0, + baseScale: BASE_SCALE, + glowOpacity: 0, + glowX: 50, + glowY: 38, + patternOffsetX: 0, + patternOffsetY: 0, + patternOpacity: PATTERN_IDLE_OPACITY, + patternScale: PATTERN_SCALE, + }; + + const target: MotionState = { ...current }; + let frameId = 0; + + const animate = () => { + current.baseOffsetX = easeValue(current.baseOffsetX, target.baseOffsetX); + current.baseOffsetY = easeValue(current.baseOffsetY, target.baseOffsetY); + current.baseScale = easeValue(current.baseScale, target.baseScale); + current.patternOffsetX = easeValue( + current.patternOffsetX, + target.patternOffsetX, + ); + current.patternOffsetY = easeValue( + current.patternOffsetY, + target.patternOffsetY, + ); + current.patternOpacity = easeValue( + current.patternOpacity, + target.patternOpacity, + ); + current.patternScale = easeValue( + current.patternScale, + target.patternScale, + ); + current.glowX = easeValue(current.glowX, target.glowX); + current.glowY = easeValue(current.glowY, target.glowY); + current.glowOpacity = easeValue(current.glowOpacity, target.glowOpacity); + + applyMotionStyles(element, current); + frameId = window.requestAnimationFrame(animate); + }; + + const updateTargetsFromPointer = (clientX: number, clientY: number) => { + const rect = element.getBoundingClientRect(); + const normalizedX = clamp( + (clientX - rect.left) / Math.max(rect.width, 1), + 0, + 1, + ); + const normalizedY = clamp( + (clientY - rect.top) / Math.max(rect.height, 1), + 0, + 1, + ); + + target.baseOffsetX = (0.5 - normalizedX) * BASE_RANGE_X; + target.baseOffsetY = (0.5 - normalizedY) * BASE_RANGE_Y; + target.baseScale = HOVER_SCALE; + target.patternOffsetX = (0.5 - normalizedX) * PATTERN_RANGE_X; + target.patternOffsetY = (0.5 - normalizedY) * PATTERN_RANGE_Y; + target.patternOpacity = PATTERN_ACTIVE_OPACITY; + target.patternScale = PATTERN_SCALE + 0.02; + target.glowX = normalizedX * 100; + target.glowY = normalizedY * 100; + target.glowOpacity = 1; + }; + + const resetTargets = () => { + target.baseOffsetX = 0; + target.baseOffsetY = 0; + target.baseScale = BASE_SCALE; + target.patternOffsetX = 0; + target.patternOffsetY = 0; + target.patternOpacity = PATTERN_IDLE_OPACITY; + target.patternScale = PATTERN_SCALE; + target.glowX = 50; + target.glowY = 38; + target.glowOpacity = 0; + }; + + const handlePointerEnter = (event: PointerEvent) => { + updateTargetsFromPointer(event.clientX, event.clientY); + }; + + const handlePointerMove = (event: PointerEvent) => { + updateTargetsFromPointer(event.clientX, event.clientY); + }; + + const handlePointerLeave = () => { + resetTargets(); + }; + + const handleWindowBlur = () => { + resetTargets(); + }; + + element.addEventListener('pointerenter', handlePointerEnter); + element.addEventListener('pointermove', handlePointerMove); + element.addEventListener('pointerleave', handlePointerLeave); + window.addEventListener('blur', handleWindowBlur); + frameId = window.requestAnimationFrame(animate); + + return () => { + window.cancelAnimationFrame(frameId); + element.removeEventListener('pointerenter', handlePointerEnter); + element.removeEventListener('pointermove', handlePointerMove); + element.removeEventListener('pointerleave', handlePointerLeave); + window.removeEventListener('blur', handleWindowBlur); + }; + }, []); + + return ( + + + + + + + + + + + ); +}