diff --git a/.gitignore b/.gitignore index 1d94e44b26..66c54682df 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ mcp.json TRANSLATION_QA_REPORT.md .playwright-mcp/ .playwright-cli/ +output/playwright/ diff --git a/packages/twenty-website-new/jest.config.mjs b/packages/twenty-website-new/jest.config.mjs new file mode 100644 index 0000000000..00867abc80 --- /dev/null +++ b/packages/twenty-website-new/jest.config.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { pathsToModuleNameMapper } from 'ts-jest'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const tsConfigPath = resolve(__dirname, './tsconfig.json'); +const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8')); + +const jestConfig = { + displayName: 'twenty-website-new', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transformIgnorePatterns: ['../../node_modules/'], + transform: { + '^.+\\.[tj]sx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + ], + }, + moduleNameMapper: { + ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { + prefix: '/', + }), + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], + coverageDirectory: './coverage', +}; + +export default jestConfig; diff --git a/packages/twenty-website-new/project.json b/packages/twenty-website-new/project.json index 7af8727de3..1b1adda042 100644 --- a/packages/twenty-website-new/project.json +++ b/packages/twenty-website-new/project.json @@ -36,6 +36,29 @@ "lint": {}, "lint:diff-with-main": {}, "typecheck": {}, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{projectRoot}/coverage"], + "options": { + "jestConfig": "{projectRoot}/jest.config.mjs", + "silent": true, + "coverage": true, + "coverageReporters": ["text-summary"], + "cacheDirectory": "../../.cache/jest/{projectRoot}" + }, + "configurations": { + "ci": { + "ci": true, + "maxWorkers": 1 + }, + "coverage": { + "coverageReporters": ["lcov", "text"] + }, + "watch": { + "watch": true + } + } + }, "fmt": { "options": { "files": "." 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 deleted file mode 100644 index ff7ff03c4a..0000000000 Binary files a/packages/twenty-website-new/public/illustrations/home/three-cards/sun.glb and /dev/null 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 deleted file mode 100644 index cb4ce7ae4d..0000000000 Binary files a/packages/twenty-website-new/public/illustrations/home/three-cards/wheelx.fbx and /dev/null 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 deleted file mode 100644 index 3821cf3047..0000000000 Binary files a/packages/twenty-website-new/public/images/home/problem/halftone-pattern.png and /dev/null differ diff --git a/packages/twenty-website-new/public/images/home/problem/monolith-problem.webp b/packages/twenty-website-new/public/images/home/problem/monolith-problem.webp new file mode 100644 index 0000000000..0207033279 Binary files /dev/null and b/packages/twenty-website-new/public/images/home/problem/monolith-problem.webp 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 deleted file mode 100644 index 79d03239bf..0000000000 Binary files a/packages/twenty-website-new/public/images/home/problem/monolith2.jpg and /dev/null differ diff --git a/packages/twenty-website-new/public/images/home/problem/problem-visual.svg b/packages/twenty-website-new/public/images/home/problem/problem-visual.svg deleted file mode 100644 index 608ba5ce1a..0000000000 --- a/packages/twenty-website-new/public/images/home/problem/problem-visual.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file 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 e5f63753d4..4dbd3b4427 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: 'wheelx', + illustration: 'lock', }, ], }; diff --git a/packages/twenty-website-new/src/app/(home)/page.tsx b/packages/twenty-website-new/src/app/(home)/page.tsx index 8d490a11c0..b7d4ffbba4 100644 --- a/packages/twenty-website-new/src/app/(home)/page.tsx +++ b/packages/twenty-website-new/src/app/(home)/page.tsx @@ -22,6 +22,8 @@ import { Testimonials } from '@/sections/Testimonials/components'; import { ThreeCards } from '@/sections/ThreeCards/components'; import { TrustedBy } from '@/sections/TrustedBy/components'; import { theme } from '@/theme'; +import { css } from '@linaria/core'; +import { styled } from '@linaria/react'; import type { Metadata } from 'next'; export const metadata: Metadata = { @@ -29,6 +31,40 @@ export const metadata: Metadata = { description: 'Modular, scalable open source CRM for modern teams.', }; +const ThreeCardsIllustrationIntroContent = styled.div` + display: grid; + grid-template-columns: 1fr; + row-gap: ${theme.spacing(2)}; + width: 100%; +`; + +const ThreeCardsIllustrationIntroHeader = styled.div` + display: grid; + grid-template-columns: 1fr; + row-gap: ${theme.spacing(6)}; + width: 100%; +`; + +const threeCardsIllustrationHeadingClassName = css` + width: 100%; + + @media (min-width: ${theme.breakpoints.md}px) { + max-width: 921px; + } + + [data-family='sans'] { + letter-spacing: -0.02em; + } +`; + +const threeCardsIllustrationBodyClassName = css` + width: 100%; + + @media (min-width: ${theme.breakpoints.md}px) { + max-width: 571px; + } +`; + export default async function HomePage() { const stats = await fetchCommunityStats(); const menuSocialLinks = mergeSocialLinkLabels(MENU_DATA.socialLinks, stats); @@ -84,16 +120,25 @@ export default async function HomePage() { - - - + + + + + + + , ) => void; onBackgroundChange: (value: Partial) => void; + onCopyShareLink: () => void; onDashColorChange: (value: string) => void; onExportHalftoneImage: (width: number, height: number) => void; + onExportBackgroundChange: (value: boolean) => void; onExportHtml: () => void; onExportNameChange: (value: string) => void; onExportReact: () => void; @@ -144,9 +48,10 @@ type ControlsPanelProps = { onShapeChange: (value: string) => void; onSourceModeChange: (value: HalftoneSourceMode) => void; onTabChange: (value: HalftoneTabId) => void; - onUploadImage: () => void; - onUploadModel: () => void; + onToggleVisibility: () => void; + onUploadSource: () => void; previewDistance: number; + visible: boolean; selectedShape: HalftoneGeometrySpec | undefined; settings: HalftoneStudioSettings; shapeOptions: Array<{ label: string; value: string }>; @@ -160,15 +65,67 @@ const TAB_LABELS: Record = { export: 'Export', }; +const TabsGroup = styled.div` + display: flex; + gap: 6px; + min-width: 0; +`; + +const TABLER_STROKE = 1.7; + +const PanelActions = styled.div` + display: inline-flex; + gap: 2px; + margin-left: auto; +`; + +const PanelActionButton = styled.button` + align-items: center; + background: transparent; + border: none; + border-radius: 7px; + color: rgba(255, 255, 255, 0.44); + cursor: pointer; + display: inline-flex; + font-family: inherit; + height: 28px; + justify-content: center; + padding: 0; + transition: + background-color 0.15s ease, + color 0.15s ease; + width: 28px; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.74); + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; + +const PanelToggleTabButton = styled(PanelActionButton)<{ $collapsed: boolean }>` + & > svg { + transform: ${(props) => (props.$collapsed ? 'scaleX(-1)' : 'none')}; + transition: transform 0.18s ease; + } +`; + export function ControlsPanel({ activeTab, defaultExportName, + exportBackground, exportName, imageFileName, onAnimationSettingsChange, onBackgroundChange, + onCopyShareLink, onDashColorChange, onExportHalftoneImage, + onExportBackgroundChange, onExportHtml, onExportNameChange, onExportReact, @@ -180,29 +137,58 @@ export function ControlsPanel({ onShapeChange, onSourceModeChange, onTabChange, - onUploadImage, - onUploadModel, + onToggleVisibility, + onUploadSource, previewDistance, + visible, selectedShape, settings, shapeOptions, }: ControlsPanelProps) { return ( - - - {TABS.map((tab) => ( - onTabChange(tab)} + + + {visible ? ( + + {TABS.map((tab) => ( + onTabChange(tab)} + type="button" + > + {TAB_LABELS[tab]} + + ))} + + ) : null} + + - {TAB_LABELS[tab]} - - ))} + + + + + + - {activeTab === 'design' ? ( + {visible && activeTab === 'design' ? ( ) : null} - {activeTab === 'animations' ? ( + {visible && activeTab === 'animations' ? ( ) : null} - {activeTab === 'export' ? ( + {visible && activeTab === 'export' ? ( 0.5) { @@ -248,9 +147,11 @@ const halftoneFragmentShader = /* glsl */ ` } } - float baseRowH = resolution.y / (numRows * distanceScale); - vec2 pointerPx = interactionUv * resolution; - vec2 fragDelta = gl_FragCoord.xy - pointerPx; + vec2 fragCoord = + (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution; + float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0); + vec2 pointerPx = interactionUv * logicalResolution; + vec2 fragDelta = fragCoord - pointerPx; float fragDist = length(fragDelta); vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); float velocityMagnitude = length(interactionVelocity); @@ -263,142 +164,73 @@ const halftoneFragmentShader = /* glsl */ ` float hoverLightMask = 0.0; if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * resolution.y; + float lightRadiusPx = hoverLightRadius * logicalResolution.y; hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); } float hoverFlowMask = 0.0; if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * resolution.y; + float hoverRadiusPx = hoverFlowRadius * logicalResolution.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; + radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15; + vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45; + vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement; - float densityBoost = - hoverFlowStrength * hoverFlowMask * 0.22 + - dragFlowStrength * dragFlowMask * 0.16; - float rowH = baseRowH / (1.0 + densityBoost); + float bandRow = floor(effectCoord.y / halftoneSize); + float waveOffset = + waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize; + effectCoord.x += waveOffset; - 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) + vec2 cellIndex = floor(effectCoord / halftoneSize); + vec2 sampleUv = clamp( + (cellIndex + 0.5) * halftoneSize / logicalResolution, + vec2(0.0), + vec2(1.0) ); + vec2 cellUv = fract(effectCoord / halftoneSize); 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), + hoverLightStrength * + hoverLightMask * + mix(0.78, 1.18, motionBias) * + 0.22; + float bandRadius = clamp( + ( + ( + sceneSample.r + + sceneSample.g + + sceneSample.b + + s_3 * length(vec2(0.5)) + ) * + (1.0 / 3.0) + ) + lightLift, 0.0, 1.0 - ); + ) * 1.86 * 0.5; - 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); + float alpha = 0.0; + if (bandRadius > 0.0001) { + float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4); + float edge = 0.02; + alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; } - 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); + vec3 color = dashColor * alpha; + gl_FragColor = vec4(color, alpha); #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) { @@ -423,6 +255,10 @@ const CanvasMount = styled.div<{ $background: string }>` export type HalftoneSnapshotFn = ( width: number, height: number, + options?: { + backgroundColor?: string; + includeBackground?: boolean; + }, ) => Promise; type HalftoneCanvasProps = { @@ -466,8 +302,6 @@ type SceneResources = { type InteractionState = { activePointerId: number | null; autoElapsed: number; - dragOffsetX: number; - dragOffsetY: number; dragging: boolean; mouseX: number; mouseY: number; @@ -512,8 +346,6 @@ function createInteractionState( return { activePointerId: null, autoElapsed: initialPose?.autoElapsed ?? 0, - dragOffsetX: 0, - dragOffsetY: 0, dragging: false, mouseX: 0.5, mouseY: 0.5, @@ -582,17 +414,10 @@ function getCanvasCursor( isDragging: boolean, ) { if (settings.sourceMode === 'image') { - if (settings.animation.dragFlowEnabled) { - return isDragging ? 'grabbing' : 'crosshair'; - } - return 'crosshair'; } - if ( - settings.animation.followDragEnabled || - settings.animation.autoRotateEnabled - ) { + if (settings.animation.followDragEnabled) { return isDragging ? 'grabbing' : 'grab'; } @@ -612,10 +437,6 @@ function setPrimaryLightPosition( ); } -function getImagePreviewZoom(previewDistance: number) { - return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001); -} - function updateLighting( resources: SceneResources, settings: HalftoneStudioSettings, @@ -643,24 +464,9 @@ 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.tile.value = settings.halftone.scale; + resources.halftoneMaterial.uniforms.s_3.value = settings.halftone.power; + resources.halftoneMaterial.uniforms.s_4.value = settings.halftone.width; (resources.halftoneMaterial.uniforms.dashColor.value as THREE.Color).set( settings.halftone.dashColor, ); @@ -670,6 +476,8 @@ function updateHalftone( : 0; resources.halftoneMaterial.uniforms.waveSpeed.value = settings.animation.waveSpeed; + resources.imageMaterial.uniforms.contrast.value = + settings.halftone.imageContrast; } function syncResources( @@ -703,8 +511,6 @@ function resetInteractionState( ) { interactionState.activePointerId = null; interactionState.dragging = false; - interactionState.dragOffsetX = 0; - interactionState.dragOffsetY = 0; interactionState.mouseX = 0.5; interactionState.mouseY = 0.5; interactionState.pointerInside = false; @@ -960,27 +766,20 @@ export function HalftoneCanvas({ uniforms: { tScene: { value: sceneTarget.texture }, tGlow: { value: blurTargetB.texture }, - resolution: { + effectResolution: { 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 }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + tile: { value: settings.halftone.scale }, + s_3: { value: settings.halftone.power }, + s_4: { value: settings.halftone.width }, dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, time: { value: 0 }, waveAmount: { value: 0 }, waveSpeed: { value: 1 }, - distanceScale: { value: 1.0 }, + footprintScale: { 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) }, @@ -989,7 +788,6 @@ export function HalftoneCanvas({ hoverFlowStrength: { value: 0 }, hoverFlowRadius: { value: 0.18 }, dragFlowStrength: { value: 0 }, - dragFlowRadius: { value: 0.24 }, cropToBounds: { value: 0 }, }, vertexShader: passThroughVertexShader, @@ -1014,9 +812,10 @@ export function HalftoneCanvas({ tImage: { value: null }, imageSize: { value: new THREE.Vector2(1, 1) }, viewportSize: { - value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), }, zoom: { value: getImagePreviewZoom(previewDistance) }, + contrast: { value: settings.halftone.imageContrast }, }, vertexShader: passThroughVertexShader, fragmentShader: imagePassthroughFragmentShader, @@ -1052,6 +851,72 @@ export function HalftoneCanvas({ sceneTarget, }; + const updateViewportUniforms = ( + logicalWidth: number, + logicalHeight: number, + effectWidth: number, + effectHeight: number, + ) => { + blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + halftoneMaterial.uniforms.effectResolution.value.set( + effectWidth, + effectHeight, + ); + halftoneMaterial.uniforms.logicalResolution.value.set( + logicalWidth, + logicalHeight, + ); + imageMaterial.uniforms.viewportSize.value.set( + logicalWidth, + logicalHeight, + ); + }; + + const getImageHalftoneScale = ( + viewportWidth: number, + viewportHeight: number, + activePreviewDistance: number, + ) => { + const imageSize = imageMaterial.uniforms.imageSize.value as THREE.Vector2; + + return getImageFootprintScale({ + imageHeight: imageSize.y, + imageWidth: imageSize.x, + previewDistance: activePreviewDistance, + viewportHeight, + viewportWidth, + }); + }; + + const getMeshHalftoneScale = ( + viewportWidth: number, + viewportHeight: number, + lookAtTarget: THREE.Vector3, + ) => { + if (!mesh.geometry.boundingBox) { + mesh.geometry.computeBoundingBox(); + } + + const localBounds = mesh.geometry.boundingBox; + + if (!localBounds) { + return 1; + } + + mesh.updateMatrixWorld(); + camera.updateMatrixWorld(); + + return getMeshFootprintScale({ + camera, + localBounds, + lookAtTarget, + meshMatrixWorld: mesh.matrixWorld, + viewportHeight, + viewportWidth, + }); + }; + resourcesReference.current = resources; syncResources(resources, settingsReference.current); @@ -1059,11 +924,15 @@ export function HalftoneCanvas({ const captureSnapshot: HalftoneSnapshotFn = async ( snapshotWidth: number, snapshotHeight: number, + options, ) => { const activeSettings = settingsReference.current; const isImage = activeSettings.sourceMode === 'image' && resources.imageTexture !== null; + const includeBackground = options?.includeBackground ?? false; + const backgroundColor = + options?.backgroundColor ?? activeSettings.background.color; // Create temporary render targets at export resolution const snapScene = createRenderTarget(snapshotWidth, snapshotHeight); @@ -1077,20 +946,12 @@ export function HalftoneCanvas({ renderer.setSize(snapshotWidth, snapshotHeight, false); // Update material uniforms for snapshot resolution - blurHorizontalMaterial.uniforms.res.value.set( + updateViewportUniforms( + snapshotWidth, + snapshotHeight, 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; @@ -1099,14 +960,22 @@ export function HalftoneCanvas({ halftoneMaterial.uniforms.cropToBounds.value = isImage ? 1 : 0; if (isImage) { - imageMaterial.uniforms.viewportSize.value.set( + imageMaterial.uniforms.zoom.value = getImagePreviewZoom( + previewDistanceReference.current, + ); + halftoneMaterial.uniforms.footprintScale.value = getImageHalftoneScale( snapshotWidth, snapshotHeight, + previewDistanceReference.current, ); - imageMaterial.uniforms.zoom.value = 1; } else { camera.aspect = snapshotWidth / snapshotHeight; camera.updateProjectionMatrix(); + halftoneMaterial.uniforms.footprintScale.value = getMeshHalftoneScale( + snapshotWidth, + snapshotHeight, + new THREE.Vector3(0, mesh.position.y * 0.2, 0), + ); } // Render scene to snapshot target @@ -1159,14 +1028,13 @@ export function HalftoneCanvas({ 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; + updateViewportUniforms( + getVirtualWidth(), + getVirtualHeight(), + prevSize.x, + prevSize.y, + ); if (isImage) { - imageMaterial.uniforms.viewportSize.value.set(prevSize.x, prevSize.y); imageMaterial.uniforms.zoom.value = getImagePreviewZoom( previewDistanceReference.current, ); @@ -1194,16 +1062,21 @@ export function HalftoneCanvas({ ); } - const cropBounds = getAlphaCropBounds( - flippedBuffer, - snapshotWidth, - snapshotHeight, - ) ?? { + const fullSnapshotBounds = { minX: 0, minY: 0, maxX: snapshotWidth - 1, maxY: snapshotHeight - 1, }; + const alphaCropBounds = getAlphaCropBounds( + flippedBuffer, + snapshotWidth, + snapshotHeight, + ); + const cropBounds = + includeBackground + ? fullSnapshotBounds + : (alphaCropBounds ?? fullSnapshotBounds); const croppedWidth = cropBounds.maxX - cropBounds.minX + 1; const croppedHeight = cropBounds.maxY - cropBounds.minY + 1; const croppedBuffer = new Uint8ClampedArray( @@ -1236,7 +1109,23 @@ export function HalftoneCanvas({ return null; } - ctx.putImageData(imageData, 0, 0); + if (includeBackground) { + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = croppedWidth; + sourceCanvas.height = croppedHeight; + const sourceContext = sourceCanvas.getContext('2d'); + + if (!sourceContext) { + return null; + } + + sourceContext.putImageData(imageData, 0, 0); + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, croppedWidth, croppedHeight); + ctx.drawImage(sourceCanvas, 0, 0); + } else { + ctx.putImageData(imageData, 0, 0); + } return new Promise((resolve) => { offscreen.toBlob((blob) => resolve(blob), 'image/png'); @@ -1254,6 +1143,8 @@ export function HalftoneCanvas({ const width = getWidth(); const height = getHeight(); + const logicalWidth = getVirtualWidth(); + const logicalHeight = getVirtualHeight(); const renderWidth = getRenderWidth(); const renderHeight = getRenderHeight(); @@ -1263,10 +1154,12 @@ export function HalftoneCanvas({ 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); + updateViewportUniforms( + logicalWidth, + logicalHeight, + renderWidth, + renderHeight, + ); }; const resizeObserver = new ResizeObserver(syncSize); @@ -1333,14 +1226,22 @@ export function HalftoneCanvas({ } }; + const markFirstInteraction = () => { + if (didInteractReference.current) { + return; + } + + didInteractReference.current = true; + onFirstInteraction(); + }; + 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; + ? false + : activeSettings.animation.followDragEnabled; updatePointerPosition(event, { resetVelocity: true }); interaction.pointerX = event.clientX; @@ -1364,21 +1265,22 @@ export function HalftoneCanvas({ interaction.dragging, ); - if (!didInteractReference.current) { - didInteractReference.current = true; - onFirstInteraction(); - } + markFirstInteraction(); }; const handlePointerMove = (event: PointerEvent) => { const interaction = interactionReference.current; const resetVelocity = !interaction.pointerInside && !interaction.dragging; - const pointerStep = updatePointerPosition( + updatePointerPosition( event, resetVelocity ? { resetVelocity: true } : undefined, ); const activeSettings = settingsReference.current; + if (activeSettings.sourceMode === 'image' && interaction.pointerInside) { + markFirstInteraction(); + } + if (!interaction.dragging) { return; } @@ -1390,27 +1292,9 @@ export function HalftoneCanvas({ 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) { + if (!animation.followDragEnabled) { return; } @@ -1490,16 +1374,14 @@ export function HalftoneCanvas({ 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; - } + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + interaction.smoothedMouseX = 0.5; + interaction.smoothedMouseY = 0.5; canvas.style.cursor = getCanvasCursor(activeSettings, false); }; @@ -1526,11 +1408,13 @@ export function HalftoneCanvas({ const interaction = interactionReference.current; const activeSettings = settingsReference.current; + const delta = clock.getDelta(); const elapsedTime = (initialPoseReference.current?.timeElapsed ?? 0) + - clock.getElapsedTime(); - const delta = 1 / 60; + clock.elapsedTime; const baseDistance = previewDistanceReference.current; + const logicalWidth = getVirtualWidth(); + const logicalHeight = getVirtualHeight(); const isImageMode = activeSettings.sourceMode === 'image'; const hasImageTexture = resources.imageTexture !== null; @@ -1541,9 +1425,6 @@ export function HalftoneCanvas({ : 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) { @@ -1555,53 +1436,26 @@ export function HalftoneCanvas({ halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0; if (isImageMode) { - const pointerFollow = interaction.dragging - ? 0.46 - : IMAGE_POINTER_FOLLOW; - const pointerActive = interaction.pointerInside || interaction.dragging; + const pointerActive = interaction.pointerInside; interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * pointerFollow; + (interaction.mouseX - interaction.smoothedMouseX) * + IMAGE_POINTER_FOLLOW; interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * pointerFollow; + (interaction.mouseY - interaction.smoothedMouseY) * + IMAGE_POINTER_FOLLOW; 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(), + interaction.pointerVelocityX * logicalWidth, + -interaction.pointerVelocityY * logicalHeight, ); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); halftoneMaterial.uniforms.hoverLightStrength.value = pointerActive && activeSettings.animation.hoverLightEnabled ? activeSettings.animation.hoverLightIntensity @@ -1610,16 +1464,17 @@ export function HalftoneCanvas({ 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; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; imageMaterial.uniforms.zoom.value = getImagePreviewZoom(baseDistance); imageMaterial.uniforms.viewportSize.value.set( - getRenderWidth(), - getRenderHeight(), + logicalWidth, + logicalHeight, + ); + halftoneMaterial.uniforms.footprintScale.value = getImageHalftoneScale( + logicalWidth, + logicalHeight, + baseDistance, ); poseChangeReference.current({ @@ -1654,14 +1509,7 @@ export function HalftoneCanvas({ 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; - } - + interaction.autoElapsed += delta; baseRotationY += interaction.autoElapsed * activeSettings.animation.autoSpeed; baseRotationX += @@ -1865,8 +1713,15 @@ export function HalftoneCanvas({ camera.position.z += (baseDistance - camera.position.z) * 0.12; } - camera.lookAt(0, meshOffsetY * 0.2, 0); + const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); + + camera.lookAt(lookAtTarget); setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); + halftoneMaterial.uniforms.footprintScale.value = getMeshHalftoneScale( + logicalWidth, + logicalHeight, + lookAtTarget, + ); poseChangeReference.current({ autoElapsed: interaction.autoElapsed, diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx index b62035001f..b8eae98168 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx @@ -10,6 +10,7 @@ import { disposeGeometryCache, getGeometryForSpec, } from '@/app/halftone/_lib/geometry-registry'; +import { REFERENCE_PREVIEW_DISTANCE } from '@/app/halftone/_lib/footprint'; import { deriveExportComponentName, generateReactComponent, @@ -22,8 +23,17 @@ import { DEFAULT_SHAPE_HALFTONE_SETTINGS, createInitialHalftoneStudioState, halftoneStudioReducer, + type HalftoneExportPose, + type HalftoneGeometrySpec, + type HalftoneModelLoader, + type HalftoneSourceMode, normalizeHalftoneStudioSettings, } from '@/app/halftone/_lib/state'; +import { + buildShareUrl, + decodeShareState, + encodeShareState, +} from '@/app/halftone/_lib/share'; import { Logo as LogoIcon } from '@/icons'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; @@ -38,128 +48,11 @@ import { } from 'react'; import type * as THREE from 'three'; -type HalftoneSourceMode = 'shape' | 'image'; -type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; -type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; -type HalftoneModelLoader = 'fbx' | 'glb'; - -interface HalftoneLightingSettings { - intensity: number; - fillIntensity: number; - ambientIntensity: number; - angleDegrees: number; - height: number; -} - -interface HalftoneMaterialSettings { - roughness: number; - metalness: number; -} - -interface HalftoneEffectSettings { - enabled: boolean; - numRows: number; - contrast: number; - power: number; - shading: number; - baseInk: number; - maxBar: number; - rowMerge: number; - cellRatio: number; - cutoff: number; - highlightOpen: number; - shadowGrouping: number; - shadowCrush: number; - dashColor: string; -} - -interface HalftoneBackgroundSettings { - transparent: boolean; - color: string; -} - -interface HalftoneAnimationSettings { - autoRotateEnabled: boolean; - breatheEnabled: boolean; - cameraParallaxEnabled: boolean; - followHoverEnabled: boolean; - followDragEnabled: boolean; - floatEnabled: boolean; - hoverLightEnabled: boolean; - dragFlowEnabled: boolean; - lightSweepEnabled: boolean; - rotateEnabled: boolean; - autoSpeed: number; - autoWobble: number; - breatheAmount: number; - breatheSpeed: number; - cameraParallaxAmount: number; - cameraParallaxEase: number; - driftAmount: number; - hoverRange: number; - hoverEase: number; - hoverReturn: boolean; - dragSens: number; - dragFriction: number; - dragMomentum: boolean; - rotateAxis: HalftoneRotateAxis; - rotatePreset: HalftoneRotatePreset; - rotateSpeed: number; - rotatePingPong: boolean; - floatAmplitude: number; - floatSpeed: number; - lightSweepHeightRange: number; - lightSweepRange: number; - lightSweepSpeed: number; - springDamping: number; - springReturnEnabled: boolean; - springStrength: number; - hoverLightIntensity: number; - hoverLightRadius: number; - dragFlowDecay: number; - dragFlowRadius: number; - dragFlowStrength: number; - hoverWarpStrength: number; - hoverWarpRadius: number; - dragWarpStrength: number; - waveEnabled: boolean; - waveSpeed: number; - waveAmount: number; -} - -interface HalftoneExportPose { - autoElapsed: number; - rotateElapsed: number; - rotationX: number; - rotationY: number; - rotationZ: number; - targetRotationX: number; - targetRotationY: number; - timeElapsed: number; -} - -interface HalftoneStudioSettings { - sourceMode: HalftoneSourceMode; - shapeKey: string; - lighting: HalftoneLightingSettings; - material: HalftoneMaterialSettings; - halftone: HalftoneEffectSettings; - background: HalftoneBackgroundSettings; - animation: HalftoneAnimationSettings; -} - -interface HalftoneGeometrySpec { - key: string; - label: string; - kind: 'builtin' | 'imported'; - loader?: HalftoneModelLoader; - filename?: string; - description?: string; - extensions?: readonly string[]; - userProvided?: boolean; -} - type GeometryCacheEntry = THREE.BufferGeometry | Promise; +const DESKTOP_CONTROLS_PANEL_WIDTH = 320; +const DESKTOP_CONTROLS_PANEL_OFFSET = 20; +const DESKTOP_CONTROLS_PANEL_FOOTPRINT = + DESKTOP_CONTROLS_PANEL_WIDTH + DESKTOP_CONTROLS_PANEL_OFFSET; const StudioShell = styled.div<{ $background: string }>` background: ${(props) => props.$background}; @@ -169,6 +62,21 @@ const StudioShell = styled.div<{ $background: string }>` width: 100%; `; +const ContentRegion = styled.div<{ $panelOpen: boolean }>` + height: 100%; + margin-right: auto; + position: relative; + transition: width 0.18s ease; + width: ${(props) => + props.$panelOpen + ? `calc(100% - ${DESKTOP_CONTROLS_PANEL_FOOTPRINT}px)` + : '100%'}; + + @media (max-width: ${theme.breakpoints.md - 1}px) { + width: 100%; + } +`; + const CanvasLayer = styled.div` height: 100%; width: 100%; @@ -192,7 +100,7 @@ const Hint = styled.div<{ $tone: 'dark' | 'light'; $visible: boolean }>` left: 50%; opacity: ${(props) => (props.$visible ? 1 : 0)}; pointer-events: none; - position: fixed; + position: absolute; top: 20px; transform: translateX(-50%); transition: opacity 0.3s ease; @@ -218,7 +126,7 @@ const Status = styled.div<{ left: 50%; opacity: ${(props) => (props.$visible ? 1 : 0)}; pointer-events: none; - position: fixed; + position: absolute; top: 44px; transform: translateX(-50%); transition: opacity 0.2s ease; @@ -226,8 +134,12 @@ const Status = styled.div<{ `; const ControlsPositioner = styled.div` + align-items: flex-start; bottom: 20px; + display: flex; height: calc(100vh - 40px); + justify-content: flex-end; + pointer-events: none; position: fixed; right: 20px; top: 20px; @@ -242,14 +154,20 @@ const ControlsPositioner = styled.div` } `; +const ControlsPanelFrame = styled.div<{ $visible: boolean }>` + height: ${(props) => (props.$visible ? '100%' : 'auto')}; + pointer-events: auto; + @media (max-width: ${theme.breakpoints.md - 1}px) { + width: ${(props) => (props.$visible ? '100%' : 'auto')}; + } +`; + const HiddenFileInput = styled.input` display: none; `; const DEFAULT_IMAGE_ASSET_PATH = '/images/shared/halftone/twenty-logo.svg'; const DEFAULT_IMAGE_FILENAME = 'twenty-logo.svg'; -const EXPORTED_PRESET_PREVIEW_DISTANCE = 4; - type PendingFilePicker = { resolve: (file: File | null) => void; }; @@ -362,10 +280,12 @@ export function HalftoneStudio() { undefined, createInitialHalftoneStudioState, ); + const [controlsVisible, setControlsVisible] = useState(true); const [previewDistance, setPreviewDistance] = useState(7); const [activeGeometry, setActiveGeometry] = useState( () => createFallbackGeometry(), ); + const [exportBackground, setExportBackground] = useState(false); const [exportName, setExportName] = useState(''); const [imageFile, setImageFile] = useState(null); const [imageElement, setImageElement] = useState( @@ -415,14 +335,25 @@ export function HalftoneStudio() { ), [selectedShape, state.importedFiles], ); - const defaultExportName = useMemo( - () => - deriveExportComponentName( - selectedShape, - selectedImportedFile ?? undefined, - ), - [selectedImportedFile, selectedShape], - ); + const defaultExportName = useMemo(() => { + if (state.settings.sourceMode === 'image') { + if (!imageFile || imageFile.name === DEFAULT_IMAGE_FILENAME) { + return 'TwentyImage'; + } + + return deriveExportComponentName(undefined, imageFile); + } + + return deriveExportComponentName( + selectedShape, + selectedImportedFile ?? undefined, + ); + }, [ + imageFile, + selectedImportedFile, + selectedShape, + state.settings.sourceMode, + ]); useEffect(() => { halftoneBySourceModeReference.current[state.settings.sourceMode] = { @@ -430,6 +361,73 @@ export function HalftoneStudio() { }; }, [state.settings.halftone, state.settings.sourceMode]); + // Hydrate from URL hash on first mount. Done in an effect (not the reducer + // initializer) so the server-rendered HTML matches the client and we don't + // touch `window` during render. + const hashHydratedReference = useRef(false); + useEffect(() => { + if (hashHydratedReference.current) { + return; + } + + hashHydratedReference.current = true; + + const decoded = decodeShareState(window.location.hash); + + if (!decoded) { + return; + } + + dispatch({ type: 'replaceSettings', value: decoded.settings }); + setPreviewDistance(decoded.previewDistance); + setExportName(decoded.exportName); + }, []); + + // Mirror the current design state to the URL hash so a refresh (and a copy + // of the URL) restores the same look. We skip the very first effect run so + // the URL stays clean until the user actually changes something. + const hashSyncInitializedReference = useRef(false); + useEffect(() => { + if (!hashSyncInitializedReference.current) { + hashSyncInitializedReference.current = true; + return; + } + + const timeout = window.setTimeout(() => { + const hash = encodeShareState({ + settings: state.settings, + previewDistance, + exportName, + }); + const nextUrl = `${window.location.pathname}${window.location.search}#${hash}`; + window.history.replaceState(null, '', nextUrl); + }, 250); + + return () => window.clearTimeout(timeout); + }, [state.settings, previewDistance, exportName]); + + const handleCopyShareLink = useCallback(() => { + const url = buildShareUrl({ + settings: state.settings, + previewDistance, + exportName, + }); + + void navigator.clipboard + .writeText(url) + .then(() => { + dispatch({ type: 'setStatus', message: 'Link copied to clipboard.' }); + window.setTimeout(() => dispatch({ type: 'clearStatus' }), 2000); + }) + .catch(() => { + dispatch({ + type: 'setStatus', + message: 'Could not copy the share link.', + isError: true, + }); + }); + }, [exportName, previewDistance, state.settings]); + const openFilePicker = useCallback((accept: string) => { return new Promise((resolve) => { const input = fileInputReference.current; @@ -542,41 +540,8 @@ export function HalftoneStudio() { } }, []); - const requestImportedFile = useCallback( - async (shape: HalftoneGeometrySpec) => { - const existingFile = state.importedFiles[shape.key]; - - if (existingFile) { - return existingFile; - } - - dispatch({ - type: 'setStatus', - message: `Select ${shape.label} to import it.`, - }); - - const file = await openFilePicker( - shape.extensions?.join(',') ?? '.fbx,.glb', - ); - - if (!file) { - dispatch({ type: 'clearStatus' }); - return null; - } - - dispatch({ - type: 'setImportedFile', - key: shape.key, - file, - }); - - return file; - }, - [openFilePicker, state.importedFiles], - ); - const handleShapeChange = useCallback( - async (key: string) => { + (key: string) => { const shape = state.geometrySpecs.find( (candidate) => candidate.key === key, ); @@ -585,67 +550,86 @@ export function HalftoneStudio() { return; } - if (shape.kind === 'imported' && !state.importedFiles[shape.key]) { - const file = await requestImportedFile(shape); - - if (!file) { - return; - } - } - dispatch({ type: 'setShapeKey', value: key, }); }, - [requestImportedFile, state.geometrySpecs, state.importedFiles], + [state.geometrySpecs], ); - const handleUploadModel = useCallback(async () => { - const file = await openFilePicker('.fbx,.glb'); + const activateUploadedModel = useCallback( + (file: File) => { + const currentDashColor = state.settings.halftone.dashColor; + const extension = file.name.split('.').pop()?.toLowerCase(); + const loader = extension === 'glb' ? 'glb' : 'fbx'; + const nextShape: HalftoneGeometrySpec = { + key: `userUpload_${Date.now()}`, + label: file.name, + kind: 'imported', + loader, + filename: file.name, + description: `${loader.toUpperCase()} model`, + extensions: [`.${loader}`], + userProvided: true, + }; + + dispatch({ + type: 'registerImportedFile', + spec: nextShape, + file, + activate: true, + }); + halftoneBySourceModeReference.current[state.settings.sourceMode] = { + ...state.settings.halftone, + }; + dispatch({ type: 'setSourceMode', value: 'shape' }); + dispatch({ + type: 'patchHalftone', + value: { + ...halftoneBySourceModeReference.current.shape, + dashColor: currentDashColor, + }, + }); + }, + [state.settings.halftone, state.settings.sourceMode], + ); + + const activateUploadedImage = useCallback( + (file: File) => { + const currentDashColor = state.settings.halftone.dashColor; + setImageFile(file); + halftoneBySourceModeReference.current[state.settings.sourceMode] = { + ...state.settings.halftone, + }; + dispatch({ type: 'setSourceMode', value: 'image' }); + dispatch({ + type: 'patchHalftone', + value: { + ...halftoneBySourceModeReference.current.image, + dashColor: currentDashColor, + }, + }); + }, + [state.settings.halftone, state.settings.sourceMode], + ); + + const handleUploadSource = useCallback(async () => { + const file = await openFilePicker( + '.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg', + ); if (!file) { return; } - const extension = file.name.split('.').pop()?.toLowerCase(); - const loader = extension === 'glb' ? 'glb' : 'fbx'; - const nextShape: HalftoneGeometrySpec = { - key: `userUpload_${Date.now()}`, - label: file.name, - kind: 'imported', - loader, - filename: file.name, - description: `${loader.toUpperCase()} model`, - extensions: [`.${loader}`], - userProvided: true, - }; - - dispatch({ - type: 'registerImportedFile', - spec: nextShape, - file, - activate: true, - }); - }, [openFilePicker]); - - const handleUploadImage = useCallback(async () => { - const file = await openFilePicker('.png,.jpg,.jpeg,.webp,.gif'); - - if (!file) { + if (/\.(png|jpe?g|webp|gif|svg)$/i.test(file.name)) { + activateUploadedImage(file); return; } - setImageFile(file); - halftoneBySourceModeReference.current[state.settings.sourceMode] = { - ...state.settings.halftone, - }; - dispatch({ type: 'setSourceMode', value: 'image' }); - dispatch({ - type: 'patchHalftone', - value: { ...halftoneBySourceModeReference.current.image }, - }); - }, [openFilePicker, state.settings.halftone, state.settings.sourceMode]); + activateUploadedModel(file); + }, [activateUploadedImage, activateUploadedModel, openFilePicker]); const loadDefaultImageFile = useCallback(async () => { if (defaultImageFileReference.current) { @@ -711,7 +695,7 @@ export function HalftoneStudio() { const handleImportPreset = useCallback(async () => { const selectedFiles = await openPresetPicker( - '.tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif', + '.tsx,.html,.fbx,.glb,.png,.jpg,.jpeg,.webp,.gif,.svg', ); const presetFile = selectedFiles.find((file) => /\.(tsx|html)$/i.test(file.name)) ?? null; @@ -799,7 +783,7 @@ export function HalftoneStudio() { exportPoseReference.current = preset.initialPose; setCanvasInitialPose(preset.initialPose); - setPreviewDistance(EXPORTED_PRESET_PREVIEW_DISTANCE); + setPreviewDistance(preset.previewDistance ?? REFERENCE_PREVIEW_DISTANCE); setExportName( preset.componentName ?? presetFile.name.replace(/\.(tsx|html)$/i, ''), ); @@ -840,6 +824,9 @@ export function HalftoneStudio() { .toLowerCase(); const isImageMode = state.settings.sourceMode === 'image'; const importedFile = selectedImportedFile; + const exportBackgroundColor = exportBackground + ? state.settings.background.color + : 'transparent'; const modelFilename = importedFile ? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}` @@ -857,8 +844,10 @@ export function HalftoneStudio() { componentName, modelFilename, exportPoseReference.current, + previewDistance, importedFile ?? undefined, imageExportFilename, + exportBackgroundColor, ), ); @@ -870,7 +859,9 @@ export function HalftoneStudio() { }, [ defaultExportName, exportName, + exportBackground, imageFile, + previewDistance, selectedImportedFile, selectedShape, state.settings, @@ -879,20 +870,32 @@ export function HalftoneStudio() { const handleExportHalftoneImage = useCallback( async (width: number, height: number) => { const snapshotFn = snapshotReference.current; + const componentName = exportName || defaultExportName; + const kebabName = componentName + .replace(/([a-z])([A-Z])/g, '$1-$2') + .toLowerCase(); if (!snapshotFn) { return; } - const blob = await snapshotFn(width, height); + const blob = await snapshotFn(width, height, { + backgroundColor: state.settings.background.color, + includeBackground: exportBackground, + }); if (!blob) { return; } - downloadBlob(`halftone-${width}x${height}.png`, blob); + downloadBlob(`${kebabName}-${width}x${height}.png`, blob); }, - [], + [ + defaultExportName, + exportBackground, + exportName, + state.settings.background.color, + ], ); const handleExportHtml = useCallback(async () => { @@ -902,6 +905,9 @@ export function HalftoneStudio() { .toLowerCase(); const isImageMode = state.settings.sourceMode === 'image'; const importedFile = selectedImportedFile; + const exportBackgroundColor = exportBackground + ? state.settings.background.color + : 'transparent'; const modelFilename = importedFile ? `${kebabName}${importedFile.name.replace(/^[^.]*/, '')}` @@ -919,8 +925,10 @@ export function HalftoneStudio() { componentName, modelFilename, exportPoseReference.current, + previewDistance, importedFile ?? undefined, imageExportFilename, + exportBackgroundColor, ), ); @@ -930,7 +938,9 @@ export function HalftoneStudio() { }, [ defaultExportName, exportName, + exportBackground, imageFile, + previewDistance, selectedImportedFile, selectedShape, state.settings, @@ -961,8 +971,10 @@ export function HalftoneStudio() { }, [imageFile]); useEffect(() => { + const geometryCache = geometryCacheReference.current; + return () => { - disposeGeometryCache(geometryCacheReference.current); + disposeGeometryCache(geometryCache); }; }, []); @@ -1041,97 +1053,109 @@ export function HalftoneStudio() { /> - - {state.settings.sourceMode === 'image' - ? 'Drag to shift the halftone pattern' - : 'Click & drag to rotate'} - - 0} - > - {state.statusMessage} - + + + {state.settings.sourceMode === 'image' + ? 'Hover to relight the halftone' + : state.settings.animation.followDragEnabled + ? 'Click & drag to rotate' + : state.settings.animation.followHoverEnabled + ? 'Move the pointer to tilt the object' + : state.settings.animation.autoRotateEnabled + ? 'Idle auto-rotate is active' + : 'Open Animations to add motion'} + + 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} - /> + + + dispatch({ type: 'patchAnimation', value }) + } + onCopyShareLink={handleCopyShareLink} + onDashColorChange={(value) => + dispatch({ + type: 'patchHalftone', + value: { dashColor: value }, + }) + } + onExportHalftoneImage={(width, height) => { + void handleExportHalftoneImage(width, height); + }} + onExportBackgroundChange={setExportBackground} + onExportHtml={() => { + void handleExportHtml(); + }} + onExportNameChange={setExportName} + onExportReact={handleExportReact} + onImportPreset={() => { + void handleImportPreset(); + }} + onBackgroundChange={(value) => + dispatch({ type: 'patchBackground', value }) + } + onHalftoneChange={(value) => + dispatch({ type: 'patchHalftone', value }) + } + onLightingChange={(value) => + dispatch({ type: 'patchLighting', value }) + } + onMaterialChange={(value) => + dispatch({ type: 'patchMaterial', value }) + } + onPreviewDistanceChange={setPreviewDistance} + onShapeChange={(value) => { + void handleShapeChange(value); + }} + onSourceModeChange={handleSourceModeChange} + onTabChange={(value) => dispatch({ type: 'setTab', value })} + onToggleVisibility={() => setControlsVisible((visible) => !visible)} + onUploadSource={() => { + void handleUploadSource(); + }} + previewDistance={previewDistance} + selectedShape={selectedShape} + settings={state.settings} + shapeOptions={shapeOptions} + visible={controlsVisible} + /> + , @@ -185,69 +88,6 @@ export function AnimationsTab({ ) : 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} -
) : ( <> @@ -269,7 +109,7 @@ export function AnimationsTab({ {animation.autoRotateEnabled ? ( <> onAnimationSettingsChange({ 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 index fc6d523fac..ffa25adc29 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/DesignTab.tsx @@ -1,6 +1,12 @@ 'use client'; import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters'; +import type { + HalftoneBackgroundSettings, + HalftoneSourceMode, + HalftoneStudioSettings, +} from '@/app/halftone/_lib/state'; +import { styled } from '@linaria/react'; import { ColorControlLabel, ColorControlRow, @@ -9,112 +15,48 @@ import { Section, SectionTitle, SectionToggleHeader, - SelectControl, SelectInput, ShapeRow, SliderControl, TabContent, UploadButton, - ValueDisplay, } from './controls-ui'; -type HalftoneSourceMode = 'shape' | 'image'; -type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; -type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; +const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg'; +const DEFAULT_IMAGE_OPTION_LABEL = 'Twenty image'; +const IMAGE_SOURCE_VALUE = '__image__'; -interface HalftoneLightingSettings { - intensity: number; - fillIntensity: number; - ambientIntensity: number; - angleDegrees: number; - height: number; -} +const ColorSectionHeader = styled(ColorControlRow)` + margin-bottom: 10px; -interface HalftoneMaterialSettings { - roughness: number; - metalness: number; -} + & > ${SectionTitle} { + margin-bottom: 0; + } +`; -interface HalftoneEffectSettings { - enabled: boolean; - numRows: number; - contrast: number; - power: number; - shading: number; - baseInk: number; - maxBar: number; - rowMerge: number; - cellRatio: number; - cutoff: number; - highlightOpen: number; - shadowGrouping: number; - shadowCrush: number; - dashColor: string; -} +const ColorSwapButton = styled.button` + align-items: center; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.34); + cursor: pointer; + display: inline-flex; + height: 16px; + justify-content: center; + padding: 0; + justify-self: end; + transition: color 0.15s ease; + width: 16px; -interface HalftoneBackgroundSettings { - transparent: boolean; - color: string; -} + &:hover { + color: rgba(255, 255, 255, 0.78); + } -interface HalftoneAnimationSettings { - autoRotateEnabled: boolean; - breatheEnabled: boolean; - cameraParallaxEnabled: boolean; - followHoverEnabled: boolean; - followDragEnabled: boolean; - floatEnabled: boolean; - hoverLightEnabled: boolean; - dragFlowEnabled: boolean; - lightSweepEnabled: boolean; - rotateEnabled: boolean; - autoSpeed: number; - autoWobble: number; - breatheAmount: number; - breatheSpeed: number; - cameraParallaxAmount: number; - cameraParallaxEase: number; - driftAmount: number; - hoverRange: number; - hoverEase: number; - hoverReturn: boolean; - dragSens: number; - dragFriction: number; - dragMomentum: boolean; - rotateAxis: HalftoneRotateAxis; - rotatePreset: HalftoneRotatePreset; - rotateSpeed: number; - rotatePingPong: boolean; - floatAmplitude: number; - floatSpeed: number; - lightSweepHeightRange: number; - lightSweepRange: number; - lightSweepSpeed: number; - springDamping: number; - springReturnEnabled: boolean; - springStrength: number; - hoverLightIntensity: number; - hoverLightRadius: number; - dragFlowDecay: number; - dragFlowRadius: number; - dragFlowStrength: number; - hoverWarpStrength: number; - hoverWarpRadius: number; - dragWarpStrength: number; - waveEnabled: boolean; - waveSpeed: number; - waveAmount: number; -} - -interface HalftoneStudioSettings { - sourceMode: HalftoneSourceMode; - shapeKey: string; - lighting: HalftoneLightingSettings; - material: HalftoneMaterialSettings; - halftone: HalftoneEffectSettings; - background: HalftoneBackgroundSettings; - animation: HalftoneAnimationSettings; -} + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; type DesignTabProps = { imageFileName: string | null; @@ -132,8 +74,7 @@ type DesignTabProps = { onPreviewDistanceChange: (value: number) => void; onShapeChange: (value: string) => void; onSourceModeChange: (value: HalftoneSourceMode) => void; - onUploadImage: () => void; - onUploadModel: () => void; + onUploadSource: () => void; previewDistance: number; settings: HalftoneStudioSettings; shapeOptions: Array<{ label: string; value: string }>; @@ -149,139 +90,124 @@ export function DesignTab({ onPreviewDistanceChange, onShapeChange, onSourceModeChange, - onUploadImage, - onUploadModel, + onUploadSource, previewDistance, settings, shapeOptions, }: DesignTabProps) { const isImageMode = settings.sourceMode === 'image'; + const selectedSourceValue = isImageMode + ? IMAGE_SOURCE_VALUE + : settings.shapeKey; + const imageOptionLabel = + imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME + ? DEFAULT_IMAGE_OPTION_LABEL + : imageFileName; + const handleSwapColors = () => { + const nextDashColor = settings.background.color; + const nextBackgroundColor = settings.halftone.dashColor; + + onDashColorChange(nextDashColor); + onBackgroundChange({ color: nextBackgroundColor }); + }; return (
Source - - onSourceModeChange(event.target.value as HalftoneSourceMode) - } - options={[ - { label: '3D Shape', value: 'shape' }, - { label: 'Image', value: 'image' }, - ]} - value={settings.sourceMode} - > - Mode - + + Source + { + const nextValue = event.target.value; - {isImageMode ? ( - - Image - - {imageFileName ?? 'twenty-logo.svg'} - - - - - - - - - - ) : ( - - Shape - onShapeChange(event.target.value)} - value={settings.shapeKey} - > + if (nextValue === IMAGE_SOURCE_VALUE) { + onSourceModeChange('image'); + return; + } + + onSourceModeChange('shape'); + onShapeChange(nextValue); + }} + value={selectedSourceValue} + > + {shapeOptions.map((option) => ( ))} - - + + + + + + - - - - - - - - )} + + + + + + + +
+ +
+ Visualization + + + onPreviewDistanceChange(Number(event.target.value)) + } + step={0.1} + value={previewDistance} + valueLabel={formatDecimal(previewDistance, 1)} + > + Distance +
{isImageMode ? ( - <> -
- Visualization - - - onPreviewDistanceChange(Number(event.target.value)) - } - step={0.1} - value={previewDistance} - valueLabel={formatDecimal(previewDistance, 1)} - > - Distance - - -
- +
+ Image + + + onHalftoneChange({ + imageContrast: Number(event.target.value), + }) + } + step={0.01} + value={settings.halftone.imageContrast} + valueLabel={formatDecimal(settings.halftone.imageContrast, 2)} + > + Contrast + + +
) : ( <> -
- Visualization - - - onPreviewDistanceChange(Number(event.target.value)) - } - step={0.1} - value={previewDistance} - valueLabel={formatDecimal(previewDistance, 1)} - > - Distance - - -
-
Lighting @@ -395,120 +321,74 @@ export function DesignTab({ {settings.halftone.enabled ? ( - onHalftoneChange({ numRows: Number(event.target.value) }) + onHalftoneChange({ scale: Number(event.target.value) }) } - value={settings.halftone.numRows} - valueLabel={String(settings.halftone.numRows)} + step={0.01} + value={settings.halftone.scale} + valueLabel={formatDecimal(settings.halftone.scale, 2)} > - Rows + Scale - 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} + step={0.01} value={settings.halftone.power} - valueLabel={formatDecimal(settings.halftone.power, 1)} + valueLabel={formatDecimal(settings.halftone.power, 2)} > 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) }) + onHalftoneChange({ width: Number(event.target.value) }) } step={0.01} - value={settings.halftone.baseInk} - valueLabel={formatDecimal(settings.halftone.baseInk)} + value={settings.halftone.width} + valueLabel={formatDecimal(settings.halftone.width, 2)} > - Base Density - - - 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 + Width ) : null}
- Colors + + Colors + + + + Dash 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 index 7085a2b6bf..0f734e9fb5 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/ExportTab.tsx @@ -1,140 +1,44 @@ 'use client'; import { formatAnimationName } from '@/app/halftone/_lib/formatters'; +import type { + HalftoneGeometrySpec, + HalftoneStudioSettings, +} from '@/app/halftone/_lib/state'; import { useState } from 'react'; import { ExportButton, ExportNameInput, ExportNote, ExportPreview, + LabelWithTooltip, Section, SectionTitle, SelectControl, - SmallBody, TabContent, + ToggleControl, } from './controls-ui'; -type HalftoneSourceMode = 'shape' | 'image'; -type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy'; -type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble'; -type HalftoneModelLoader = 'fbx' | 'glb'; - -interface HalftoneLightingSettings { - intensity: number; - fillIntensity: number; - ambientIntensity: number; - angleDegrees: number; - height: number; -} - -interface HalftoneMaterialSettings { - roughness: number; - metalness: number; -} - -interface HalftoneEffectSettings { - enabled: boolean; - numRows: number; - contrast: number; - power: number; - shading: number; - baseInk: number; - maxBar: number; - rowMerge: number; - cellRatio: number; - cutoff: number; - highlightOpen: number; - shadowGrouping: number; - shadowCrush: number; - dashColor: string; -} - -interface HalftoneBackgroundSettings { - transparent: boolean; - color: string; -} - -interface HalftoneAnimationSettings { - autoRotateEnabled: boolean; - breatheEnabled: boolean; - cameraParallaxEnabled: boolean; - followHoverEnabled: boolean; - followDragEnabled: boolean; - floatEnabled: boolean; - hoverLightEnabled: boolean; - dragFlowEnabled: boolean; - lightSweepEnabled: boolean; - rotateEnabled: boolean; - autoSpeed: number; - autoWobble: number; - breatheAmount: number; - breatheSpeed: number; - cameraParallaxAmount: number; - cameraParallaxEase: number; - driftAmount: number; - hoverRange: number; - hoverEase: number; - hoverReturn: boolean; - dragSens: number; - dragFriction: number; - dragMomentum: boolean; - rotateAxis: HalftoneRotateAxis; - rotatePreset: HalftoneRotatePreset; - rotateSpeed: number; - rotatePingPong: boolean; - floatAmplitude: number; - floatSpeed: number; - lightSweepHeightRange: number; - lightSweepRange: number; - lightSweepSpeed: number; - springDamping: number; - springReturnEnabled: boolean; - springStrength: number; - hoverLightIntensity: number; - hoverLightRadius: number; - dragFlowDecay: number; - dragFlowRadius: number; - dragFlowStrength: number; - hoverWarpStrength: number; - hoverWarpRadius: number; - dragWarpStrength: number; - waveEnabled: boolean; - waveSpeed: number; - waveAmount: number; -} - -interface HalftoneStudioSettings { - sourceMode: HalftoneSourceMode; - shapeKey: string; - lighting: HalftoneLightingSettings; - material: HalftoneMaterialSettings; - halftone: HalftoneEffectSettings; - background: HalftoneBackgroundSettings; - animation: HalftoneAnimationSettings; -} - -interface HalftoneGeometrySpec { - key: string; - label: string; - kind: 'builtin' | 'imported'; - loader?: HalftoneModelLoader; - filename?: string; - description?: string; - extensions?: readonly string[]; - userProvided?: boolean; -} - const RESOLUTION_OPTIONS = [ { label: '720p (1280 × 720)', value: '1280x720' }, { label: '1080p (1920 × 1080)', value: '1920x1080' }, { label: '2K (2560 × 1440)', value: '2560x1440' }, { label: '4K (3840 × 2160)', value: '3840x2160' }, ]; +const DEFAULT_IMAGE_FILE_NAME = 'twenty-logo.svg'; +const DEFAULT_IMAGE_LABEL = 'Twenty image'; + +function sectionLabel(label: string, description: string) { + return ; +} type ExportTabProps = { defaultExportName: string; + exportBackground: boolean; exportName: string; + imageFileName: string | null; onExportHalftoneImage: (width: number, height: number) => void; + onExportBackgroundChange: (value: boolean) => void; onExportHtml: () => void; onExportNameChange: (value: string) => void; onExportReact: () => void; @@ -145,8 +49,11 @@ type ExportTabProps = { export function ExportTab({ defaultExportName, + exportBackground, exportName, + imageFileName, onExportHalftoneImage, + onExportBackgroundChange, onExportHtml, onExportNameChange, onExportReact, @@ -160,6 +67,11 @@ export function ExportTab({ settings.animation, settings.sourceMode, ); + const sourceLabel = isImageMode + ? imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME + ? DEFAULT_IMAGE_LABEL + : imageFileName + : (selectedShape?.label ?? settings.shapeKey); const componentName = exportName || defaultExportName; @@ -173,11 +85,39 @@ export function ExportTab({ return (
- Download Image - - Downloads a PNG snapshot of the current halftone effect at the selected - resolution. - + + {sectionLabel( + 'Export Name', + 'Sets the base name used across the PNG, React component, and standalone HTML exports.', + )} + + + onExportNameChange(event.target.value)} + onClick={(event) => event.currentTarget.select()} + onFocus={(event) => event.currentTarget.select()} + placeholder={defaultExportName} + type="text" + value={componentName} + /> + + onExportBackgroundChange(event.target.checked)} + /> +
+ +
+ + {sectionLabel( + 'Download Image', + 'Downloads a PNG snapshot of the current halftone effect at the selected resolution.', + )} + setResolution(event.target.value)} @@ -197,18 +137,12 @@ export function ExportTab({
- 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} - /> + + {sectionLabel( + 'Code Exports', + 'Downloads either a self-contained React component or standalone HTML with the current design and animation settings baked in. React exports require three.', + )} +
@@ -230,7 +164,7 @@ export function ExportTab({

- {`// Shape: ${selectedShape?.key ?? settings.shapeKey}`} + {`// Source: ${sourceLabel}`}
{`// Animation: ${animationLabel}`} @@ -239,7 +173,13 @@ export function ExportTab({ {`// Dash color: ${settings.halftone.dashColor}`}
- {`// Rows: ${settings.halftone.numRows}`} + {`// Scale: ${settings.halftone.scale.toFixed(2)}`} +
+
+ {`// Power: ${settings.halftone.power.toFixed(2)}`} +
+
+ {`// Width: ${settings.halftone.width.toFixed(2)}`}
@@ -260,12 +200,12 @@ export function ExportTab({
- 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. - + + {sectionLabel( + 'Import Preset', + 'Loads a previously exported TSX or HTML halftone preset. If that preset depends on a separate image or model file, select that asset in the picker too.', + )} + 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 index af3842f145..037d071a90 100644 --- 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 @@ -14,7 +14,7 @@ import { createPortal } from 'react-dom'; const TAB_LABEL_WIDTH = 72; -export const PanelShell = styled.aside` +export const PanelShell = styled.aside<{ $collapsed?: boolean }>` background: rgba(18, 18, 22, 0.88); backdrop-filter: blur(24px) saturate(1.4); border: 1px solid rgba(255, 255, 255, 0.08); @@ -27,22 +27,24 @@ export const PanelShell = styled.aside` flex-direction: column; font-family: ${theme.font.family.sans}; font-size: 11px; - height: 100%; + height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; overflow: hidden; - width: min(320px, calc(100vw - 32px)); + width: ${(props) => + props.$collapsed ? 'auto' : 'min(320px, calc(100vw - 32px))'}; @media (max-width: ${theme.breakpoints.md - 1}px) { - height: 100%; - width: 100%; + height: ${(props) => (props.$collapsed ? 'auto' : '100%')}; + width: ${(props) => (props.$collapsed ? 'auto' : '100%')}; } `; -export const TabsBar = styled.div` +export const TabsBar = styled.div<{ $collapsed?: boolean }>` + align-items: center; display: flex; flex-shrink: 0; gap: 6px; margin: 0; - padding: 12px 12px 0; + padding: ${(props) => (props.$collapsed ? '12px' : '12px 12px 0')}; `; export const TabButton = styled.button<{ $active: boolean }>` @@ -65,9 +67,7 @@ export const TabButton = styled.button<{ $active: boolean }>` &:hover { background: ${(props) => - props.$active - ? 'rgba(255, 255, 255, 0.1)' - : 'rgba(255, 255, 255, 0.04)'}; + props.$active ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.04)'}; color: ${(props) => props.$active ? 'rgba(255, 255, 255, 0.94)' @@ -527,7 +527,9 @@ export function ColorField({ ariaLabel, onChange, value }: ColorFieldProps) { aria-label={`${ariaLabel} hex value`} maxLength={7} onBlur={commitDraftValue} + onClick={(event) => event.currentTarget.select()} onChange={(event) => setDraftValue(event.target.value.toUpperCase())} + onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); @@ -629,30 +631,53 @@ export const ExportPreview = styled.div` export const ExportButton = styled.button<{ $primary?: boolean }>` align-items: center; background: ${(props) => - props.$primary ? '#4A38F5' : 'rgba(255, 255, 255, 0.08)'}; - border: 1px solid - ${(props) => - props.$primary ? 'rgba(116, 98, 255, 0.7)' : 'rgba(255, 255, 255, 0.12)'}; + props.$primary ? 'rgba(255, 255, 255, 0.24)' : 'rgba(255, 255, 255, 0.2)'}; + border: none; border-radius: 8px; - color: ${(props) => (props.$primary ? '#fff' : 'rgba(255, 255, 255, 0.8)')}; + color: ${(props) => + props.$primary ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.8)'}; cursor: pointer; display: flex; font-family: ${theme.font.family.sans}; font-size: 11px; - font-weight: 600; + font-weight: ${theme.font.weight.medium}; gap: 8px; justify-content: center; + line-height: normal; margin-top: ${(props) => (props.$primary ? '0' : '8px')}; - min-height: 31px; padding: 7px 12px; - transition: all 0.2s ease; + transition: + background-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; width: 100%; &:hover { background: ${(props) => - props.$primary ? '#5a4af7' : 'rgba(255, 255, 255, 0.14)'}; - border-color: ${(props) => - props.$primary ? 'rgba(130, 114, 255, 0.9)' : 'rgba(255, 255, 255, 0.22)'}; + props.$primary + ? 'rgba(255, 255, 255, 0.28)' + : 'rgba(255, 255, 255, 0.24)'}; + color: rgba(255, 255, 255, 0.92); + } + + &:active { + background: ${(props) => + props.$primary + ? 'rgba(255, 255, 255, 0.3)' + : 'rgba(255, 255, 255, 0.28)'}; + transform: translateY(1px); + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.36); + outline-offset: 2px; + } + + &:disabled { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.36); + cursor: not-allowed; + transform: none; } `; @@ -721,9 +746,7 @@ function clampAndSnapValue( const precision = getStepPrecision(step); const snappedValue = Math.round((clampedValue - min) / step) * step + min; - return Number( - Math.min(Math.max(snappedValue, min), max).toFixed(precision), - ); + return Number(Math.min(Math.max(snappedValue, min), max).toFixed(precision)); } export function SliderControl({ diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts index 0bb78df189..6244665f9b 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts @@ -1,4 +1,15 @@ -const VIRTUAL_RENDER_HEIGHT = 768; +import { + HALFTONE_FOOTPRINT_RUNTIME_SOURCE, + REFERENCE_PREVIEW_DISTANCE, + VIRTUAL_RENDER_HEIGHT, +} from '@/app/halftone/_lib/footprint'; +import { + LEGACY_HALFTONE_SETTING_KEYS, + isRoundedBandHalftoneSettings, + type HalftoneExportPose, + type HalftoneGeometrySpec, + type HalftoneStudioSettings, +} from '@/app/halftone/_lib/state'; const passThroughVertexShader = /* glsl */ ` varying vec2 vUv; @@ -48,6 +59,7 @@ const imagePassthroughFragmentShader = /* glsl */ ` uniform vec2 imageSize; uniform vec2 viewportSize; uniform float zoom; + uniform float contrast; varying vec2 vUv; @@ -72,8 +84,9 @@ const imagePassthroughFragmentShader = /* glsl */ ` * step(0.0, uv.y) * step(uv.y, 1.0); vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0)); + vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0); - gl_FragColor = vec4(color.rgb, inBounds); + gl_FragColor = vec4(contrastColor, inBounds); } `; @@ -82,25 +95,16 @@ const halftoneFragmentShader = /* glsl */ ` 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 vec2 effectResolution; + uniform vec2 logicalResolution; + uniform float tile; + uniform float s_3; + uniform float s_4; uniform vec3 dashColor; uniform float time; uniform float waveAmount; uniform float waveSpeed; - uniform float distanceScale; + uniform float footprintScale; uniform vec2 interactionUv; uniform vec2 interactionVelocity; uniform vec2 dragOffset; @@ -109,11 +113,26 @@ const halftoneFragmentShader = /* glsl */ ` uniform float hoverFlowStrength; uniform float hoverFlowRadius; uniform float dragFlowStrength; - uniform float dragFlowRadius; uniform float cropToBounds; varying vec2 vUv; + float distSegment(in vec2 p, in vec2 a, in vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float denom = max(dot(ba, ba), 0.000001); + float h = clamp(dot(pa, ba) / denom, 0.0, 1.0); + return length(pa - ba * h); + } + + float lineSimpleEt(in vec2 p, in float r, in float thickness) { + vec2 a = vec2(0.5) + vec2(-r, 0.0); + vec2 b = vec2(0.5) + vec2(r, 0.0); + float distToSegment = distSegment(p, a, b); + float halfThickness = thickness * r; + return distToSegment - halfThickness; + } + void main() { // Crop to image bounds: discard fragments outside source image (image mode only) if (cropToBounds > 0.5) { @@ -124,9 +143,11 @@ const halftoneFragmentShader = /* glsl */ ` } } - float baseRowH = resolution.y / (numRows * distanceScale); - vec2 pointerPx = interactionUv * resolution; - vec2 fragDelta = gl_FragCoord.xy - pointerPx; + vec2 fragCoord = + (gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution; + float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0); + vec2 pointerPx = interactionUv * logicalResolution; + vec2 fragDelta = fragCoord - pointerPx; float fragDist = length(fragDelta); vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0); float velocityMagnitude = length(interactionVelocity); @@ -139,259 +160,68 @@ const halftoneFragmentShader = /* glsl */ ` float hoverLightMask = 0.0; if (hoverLightStrength > 0.0) { - float lightRadiusPx = hoverLightRadius * resolution.y; + float lightRadiusPx = hoverLightRadius * logicalResolution.y; hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); } float hoverFlowMask = 0.0; if (hoverFlowStrength > 0.0) { - float hoverRadiusPx = hoverFlowRadius * resolution.y; + float hoverRadiusPx = hoverFlowRadius * logicalResolution.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; + radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 + + motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15; + vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45; + vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement; - float densityBoost = - hoverFlowStrength * hoverFlowMask * 0.22 + - dragFlowStrength * dragFlowMask * 0.16; - float rowH = baseRowH / (1.0 + densityBoost); + float bandRow = floor(effectCoord.y / halftoneSize); + float waveOffset = + waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize; + effectCoord.x += waveOffset; - 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) + vec2 cellIndex = floor(effectCoord / halftoneSize); + vec2 sampleUv = clamp( + (cellIndex + 0.5) * halftoneSize / logicalResolution, + vec2(0.0), + vec2(1.0) ); + vec2 cellUv = fract(effectCoord / halftoneSize); 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), + hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22; + float bandRadius = clamp( + ( + ( + sceneSample.r + + sceneSample.g + + sceneSample.b + + s_3 * length(vec2(0.5)) + ) * + (1.0 / 3.0) + ) + lightLift, 0.0, 1.0 - ); + ) * 1.86 * 0.5; - 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); + float alpha = 0.0; + if (bandRadius > 0.0001) { + float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4); + float edge = 0.02; + alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; } - 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); + vec3 color = dashColor * alpha; + gl_FragColor = vec4(color, alpha); #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(); @@ -964,6 +794,7 @@ export type ParsedExportedPreset = { imageAssetReference: string | null; initialPose: HalftoneExportPose; modelAssetReference: string | null; + previewDistance: number; settings: HalftoneStudioSettings; shape: ExportedShapeDescriptor; }; @@ -1006,6 +837,12 @@ function normalizeExportPose( }; } +function normalizePreviewDistance(previewDistance: number | undefined) { + return Number.isFinite(previewDistance) + ? Math.max(previewDistance ?? REFERENCE_PREVIEW_DISTANCE, 0.001) + : REFERENCE_PREVIEW_DISTANCE; +} + function toPascalCase(value: string) { const tokens = value .replace(/\.[^.]+$/, '') @@ -1063,23 +900,64 @@ function extractFirstMatch(content: string, patterns: RegExp[]) { return null; } +function assertRoundedBandPreset( + settings: HalftoneStudioSettings, +): asserts settings is HalftoneStudioSettings { + if (isRoundedBandHalftoneSettings(settings.halftone)) { + return; + } + + const halftoneValue = + settings.halftone && typeof settings.halftone === 'object' + ? (settings.halftone as Record) + : null; + const detectedLegacyKeys = LEGACY_HALFTONE_SETTING_KEYS.filter((key) => + halftoneValue ? key in halftoneValue : false, + ); + const suffix = + detectedLegacyKeys.length > 0 + ? ` Detected legacy keys: ${detectedLegacyKeys.join(', ')}.` + : ''; + + throw new Error( + `This preset uses the legacy halftone effect and can no longer be imported.${suffix}`, + ); +} + export function parseExportedPreset(content: string): ParsedExportedPreset { const settings = extractSerializedJson( content, 'settings', 'shape', ); + assertRoundedBandPreset(settings); const shape = extractSerializedJson( content, 'shape', 'initialPose', ); const initialPose = normalizeExportPose( - extractSerializedJson( - content, - 'initialPose', - 'VIRTUAL_RENDER_HEIGHT', - ), + (() => { + try { + return extractSerializedJson( + content, + 'initialPose', + 'previewDistance', + ); + } catch { + return extractSerializedJson( + content, + 'initialPose', + 'VIRTUAL_RENDER_HEIGHT', + ); + } + })(), + ); + const previewDistanceMatch = content.match( + /const\s+previewDistance\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;/, + ); + const previewDistance = normalizePreviewDistance( + previewDistanceMatch ? Number(previewDistanceMatch[1]) : undefined, ); const componentName = extractFirstMatch(content, [ @@ -1100,6 +978,7 @@ export function parseExportedPreset(content: string): ParsedExportedPreset { imageAssetReference, initialPose, modelAssetReference, + previewDistance, settings, shape, }; @@ -1204,19 +1083,24 @@ function serializeRuntimeSource( settings: HalftoneStudioSettings, shape: ExportedShapeDescriptor, initialPose: HalftoneExportPose, + previewDistance: number | undefined, ) { const isImageMode = settings.sourceMode === 'image'; + const normalizedPreviewDistance = normalizePreviewDistance(previewDistance); return ` const settings = ${JSON.stringify(settings, null, 2)}; const shape = ${JSON.stringify(shape, null, 2)}; const initialPose = ${JSON.stringify(initialPose, null, 2)}; +const previewDistance = ${JSON.stringify(normalizedPreviewDistance)}; 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)};` : ''} +${HALFTONE_FOOTPRINT_RUNTIME_SOURCE} + ${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE} ${isImageMode ? '' : IMPORTED_RUNTIME_SOURCE} @@ -1233,8 +1117,6 @@ function createInteractionState() { return { autoElapsed: initialPose.autoElapsed, activePointerId: null, - dragOffsetX: 0, - dragOffsetY: 0, dragging: false, mouseX: 0.5, mouseY: 0.5, @@ -1338,7 +1220,7 @@ async function mountHalftoneCanvas(options) { renderer.setSize(getVirtualWidth(), getVirtualHeight(), false); const canvas = renderer.domElement; - canvas.style.cursor = 'grab'; + canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default'; canvas.style.display = 'block'; canvas.style.height = '100%'; canvas.style.touchAction = 'none'; @@ -1355,7 +1237,7 @@ async function mountHalftoneCanvas(options) { const scene3d = new THREE.Scene(); scene3d.background = null; - const baseCameraDistance = 4; + const baseCameraDistance = previewDistance; const camera = new THREE.PerspectiveCamera(45, getWidth() / getHeight(), 0.1, 100); camera.position.z = baseCameraDistance; @@ -1426,27 +1308,20 @@ async function mountHalftoneCanvas(options) { uniforms: { tScene: { value: sceneTarget.texture }, tGlow: { value: blurTargetB.texture }, - resolution: { + effectResolution: { 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 }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + tile: { value: settings.halftone.scale }, + s_3: { value: settings.halftone.power }, + s_4: { value: settings.halftone.width }, dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, time: { value: 0 }, waveAmount: { value: 0 }, waveSpeed: { value: 1 }, - distanceScale: { value: 1.0 }, + footprintScale: { 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) }, @@ -1455,7 +1330,6 @@ async function mountHalftoneCanvas(options) { hoverFlowStrength: { value: 0 }, hoverFlowRadius: { value: 0.18 }, dragFlowStrength: { value: 0 }, - dragFlowRadius: { value: 0.24 }, cropToBounds: { value: 0 }, }, vertexShader: passThroughVertexShader, @@ -1473,6 +1347,46 @@ async function mountHalftoneCanvas(options) { const postScene = new THREE.Scene(); postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + const updateViewportUniforms = ( + logicalWidth, + logicalHeight, + effectWidth, + effectHeight, + ) => { + blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + halftoneMaterial.uniforms.effectResolution.value.set( + effectWidth, + effectHeight, + ); + halftoneMaterial.uniforms.logicalResolution.value.set( + logicalWidth, + logicalHeight, + ); + }; + + const getHalftoneScale = (viewportWidth, viewportHeight, lookAtTarget) => { + if (!mesh.geometry.boundingBox) { + mesh.geometry.computeBoundingBox(); + } + + if (!mesh.geometry.boundingBox) { + return 1; + } + + mesh.updateMatrixWorld(); + camera.updateMatrixWorld(); + + return getMeshFootprintScale({ + camera, + localBounds: mesh.geometry.boundingBox, + lookAtTarget, + meshMatrixWorld: mesh.matrixWorld, + viewportHeight, + viewportWidth, + }); + }; + const interaction = createInteractionState(); const autoRotateEnabled = settings.animation.autoRotateEnabled; const followHoverEnabled = settings.animation.followHoverEnabled; @@ -1491,9 +1405,12 @@ async function mountHalftoneCanvas(options) { 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); + updateViewportUniforms( + virtualWidth, + virtualHeight, + virtualWidth, + virtualHeight, + ); }; const resizeObserver = new ResizeObserver(syncSize); @@ -1518,6 +1435,10 @@ async function mountHalftoneCanvas(options) { const handlePointerDown = (event) => { updatePointerPosition(event); + if (!followDragEnabled) { + return; + } + interaction.dragging = true; interaction.pointerX = event.clientX; interaction.pointerY = event.clientY; @@ -1533,10 +1454,7 @@ async function mountHalftoneCanvas(options) { const handleWindowPointerMove = (event) => { updatePointerPosition(event); - if ( - !interaction.dragging || - (!followDragEnabled && !autoRotateEnabled) - ) { + if (!interaction.dragging || !followDragEnabled) { return; } @@ -1561,7 +1479,7 @@ async function mountHalftoneCanvas(options) { const handlePointerUp = () => { interaction.dragging = false; - canvas.style.cursor = 'grab'; + canvas.style.cursor = followDragEnabled ? 'grab' : 'default'; if (!settings.animation.springReturnEnabled) { return; @@ -1608,14 +1526,7 @@ async function mountHalftoneCanvas(options) { 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; - } - + interaction.autoElapsed += delta; baseRotationY += interaction.autoElapsed * settings.animation.autoSpeed; baseRotationX += Math.sin(interaction.autoElapsed * 0.2) * settings.animation.autoWobble; } @@ -1805,8 +1716,15 @@ async function mountHalftoneCanvas(options) { camera.position.z += (baseCameraDistance - camera.position.z) * 0.12; } - camera.lookAt(0, meshOffsetY * 0.2, 0); + const lookAtTarget = new THREE.Vector3(0, meshOffsetY * 0.2, 0); + + camera.lookAt(lookAtTarget); setPrimaryLightPosition(primaryLight, lightAngle, lightHeight); + halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale( + getVirtualWidth(), + getVirtualHeight(), + lookAtTarget, + ); if (!settings.halftone.enabled) { renderer.setRenderTarget(null); @@ -1924,7 +1842,8 @@ async function mountHalftoneCanvas(options) { tImage: { value: imageTexture }, imageSize: { value: new THREE.Vector2(image.width, image.height) }, viewportSize: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) }, - zoom: { value: 1 }, + zoom: { value: getImagePreviewZoom(previewDistance) }, + contrast: { value: settings.halftone.imageContrast }, }, vertexShader: passThroughVertexShader, fragmentShader: imagePassthroughFragmentShader, @@ -1958,27 +1877,20 @@ async function mountHalftoneCanvas(options) { uniforms: { tScene: { value: sceneTarget.texture }, tGlow: { value: blurTargetB.texture }, - resolution: { + effectResolution: { 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 }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + tile: { value: settings.halftone.scale }, + s_3: { value: settings.halftone.power }, + s_4: { value: settings.halftone.width }, dashColor: { value: new THREE.Color(settings.halftone.dashColor) }, time: { value: 0 }, waveAmount: { value: 0 }, waveSpeed: { value: settings.animation.waveSpeed }, - distanceScale: { value: 1.0 }, + footprintScale: { 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) }, @@ -1987,7 +1899,6 @@ async function mountHalftoneCanvas(options) { hoverFlowStrength: { value: 0 }, hoverFlowRadius: { value: 0.18 }, dragFlowStrength: { value: 0 }, - dragFlowRadius: { value: settings.animation.dragFlowRadius }, cropToBounds: { value: 1 }, }, vertexShader: passThroughVertexShader, @@ -2003,10 +1914,37 @@ async function mountHalftoneCanvas(options) { const postScene = new THREE.Scene(); postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + const updateViewportUniforms = ( + logicalWidth, + logicalHeight, + effectWidth, + effectHeight, + ) => { + blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight); + halftoneMaterial.uniforms.effectResolution.value.set( + effectWidth, + effectHeight, + ); + halftoneMaterial.uniforms.logicalResolution.value.set( + logicalWidth, + logicalHeight, + ); + imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight); + }; + + const getHalftoneScale = () => + getImageFootprintScale({ + imageHeight: image.height, + imageWidth: image.width, + previewDistance, + viewportHeight: getVirtualHeight(), + viewportWidth: getVirtualWidth(), + }); + const interaction = createInteractionState(); const imagePointerFollow = 0.38; const imagePointerVelocityDamping = 0.82; - const imageDragOffsetLimit = 0.08; const syncSize = () => { const virtualWidth = getVirtualWidth(); @@ -2016,10 +1954,12 @@ async function mountHalftoneCanvas(options) { 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); + updateViewportUniforms( + virtualWidth, + virtualHeight, + virtualWidth, + virtualHeight, + ); }; const resizeObserver = new ResizeObserver(syncSize); @@ -2082,55 +2022,14 @@ async function mountHalftoneCanvas(options) { 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( + 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 = () => { @@ -2160,8 +2059,6 @@ async function mountHalftoneCanvas(options) { releasePointerCapture(interaction.activePointerId); interaction.activePointerId = null; interaction.dragging = false; - interaction.dragOffsetX = 0; - interaction.dragOffsetY = 0; interaction.pointerInside = false; interaction.pointerVelocityX = 0; interaction.pointerVelocityY = 0; @@ -2186,39 +2083,15 @@ async function mountHalftoneCanvas(options) { const elapsedTime = clock.getElapsedTime(); halftoneMaterial.uniforms.time.value = elapsedTime; - const pointerFollow = interaction.dragging ? 0.46 : imagePointerFollow; - const pointerActive = interaction.pointerInside || interaction.dragging; + const pointerActive = interaction.pointerInside; interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * pointerFollow; + (interaction.mouseX - interaction.smoothedMouseX) * imagePointerFollow; interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * pointerFollow; + (interaction.mouseY - interaction.smoothedMouseY) * imagePointerFollow; 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, @@ -2227,10 +2100,7 @@ async function mountHalftoneCanvas(options) { interaction.pointerVelocityX * getVirtualWidth(), -interaction.pointerVelocityY * getVirtualHeight(), ); - halftoneMaterial.uniforms.dragOffset.value.set( - interaction.dragOffsetX * getVirtualWidth(), - -interaction.dragOffsetY * getVirtualHeight(), - ); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); halftoneMaterial.uniforms.hoverLightStrength.value = pointerActive && settings.animation.hoverLightEnabled ? settings.animation.hoverLightIntensity @@ -2239,11 +2109,9 @@ async function mountHalftoneCanvas(options) { 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; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + imageMaterial.uniforms.zoom.value = getImagePreviewZoom(previewDistance); + halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale(); renderer.setRenderTarget(sceneTarget); renderer.render(imageScene, orthographicCamera); @@ -2316,8 +2184,10 @@ export function generateReactComponent( componentName = 'HalftoneDashes', modelFilenameOverride?: string, initialPose?: HalftoneExportPose, + previewDistance?: number, importedFile?: File, imageFilename?: string, + background = 'transparent', ) { const isImageMode = settings.sourceMode === 'image'; const shape = createShapeDescriptor( @@ -2330,13 +2200,11 @@ export function generateReactComponent( 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)} +${serializeRuntimeSource(settings, shape, pose, previewDistance)} ${createImageMountScript()} @@ -2392,7 +2260,7 @@ import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; -${serializeRuntimeSource(settings, shape, pose)} +${serializeRuntimeSource(settings, shape, pose, previewDistance)} ${createMountScript()} @@ -2448,8 +2316,10 @@ export async function generateStandaloneHtml( componentName = 'HalftoneDashes', modelFilenameOverride?: string, initialPose?: HalftoneExportPose, + previewDistance?: number, importedFile?: File, imageFilename?: string, + background = 'transparent', ) { const isImageMode = settings.sourceMode === 'image'; const shape = createShapeDescriptor( @@ -2460,7 +2330,6 @@ export async function generateStandaloneHtml( ); const pose = normalizeExportPose(initialPose); const defaultImageUrl = imageFilename ?? 'image.png'; - const background = 'transparent'; const embeddedImportedModelUrl = !isImageMode && shape.kind === 'imported' && importedFile ? await fileToDataUrl(importedFile, shape.loader) @@ -2585,7 +2454,7 @@ export async function generateStandaloneHtml(