diff --git a/packages/twenty-website-new/public/halftone/materials/glass/environment.jpg b/packages/twenty-website-new/public/halftone/materials/glass/environment.jpg new file mode 100644 index 0000000000..0ddd056788 Binary files /dev/null and b/packages/twenty-website-new/public/halftone/materials/glass/environment.jpg differ diff --git a/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx index 9d52669f2b..b7dabd2b12 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/ControlsPanel.tsx @@ -1,9 +1,6 @@ 'use client'; -import { - IconLayoutSidebarRightCollapse, - IconShare, -} from '@tabler/icons-react'; +import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react'; import { styled } from '@linaria/react'; import type { HalftoneBackgroundSettings, @@ -191,6 +188,7 @@ export function ControlsPanel({ {visible && activeTab === 'design' ? ( viewAspect) { float scale = viewAspect / imageAspect; uv.y = (uv.y - 0.5) / scale + 0.5; @@ -94,7 +101,7 @@ const imagePassthroughFragmentShader = /* glsl */ ` } `; -const halftoneFragmentShader = /* glsl */ ` +const halftoneFragmentShader = ` precision highp float; uniform sampler2D tScene; @@ -112,6 +119,9 @@ const halftoneFragmentShader = /* glsl */ ` uniform vec2 interactionUv; uniform vec2 interactionVelocity; uniform vec2 dragOffset; + uniform float hoverHalftonePowerShift; + uniform float hoverHalftoneRadius; + uniform float hoverHalftoneWidthShift; uniform float hoverLightStrength; uniform float hoverLightRadius; uniform float hoverFlowStrength; @@ -138,7 +148,6 @@ const halftoneFragmentShader = /* glsl */ ` } void main() { - // Crop to image bounds: discard fragments outside source image (image mode only) if (cropToBounds > 0.5) { vec4 boundsCheck = texture2D(tScene, vUv); if (boundsCheck.a < 0.01) { @@ -168,6 +177,15 @@ const halftoneFragmentShader = /* glsl */ ` hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); } + float hoverHalftoneMask = 0.0; + if ( + abs(hoverHalftonePowerShift) > 0.0001 || + abs(hoverHalftoneWidthShift) > 0.0001 + ) { + float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y; + hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist); + } + float hoverFlowMask = 0.0; if (hoverFlowStrength > 0.0) { float hoverRadiusPx = hoverFlowRadius * logicalResolution.y; @@ -195,6 +213,16 @@ const halftoneFragmentShader = /* glsl */ ` vec4 sceneSample = texture2D(tScene, sampleUv); float mask = smoothstep(0.02, 0.08, sceneSample.a); + float localPower = clamp( + s_3 + hoverHalftonePowerShift * hoverHalftoneMask, + -1.5, + 1.5 + ); + float localWidth = clamp( + s_4 + hoverHalftoneWidthShift * hoverHalftoneMask, + 0.05, + 1.4 + ); float lightLift = hoverLightStrength * hoverLightMask * @@ -206,7 +234,7 @@ const halftoneFragmentShader = /* glsl */ ` sceneSample.r + sceneSample.g + sceneSample.b + - s_3 * length(vec2(0.5)) + localPower * length(vec2(0.5)) ) * (1.0 / 3.0) ) + lightLift, @@ -216,7 +244,7 @@ const halftoneFragmentShader = /* glsl */ ` float alpha = 0.0; if (bandRadius > 0.0001) { - float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4); + float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth); float edge = 0.02; alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; } @@ -233,17 +261,6 @@ const IMAGE_POINTER_FOLLOW = 0.38; const IMAGE_POINTER_VELOCITY_DAMPING = 0.82; const MAX_PREVIEW_PIXEL_RATIO = 2; -function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); - - return environmentTexture; -} - const CanvasMount = styled.div<{ $background: string }>` background: ${(props) => props.$background}; display: block; @@ -282,14 +299,14 @@ type SceneResources = { blurVerticalScene: THREE.Scene; camera: THREE.PerspectiveCamera; canvas: HTMLCanvasElement; - environmentTexture: THREE.Texture; fillLight: THREE.DirectionalLight; fullScreenGeometry: THREE.PlaneGeometry; halftoneMaterial: THREE.ShaderMaterial; imageMaterial: THREE.ShaderMaterial; imageScene: THREE.Scene; imageTexture: THREE.Texture | null; - material: THREE.MeshPhysicalMaterial; + materialAssets: HalftoneMaterialAssets; + material: HalftoneTransmissionMaterial; mesh: THREE.Mesh; orthographicCamera: THREE.OrthographicCamera; postScene: THREE.Scene; @@ -297,6 +314,8 @@ type SceneResources = { renderer: THREE.WebGLRenderer; scene3d: THREE.Scene; sceneTarget: THREE.WebGLRenderTarget; + transmissionBacksideTarget: THREE.WebGLRenderTarget; + transmissionTarget: THREE.WebGLRenderTarget; }; type InteractionState = { @@ -455,9 +474,11 @@ function updateMaterial( resources: SceneResources, settings: HalftoneStudioSettings, ) { - resources.material.roughness = settings.material.roughness; - resources.material.metalness = settings.material.metalness; - resources.material.needsUpdate = true; + applyHalftoneMaterialSettings( + resources.material, + settings.material, + resources.materialAssets, + ); } function updateHalftone( @@ -552,6 +573,8 @@ export function HalftoneCanvas({ const initialPoseReference = useRef(initialPose); const poseChangeReference = useRef(onPoseChange); const previewDistanceReference = useRef(previewDistance); + const geometryReference = useRef(geometry); + const snapshotReference = useRef(snapshotRef); useEffect(() => { initialPoseReference.current = initialPose; @@ -591,6 +614,7 @@ export function HalftoneCanvas({ prev.autoRotateEnabled !== next.autoRotateEnabled || prev.followHoverEnabled !== next.followHoverEnabled || prev.followDragEnabled !== next.followDragEnabled || + prev.hoverHalftoneEnabled !== next.hoverHalftoneEnabled || prev.hoverLightEnabled !== next.hoverLightEnabled || prev.dragFlowEnabled !== next.dragFlowEnabled ) { @@ -613,6 +637,8 @@ export function HalftoneCanvas({ }, [settings]); useEffect(() => { + geometryReference.current = geometry; + const resources = resourcesReference.current; if (!resources || !geometry) { @@ -622,6 +648,10 @@ export function HalftoneCanvas({ resources.mesh.geometry = geometry; }, [geometry]); + useEffect(() => { + snapshotReference.current = snapshotRef; + }, [snapshotRef]); + useEffect(() => { const resources = resourcesReference.current; @@ -654,8 +684,11 @@ export function HalftoneCanvas({ useEffect(() => { const container = mountReference.current; + const initialSettings = settingsReference.current; + const initialPreviewDistance = previewDistanceReference.current; + const activeSnapshotRef = snapshotReference.current; - if (!container || !geometry) { + if (!container || !geometryReference.current) { return; } @@ -693,1123 +726,1259 @@ export function HalftoneCanvas({ canvas.style.width = '100%'; container.appendChild(canvas); - const environmentTexture = createEnvironmentTexture(renderer); - - const scene3d = new THREE.Scene(); - scene3d.background = null; - - const camera = new THREE.PerspectiveCamera( - 45, - getWidth() / getHeight(), - 0.1, - 100, - ); - camera.position.z = previewDistance; - - const primaryLight = new THREE.DirectionalLight(0xffffff, 1.5); - scene3d.add(primaryLight); - - const fillLight = new THREE.DirectionalLight(0xffffff, 0.15); - fillLight.position.set(-3, -1, 1); - scene3d.add(fillLight); - - const ambientLight = new THREE.AmbientLight(0xffffff, 0.08); - scene3d.add(ambientLight); - - const material = new THREE.MeshPhysicalMaterial({ - color: 0xd4d0c8, - roughness: 0.42, - metalness: 0.16, - envMap: environmentTexture, - envMapIntensity: 0.25, - clearcoat: 0, - clearcoatRoughness: 0.08, - reflectivity: 0.5, - transmission: 0, - }); - - const mesh = new THREE.Mesh(geometry, material); - scene3d.add(mesh); - - const sceneTarget = createRenderTarget(getRenderWidth(), getRenderHeight()); - const blurTargetA = createRenderTarget(getRenderWidth(), getRenderHeight()); - const blurTargetB = createRenderTarget(getRenderWidth(), getRenderHeight()); - const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); - const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); - - const blurHorizontalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(1, 0) }, - res: { - value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const blurVerticalMaterial = new THREE.ShaderMaterial({ - uniforms: { - tInput: { value: null }, - dir: { value: new THREE.Vector2(0, 1) }, - res: { - value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), - }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: blurFragmentShader, - }); - - const halftoneMaterial = new THREE.ShaderMaterial({ - transparent: true, - uniforms: { - tScene: { value: sceneTarget.texture }, - tGlow: { value: blurTargetB.texture }, - effectResolution: { - value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), - }, - 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 }, - 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) }, - hoverLightStrength: { value: 0 }, - hoverLightRadius: { value: 0.2 }, - hoverFlowStrength: { value: 0 }, - hoverFlowRadius: { value: 0.18 }, - dragFlowStrength: { value: 0 }, - cropToBounds: { value: 0 }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: halftoneFragmentShader, - }); - - const blurHorizontalScene = new THREE.Scene(); - blurHorizontalScene.add( - new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), - ); - - const blurVerticalScene = new THREE.Scene(); - blurVerticalScene.add( - new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), - ); - - const postScene = new THREE.Scene(); - postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); - - const imageMaterial = new THREE.ShaderMaterial({ - uniforms: { - tImage: { value: null }, - imageSize: { value: new THREE.Vector2(1, 1) }, - viewportSize: { - value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), - }, - zoom: { value: getImagePreviewZoom(previewDistance) }, - contrast: { value: settings.halftone.imageContrast }, - }, - vertexShader: passThroughVertexShader, - fragmentShader: imagePassthroughFragmentShader, - }); - - const imageScene = new THREE.Scene(); - imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); - - const resources: SceneResources = { - ambientLight, - blurHorizontalMaterial, - blurHorizontalScene, - blurTargetA, - blurTargetB, - blurVerticalMaterial, - blurVerticalScene, - camera, - canvas, - environmentTexture, - fillLight, - fullScreenGeometry, - halftoneMaterial, - imageMaterial, - imageScene, - imageTexture: null, - material, - mesh, - orthographicCamera, - postScene, - primaryLight, - renderer, - scene3d, - sceneTarget, - }; - - 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); - - // Snapshot: render the current frame at arbitrary resolution and return a PNG blob - 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); - const snapBlurA = createRenderTarget(snapshotWidth, snapshotHeight); - const snapBlurB = createRenderTarget(snapshotWidth, snapshotHeight); - - // Save current renderer state - const prevSize = renderer.getSize(new THREE.Vector2()); - - // Resize renderer to snapshot resolution - renderer.setSize(snapshotWidth, snapshotHeight, false); - - // Update material uniforms for snapshot resolution - updateViewportUniforms( - snapshotWidth, - snapshotHeight, - snapshotWidth, - snapshotHeight, - ); - halftoneMaterial.uniforms.hoverLightStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - halftoneMaterial.uniforms.cropToBounds.value = isImage ? 1 : 0; - - if (isImage) { - imageMaterial.uniforms.zoom.value = getImagePreviewZoom( - previewDistanceReference.current, - ); - halftoneMaterial.uniforms.footprintScale.value = getImageHalftoneScale( - snapshotWidth, - snapshotHeight, - previewDistanceReference.current, - ); - } 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 - renderer.setRenderTarget(snapScene); - if (isImage) { - renderer.render(imageScene, orthographicCamera); - } else { - renderer.render(scene3d, camera); - } - - // Blur passes - halftoneMaterial.uniforms.tScene.value = snapScene.texture; - - blurHorizontalMaterial.uniforms.tInput.value = snapScene.texture; - renderer.setRenderTarget(snapBlurA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; - renderer.setRenderTarget(snapBlurB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = snapBlurB.texture; - renderer.setRenderTarget(snapBlurA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; - renderer.setRenderTarget(snapBlurB); - renderer.render(blurVerticalScene, orthographicCamera); - - halftoneMaterial.uniforms.tGlow.value = snapBlurB.texture; - - // Final halftone render to a target we can read pixels from - const outputTarget = createRenderTarget(snapshotWidth, snapshotHeight); - renderer.setRenderTarget(outputTarget); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - - // Read pixels - const pixelBuffer = new Uint8Array(snapshotWidth * snapshotHeight * 4); - renderer.readRenderTargetPixels( - outputTarget, - 0, - 0, - snapshotWidth, - snapshotHeight, - pixelBuffer, - ); - - // Restore renderer state - renderer.setSize(prevSize.x, prevSize.y, false); - halftoneMaterial.uniforms.tScene.value = sceneTarget.texture; - halftoneMaterial.uniforms.tGlow.value = blurTargetB.texture; - updateViewportUniforms( - getVirtualWidth(), - getVirtualHeight(), - prevSize.x, - prevSize.y, - ); - if (isImage) { - imageMaterial.uniforms.zoom.value = getImagePreviewZoom( - previewDistanceReference.current, - ); - } else { - camera.aspect = getWidth() / Math.max(getHeight(), 1); - camera.updateProjectionMatrix(); - } - - // Dispose temporary targets - snapScene.dispose(); - snapBlurA.dispose(); - snapBlurB.dispose(); - outputTarget.dispose(); - - // Convert pixels to PNG via canvas - // WebGL readPixels returns rows bottom-to-top, so flip vertically - const flippedBuffer = new Uint8Array(snapshotWidth * snapshotHeight * 4); - const rowSize = snapshotWidth * 4; - for (let y = 0; y < snapshotHeight; y++) { - const srcOffset = y * rowSize; - const dstOffset = (snapshotHeight - 1 - y) * rowSize; - flippedBuffer.set( - pixelBuffer.subarray(srcOffset, srcOffset + rowSize), - dstOffset, - ); - } - - const 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( - croppedWidth * croppedHeight * 4, - ); - - for (let y = 0; y < croppedHeight; y++) { - const sourceStart = - ((cropBounds.minY + y) * snapshotWidth + cropBounds.minX) * 4; - const sourceEnd = sourceStart + croppedWidth * 4; - const destinationStart = y * croppedWidth * 4; - - croppedBuffer.set( - flippedBuffer.subarray(sourceStart, sourceEnd), - destinationStart, - ); - } - - const imageData = new ImageData( - croppedBuffer, - croppedWidth, - croppedHeight, - ); - const offscreen = document.createElement('canvas'); - offscreen.width = croppedWidth; - offscreen.height = croppedHeight; - const ctx = offscreen.getContext('2d'); - - if (!ctx) { - return null; - } - - 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'); - }); - }; - - if (snapshotRef) { - snapshotRef.current = captureSnapshot; - } - - const syncSize = () => { - if (cancelled) { - return; - } - - const width = getWidth(); - const height = getHeight(); - const logicalWidth = getVirtualWidth(); - const logicalHeight = getVirtualHeight(); - const renderWidth = getRenderWidth(); - const renderHeight = getRenderHeight(); - - renderer.setSize(renderWidth, renderHeight, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - sceneTarget.setSize(renderWidth, renderHeight); - blurTargetA.setSize(renderWidth, renderHeight); - blurTargetB.setSize(renderWidth, renderHeight); - updateViewportUniforms( - logicalWidth, - logicalHeight, - renderWidth, - renderHeight, - ); - }; - - const resizeObserver = new ResizeObserver(syncSize); - resizeObserver.observe(container); - - const updatePointerPosition = ( - event: PointerEvent, - options?: { resetVelocity?: boolean }, - ) => { - const interaction = interactionReference.current; - const rect = canvas.getBoundingClientRect(); - const width = Math.max(rect.width, 1); - const height = Math.max(rect.height, 1); - - const nextMouseX = THREE.MathUtils.clamp( - (event.clientX - rect.left) / width, - 0, - 1, - ); - const nextMouseY = THREE.MathUtils.clamp( - (event.clientY - rect.top) / height, - 0, - 1, - ); - - const deltaX = nextMouseX - interaction.mouseX; - const deltaY = nextMouseY - interaction.mouseY; - - interaction.mouseX = nextMouseX; - interaction.mouseY = nextMouseY; - interaction.pointerInside = - interaction.dragging || - (event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom); - - if (options?.resetVelocity) { - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - interaction.smoothedMouseX = nextMouseX; - interaction.smoothedMouseY = nextMouseY; - } else { - interaction.pointerVelocityX = deltaX; - interaction.pointerVelocityY = deltaY; - } - - return { deltaX, deltaY }; - }; - - const releasePointerCapture = (pointerId: number | null) => { - if (pointerId === null) { - return; - } - - if (!canvas.hasPointerCapture(pointerId)) { - return; - } - - try { - canvas.releasePointerCapture(pointerId); - } catch { - // Ignore capture release failures during teardown. - } - }; - - const 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' - ? false - : activeSettings.animation.followDragEnabled; - - updatePointerPosition(event, { resetVelocity: true }); - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - - if (canDrag) { - interaction.dragging = true; - interaction.activePointerId = event.pointerId; - interaction.velocityX = 0; - interaction.velocityY = 0; - - try { - canvas.setPointerCapture(event.pointerId); - } catch { - // Pointer capture can fail in some browsers if the canvas is detached. - } - } - - canvas.style.cursor = getCanvasCursor( - activeSettings, - interaction.dragging, - ); - - markFirstInteraction(); - }; - - const handlePointerMove = (event: PointerEvent) => { - const interaction = interactionReference.current; - const resetVelocity = !interaction.pointerInside && !interaction.dragging; - updatePointerPosition( - event, - resetVelocity ? { resetVelocity: true } : undefined, - ); - const activeSettings = settingsReference.current; - - if (activeSettings.sourceMode === 'image' && interaction.pointerInside) { - markFirstInteraction(); - } - - if (!interaction.dragging) { - return; - } - - if ( - interaction.activePointerId !== null && - event.pointerId !== interaction.activePointerId - ) { - return; - } - - const animation = activeSettings.animation; - - if (!animation.followDragEnabled) { - return; - } - - const deltaX = - (event.clientX - interaction.pointerX) * animation.dragSens; - const deltaY = - (event.clientY - interaction.pointerY) * animation.dragSens; - interaction.velocityX = deltaY; - interaction.velocityY = deltaX; - interaction.targetRotationY += deltaX; - interaction.targetRotationX += deltaY; - interaction.pointerX = event.clientX; - interaction.pointerY = event.clientY; - }; - - const handlePointerLeave = () => { - const interaction = interactionReference.current; - const activeSettings = settingsReference.current; - - if (interaction.dragging) { - return; - } - - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - - if (activeSettings.sourceMode !== 'image') { - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - } - - canvas.style.cursor = getCanvasCursor(activeSettings, false); - }; - - const handlePointerUp = (event: PointerEvent) => { - const interaction = interactionReference.current; - const activeSettings = settingsReference.current; - const animation = activeSettings.animation; - - updatePointerPosition(event, { resetVelocity: true }); - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - const rect = canvas.getBoundingClientRect(); - interaction.pointerInside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - - if (!interaction.pointerInside && activeSettings.sourceMode !== 'image') { - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - } - - canvas.style.cursor = getCanvasCursor(activeSettings, false); - - if (!animation.springReturnEnabled) { - return; - } - - const springImpulse = Math.max(animation.springStrength * 10, 1.2); - interaction.rotationVelocityX += interaction.velocityX * springImpulse; - interaction.rotationVelocityY += interaction.velocityY * springImpulse; - interaction.rotationVelocityZ += - interaction.velocityY * springImpulse * 0.12; - interaction.targetRotationX = 0; - interaction.targetRotationY = 0; - interaction.velocityX = 0; - interaction.velocityY = 0; - }; - - const handlePointerCancel = () => { - const interaction = interactionReference.current; - const activeSettings = settingsReference.current; - releasePointerCapture(interaction.activePointerId); - interaction.activePointerId = null; - interaction.dragging = false; - interaction.pointerInside = false; - interaction.pointerVelocityX = 0; - interaction.pointerVelocityY = 0; - - interaction.mouseX = 0.5; - interaction.mouseY = 0.5; - interaction.smoothedMouseX = 0.5; - interaction.smoothedMouseY = 0.5; - - canvas.style.cursor = getCanvasCursor(activeSettings, false); - }; - - const handleWindowBlur = () => { - handlePointerCancel(); - }; - - canvas.addEventListener('pointermove', handlePointerMove); - canvas.addEventListener('pointerleave', handlePointerLeave); - canvas.addEventListener('pointerup', handlePointerUp); - canvas.addEventListener('pointercancel', handlePointerCancel); - window.addEventListener('blur', handleWindowBlur); - canvas.addEventListener('pointerdown', handlePointerDown); - - const clock = new THREE.Clock(); - - const renderFrame = () => { - if (cancelled) { - return; - } - - animationFrameId = window.requestAnimationFrame(renderFrame); - - const interaction = interactionReference.current; - const activeSettings = settingsReference.current; - const delta = clock.getDelta(); - const elapsedTime = - (initialPoseReference.current?.timeElapsed ?? 0) + - clock.elapsedTime; - const baseDistance = previewDistanceReference.current; - const logicalWidth = getVirtualWidth(); - const logicalHeight = getVirtualHeight(); - const isImageMode = activeSettings.sourceMode === 'image'; - const hasImageTexture = resources.imageTexture !== null; - - halftoneMaterial.uniforms.time.value = elapsedTime; - halftoneMaterial.uniforms.waveAmount.value = - activeSettings.animation.waveEnabled && !isImageMode - ? activeSettings.animation.waveAmount - : 0; - halftoneMaterial.uniforms.waveSpeed.value = - activeSettings.animation.waveSpeed; - - // Image mode selected but no image loaded yet — show empty canvas - if (isImageMode && !hasImageTexture) { - renderer.setRenderTarget(null); - renderer.clear(); - return; - } - - halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0; - - if (isImageMode) { - const pointerActive = interaction.pointerInside; - - interaction.smoothedMouseX += - (interaction.mouseX - interaction.smoothedMouseX) * - IMAGE_POINTER_FOLLOW; - interaction.smoothedMouseY += - (interaction.mouseY - interaction.smoothedMouseY) * - IMAGE_POINTER_FOLLOW; - interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING; - interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING; - - halftoneMaterial.uniforms.interactionUv.value.set( - interaction.smoothedMouseX, - 1 - interaction.smoothedMouseY, - ); - halftoneMaterial.uniforms.interactionVelocity.value.set( - interaction.pointerVelocityX * logicalWidth, - -interaction.pointerVelocityY * logicalHeight, - ); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - halftoneMaterial.uniforms.hoverLightStrength.value = - pointerActive && activeSettings.animation.hoverLightEnabled - ? activeSettings.animation.hoverLightIntensity - : 0; - halftoneMaterial.uniforms.hoverLightRadius.value = - activeSettings.animation.hoverLightRadius; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - - imageMaterial.uniforms.zoom.value = getImagePreviewZoom(baseDistance); - imageMaterial.uniforms.viewportSize.value.set( - logicalWidth, - logicalHeight, - ); - halftoneMaterial.uniforms.footprintScale.value = getImageHalftoneScale( - logicalWidth, - logicalHeight, - baseDistance, - ); - - poseChangeReference.current({ - autoElapsed: 0, - rotateElapsed: 0, - rotationX: 0, - rotationY: 0, - rotationZ: 0, - targetRotationX: interaction.targetRotationX, - targetRotationY: interaction.targetRotationY, - timeElapsed: elapsedTime, - }); - - if (!activeSettings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(imageScene, orthographicCamera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(imageScene, orthographicCamera); - } - - if (!isImageMode) { - let baseRotationX = 0; - let baseRotationY = 0; - let baseRotationZ = 0; - let meshOffsetY = 0; - let meshScale = 1; - let lightAngle = activeSettings.lighting.angleDegrees; - let lightHeight = activeSettings.lighting.height; - - if (activeSettings.animation.autoRotateEnabled) { - interaction.autoElapsed += delta; - baseRotationY += - interaction.autoElapsed * activeSettings.animation.autoSpeed; - baseRotationX += - Math.sin(interaction.autoElapsed * 0.2) * - activeSettings.animation.autoWobble; - } - - if (activeSettings.animation.floatEnabled) { - const floatPhase = elapsedTime * activeSettings.animation.floatSpeed; - const driftAmount = - (activeSettings.animation.driftAmount * Math.PI) / 180; - - meshOffsetY += - Math.sin(floatPhase) * activeSettings.animation.floatAmplitude; - baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; - baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; - } - - if (activeSettings.animation.breatheEnabled) { - meshScale *= - 1 + - Math.sin(elapsedTime * activeSettings.animation.breatheSpeed) * - activeSettings.animation.breatheAmount; - } - - if (activeSettings.animation.rotateEnabled) { - interaction.rotateElapsed += delta; - const rotateProgress = activeSettings.animation.rotatePingPong - ? Math.sin( - interaction.rotateElapsed * - activeSettings.animation.rotateSpeed, - ) * Math.PI - : interaction.rotateElapsed * activeSettings.animation.rotateSpeed; - - if (activeSettings.animation.rotatePreset === 'axis') { - const axisDirection = - activeSettings.animation.rotateAxis.startsWith('-') ? -1 : 1; - const axisProgress = rotateProgress * axisDirection; - - if ( - activeSettings.animation.rotateAxis === 'x' || - activeSettings.animation.rotateAxis === 'xy' || - activeSettings.animation.rotateAxis === '-x' || - activeSettings.animation.rotateAxis === '-xy' - ) { - baseRotationX += axisProgress; - } - - if ( - activeSettings.animation.rotateAxis === 'y' || - activeSettings.animation.rotateAxis === 'xy' || - activeSettings.animation.rotateAxis === '-y' || - activeSettings.animation.rotateAxis === '-xy' - ) { - baseRotationY += axisProgress; - } - - if ( - activeSettings.animation.rotateAxis === 'z' || - activeSettings.animation.rotateAxis === '-z' - ) { - baseRotationZ += axisProgress; - } - } else if (activeSettings.animation.rotatePreset === 'lissajous') { - baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; - baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; - baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; - } else if (activeSettings.animation.rotatePreset === 'orbit') { - baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; - baseRotationY += Math.cos(rotateProgress) * 1.2; - baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; - } else if (activeSettings.animation.rotatePreset === 'tumble') { - baseRotationX += rotateProgress * 0.55; - baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; - baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; - } - } - - if (activeSettings.animation.lightSweepEnabled) { - const lightPhase = - elapsedTime * activeSettings.animation.lightSweepSpeed; - lightAngle += - Math.sin(lightPhase) * activeSettings.animation.lightSweepRange; - lightHeight += - Math.cos(lightPhase * 0.85) * - activeSettings.animation.lightSweepHeightRange; - } - - let targetX = baseRotationX; - let targetY = baseRotationY; - let easing = 0.12; - - if (activeSettings.animation.followHoverEnabled) { - const rangeRadians = - (activeSettings.animation.hoverRange * Math.PI) / 180; - - if ( - activeSettings.animation.hoverReturn || - interaction.mouseX !== 0.5 || - interaction.mouseY !== 0.5 - ) { - targetX += (interaction.mouseY - 0.5) * rangeRadians; - targetY += (interaction.mouseX - 0.5) * rangeRadians; - } - - easing = activeSettings.animation.hoverEase; - } - - if (activeSettings.animation.followDragEnabled) { - if (!interaction.dragging && activeSettings.animation.dragMomentum) { - interaction.targetRotationX += interaction.velocityX; - interaction.targetRotationY += interaction.velocityY; - interaction.velocityX *= 1 - activeSettings.animation.dragFriction; - interaction.velocityY *= 1 - activeSettings.animation.dragFriction; - } - - targetX += interaction.targetRotationX; - targetY += interaction.targetRotationY; - easing = activeSettings.animation.dragFriction; - } - - if ( - activeSettings.animation.autoRotateEnabled && - !activeSettings.animation.followHoverEnabled && - !activeSettings.animation.followDragEnabled - ) { - targetX = baseRotationX + interaction.targetRotationX; - targetY = baseRotationY + interaction.targetRotationY; - - if (interaction.dragging) { - targetX = interaction.targetRotationX; - targetY = interaction.targetRotationY; - } - - easing = 0.08; - } - - if (activeSettings.animation.springReturnEnabled) { - const springX = applySpringStep( - interaction.rotationX, - targetX, - interaction.rotationVelocityX, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); - const springY = applySpringStep( - interaction.rotationY, - targetY, - interaction.rotationVelocityY, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); - const springZ = applySpringStep( - interaction.rotationZ, - baseRotationZ, - interaction.rotationVelocityZ, - activeSettings.animation.springStrength, - activeSettings.animation.springDamping, - ); - - interaction.rotationX = springX.value; - interaction.rotationY = springY.value; - interaction.rotationZ = springZ.value; - interaction.rotationVelocityX = springX.velocity; - interaction.rotationVelocityY = springY.velocity; - interaction.rotationVelocityZ = springZ.velocity; - } else { - interaction.rotationX += (targetX - interaction.rotationX) * easing; - interaction.rotationY += (targetY - interaction.rotationY) * easing; - interaction.rotationZ += - (baseRotationZ - interaction.rotationZ) * - (activeSettings.animation.rotatePingPong ? 0.18 : 0.12); - } - - mesh.rotation.set( - interaction.rotationX, - interaction.rotationY, - interaction.rotationZ, - ); - mesh.position.y = meshOffsetY; - mesh.scale.setScalar(meshScale); - - if (activeSettings.animation.cameraParallaxEnabled) { - const cameraRange = activeSettings.animation.cameraParallaxAmount; - const cameraEase = activeSettings.animation.cameraParallaxEase; - const centeredX = (interaction.mouseX - 0.5) * 2; - const centeredY = (0.5 - interaction.mouseY) * 2; - const orbitYaw = centeredX * cameraRange; - const orbitPitch = centeredY * cameraRange * 0.7; - const horizontalRadius = Math.cos(orbitPitch) * baseDistance; - const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; - const targetCameraY = Math.sin(orbitPitch) * baseDistance * 0.85; - const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; - - camera.position.x += (targetCameraX - camera.position.x) * cameraEase; - camera.position.y += (targetCameraY - camera.position.y) * cameraEase; - camera.position.z += (targetCameraZ - camera.position.z) * cameraEase; - } else { - camera.position.x += (0 - camera.position.x) * 0.12; - camera.position.y += (0 - camera.position.y) * 0.12; - camera.position.z += (baseDistance - camera.position.z) * 0.12; - } - - 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, - rotateElapsed: interaction.rotateElapsed, - rotationX: interaction.rotationX, - rotationY: interaction.rotationY, - rotationZ: interaction.rotationZ, - targetRotationX: interaction.targetRotationX, - targetRotationY: interaction.targetRotationY, - timeElapsed: elapsedTime, - }); - - if (!activeSettings.halftone.enabled) { - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(scene3d, camera); - return; - } - - renderer.setRenderTarget(sceneTarget); - renderer.render(scene3d, camera); - halftoneMaterial.uniforms.hoverLightStrength.value = 0; - halftoneMaterial.uniforms.hoverFlowStrength.value = 0; - halftoneMaterial.uniforms.dragFlowStrength.value = 0; - halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); - halftoneMaterial.uniforms.dragOffset.value.set(0, 0); - } // end if (!isImageMode) - - blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; - renderer.setRenderTarget(blurTargetA); - renderer.render(blurHorizontalScene, orthographicCamera); - - blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; - renderer.setRenderTarget(blurTargetB); - renderer.render(blurVerticalScene, orthographicCamera); - - renderer.setRenderTarget(null); - renderer.clear(); - renderer.render(postScene, orthographicCamera); - }; - - renderFrame(); - - return () => { - cancelled = true; - resizeObserver.disconnect(); - canvas.removeEventListener('pointermove', handlePointerMove); - canvas.removeEventListener('pointerleave', handlePointerLeave); - canvas.removeEventListener('pointerup', handlePointerUp); - canvas.removeEventListener('pointercancel', handlePointerCancel); - window.removeEventListener('blur', handleWindowBlur); - canvas.removeEventListener('pointerdown', handlePointerDown); - window.cancelAnimationFrame(animationFrameId); - - blurHorizontalMaterial.dispose(); - blurVerticalMaterial.dispose(); - halftoneMaterial.dispose(); - imageMaterial.dispose(); - - if (resources.imageTexture) { - resources.imageTexture.dispose(); - } - - fullScreenGeometry.dispose(); - material.dispose(); - sceneTarget.dispose(); - blurTargetA.dispose(); - blurTargetB.dispose(); - environmentTexture.dispose(); + let cleanup = () => { renderer.dispose(); resourcesReference.current = null; - if (snapshotRef) { - snapshotRef.current = null; + if (activeSnapshotRef) { + activeSnapshotRef.current = null; } if (canvas.parentNode === container) { container.removeChild(canvas); } }; + + void (async () => { + const materialAssets = await createHalftoneMaterialAssets(renderer); + + if (cancelled) { + disposeHalftoneMaterialAssets(materialAssets); + cleanup(); + + return; + } + + const scene3d = new THREE.Scene(); + scene3d.background = null; + + const camera = new THREE.PerspectiveCamera( + 45, + getWidth() / getHeight(), + 0.1, + 100, + ); + camera.position.z = initialPreviewDistance; + + const primaryLight = new THREE.DirectionalLight(0xffffff, 1.5); + scene3d.add(primaryLight); + + const fillLight = new THREE.DirectionalLight(0xffffff, 0.15); + fillLight.position.set(-3, -1, 1); + scene3d.add(fillLight); + + const ambientLight = new THREE.AmbientLight(0xffffff, 0.08); + scene3d.add(ambientLight); + + const material = createHalftoneMaterial(); + applyHalftoneMaterialSettings( + material, + settingsReference.current.material, + materialAssets, + ); + + const currentGeometry = geometryReference.current; + + if (!currentGeometry) { + disposeHalftoneMaterialAssets(materialAssets); + cleanup(); + + return; + } + + const mesh = new THREE.Mesh(currentGeometry, material); + scene3d.add(mesh); + + const sceneTarget = createRenderTarget( + getRenderWidth(), + getRenderHeight(), + ); + const transmissionBacksideTarget = createRenderTarget( + getRenderWidth(), + getRenderHeight(), + ); + const transmissionTarget = createRenderTarget( + getRenderWidth(), + getRenderHeight(), + ); + const blurTargetA = createRenderTarget( + getRenderWidth(), + getRenderHeight(), + ); + const blurTargetB = createRenderTarget( + getRenderWidth(), + getRenderHeight(), + ); + const fullScreenGeometry = new THREE.PlaneGeometry(2, 2); + const orthographicCamera = new THREE.OrthographicCamera( + -1, + 1, + 1, + -1, + 0, + 1, + ); + + const blurHorizontalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(1, 0) }, + res: { + value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const blurVerticalMaterial = new THREE.ShaderMaterial({ + uniforms: { + tInput: { value: null }, + dir: { value: new THREE.Vector2(0, 1) }, + res: { + value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), + }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: blurFragmentShader, + }); + + const halftoneMaterial = new THREE.ShaderMaterial({ + transparent: true, + uniforms: { + tScene: { value: sceneTarget.texture }, + tGlow: { value: blurTargetB.texture }, + effectResolution: { + value: new THREE.Vector2(getRenderWidth(), getRenderHeight()), + }, + logicalResolution: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + tile: { value: initialSettings.halftone.scale }, + s_3: { value: initialSettings.halftone.power }, + s_4: { value: initialSettings.halftone.width }, + dashColor: { + value: new THREE.Color(initialSettings.halftone.dashColor), + }, + time: { value: 0 }, + waveAmount: { value: 0 }, + waveSpeed: { value: 1 }, + 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) }, + hoverHalftonePowerShift: { value: 0 }, + hoverHalftoneRadius: { value: 0.2 }, + hoverHalftoneWidthShift: { value: 0 }, + hoverLightStrength: { value: 0 }, + hoverLightRadius: { value: 0.2 }, + hoverFlowStrength: { value: 0 }, + hoverFlowRadius: { value: 0.18 }, + dragFlowStrength: { value: 0 }, + cropToBounds: { value: 0 }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: halftoneFragmentShader, + }); + + const blurHorizontalScene = new THREE.Scene(); + blurHorizontalScene.add( + new THREE.Mesh(fullScreenGeometry, blurHorizontalMaterial), + ); + + const blurVerticalScene = new THREE.Scene(); + blurVerticalScene.add( + new THREE.Mesh(fullScreenGeometry, blurVerticalMaterial), + ); + + const postScene = new THREE.Scene(); + postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial)); + + const imageMaterial = new THREE.ShaderMaterial({ + uniforms: { + tImage: { value: null }, + imageSize: { value: new THREE.Vector2(1, 1) }, + viewportSize: { + value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()), + }, + zoom: { value: getImagePreviewZoom(initialPreviewDistance) }, + contrast: { value: initialSettings.halftone.imageContrast }, + }, + vertexShader: passThroughVertexShader, + fragmentShader: imagePassthroughFragmentShader, + }); + + const imageScene = new THREE.Scene(); + imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial)); + + const resources: SceneResources = { + ambientLight, + blurHorizontalMaterial, + blurHorizontalScene, + blurTargetA, + blurTargetB, + blurVerticalMaterial, + blurVerticalScene, + camera, + canvas, + fillLight, + fullScreenGeometry, + halftoneMaterial, + imageMaterial, + imageScene, + imageTexture: null, + materialAssets, + material, + mesh, + orthographicCamera, + postScene, + primaryLight, + renderer, + scene3d, + sceneTarget, + transmissionBacksideTarget, + transmissionTarget, + }; + + 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; + const latestGeometry = geometryReference.current; + + if (latestGeometry && resources.mesh.geometry !== latestGeometry) { + resources.mesh.geometry = latestGeometry; + } + + syncResources(resources, settingsReference.current); + + 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; + + const snapScene = createRenderTarget(snapshotWidth, snapshotHeight); + const snapTransmissionBackside = createRenderTarget( + snapshotWidth, + snapshotHeight, + ); + const snapTransmission = createRenderTarget( + snapshotWidth, + snapshotHeight, + ); + const snapBlurA = createRenderTarget(snapshotWidth, snapshotHeight); + const snapBlurB = createRenderTarget(snapshotWidth, snapshotHeight); + + const prevSize = renderer.getSize(new THREE.Vector2()); + + renderer.setSize(snapshotWidth, snapshotHeight, false); + + updateViewportUniforms( + snapshotWidth, + snapshotHeight, + snapshotWidth, + snapshotHeight, + ); + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = 0; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = 0; + halftoneMaterial.uniforms.hoverLightStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + halftoneMaterial.uniforms.cropToBounds.value = isImage ? 1 : 0; + + if (isImage) { + imageMaterial.uniforms.zoom.value = getImagePreviewZoom( + previewDistanceReference.current, + ); + halftoneMaterial.uniforms.footprintScale.value = + getImageHalftoneScale( + snapshotWidth, + snapshotHeight, + previewDistanceReference.current, + ); + } else { + camera.aspect = snapshotWidth / snapshotHeight; + camera.updateProjectionMatrix(); + halftoneMaterial.uniforms.footprintScale.value = getMeshHalftoneScale( + snapshotWidth, + snapshotHeight, + new THREE.Vector3(0, mesh.position.y * 0.2, 0), + ); + } + + if (isImage) { + renderer.setRenderTarget(snapScene); + renderer.render(imageScene, orthographicCamera); + } else { + renderHalftoneMaterialScene({ + camera, + elapsedTime: halftoneMaterial.uniforms.time.value as number, + material, + mesh, + outputTarget: snapScene, + renderer, + scene: scene3d, + transmissionBackground: materialAssets.glassBackgroundTexture, + transmissionScene: materialAssets.glassTransmissionScene, + transmissionBacksideTarget: snapTransmissionBackside, + transmissionTarget: snapTransmission, + }); + } + + halftoneMaterial.uniforms.tScene.value = snapScene.texture; + + blurHorizontalMaterial.uniforms.tInput.value = snapScene.texture; + renderer.setRenderTarget(snapBlurA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; + renderer.setRenderTarget(snapBlurB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = snapBlurB.texture; + renderer.setRenderTarget(snapBlurA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = snapBlurA.texture; + renderer.setRenderTarget(snapBlurB); + renderer.render(blurVerticalScene, orthographicCamera); + + halftoneMaterial.uniforms.tGlow.value = snapBlurB.texture; + + const outputTarget = createRenderTarget(snapshotWidth, snapshotHeight); + renderer.setRenderTarget(outputTarget); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + + const pixelBuffer = new Uint8Array(snapshotWidth * snapshotHeight * 4); + renderer.readRenderTargetPixels( + outputTarget, + 0, + 0, + snapshotWidth, + snapshotHeight, + pixelBuffer, + ); + + renderer.setSize(prevSize.x, prevSize.y, false); + halftoneMaterial.uniforms.tScene.value = sceneTarget.texture; + halftoneMaterial.uniforms.tGlow.value = blurTargetB.texture; + updateViewportUniforms( + getVirtualWidth(), + getVirtualHeight(), + prevSize.x, + prevSize.y, + ); + if (isImage) { + imageMaterial.uniforms.zoom.value = getImagePreviewZoom( + previewDistanceReference.current, + ); + } else { + camera.aspect = getWidth() / Math.max(getHeight(), 1); + camera.updateProjectionMatrix(); + } + + snapScene.dispose(); + snapTransmissionBackside.dispose(); + snapTransmission.dispose(); + snapBlurA.dispose(); + snapBlurB.dispose(); + outputTarget.dispose(); + + const flippedBuffer = new Uint8Array( + snapshotWidth * snapshotHeight * 4, + ); + const rowSize = snapshotWidth * 4; + for (let y = 0; y < snapshotHeight; y++) { + const srcOffset = y * rowSize; + const dstOffset = (snapshotHeight - 1 - y) * rowSize; + flippedBuffer.set( + pixelBuffer.subarray(srcOffset, srcOffset + rowSize), + dstOffset, + ); + } + + const 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( + croppedWidth * croppedHeight * 4, + ); + + for (let y = 0; y < croppedHeight; y++) { + const sourceStart = + ((cropBounds.minY + y) * snapshotWidth + cropBounds.minX) * 4; + const sourceEnd = sourceStart + croppedWidth * 4; + const destinationStart = y * croppedWidth * 4; + + croppedBuffer.set( + flippedBuffer.subarray(sourceStart, sourceEnd), + destinationStart, + ); + } + + const imageData = new ImageData( + croppedBuffer, + croppedWidth, + croppedHeight, + ); + const offscreen = document.createElement('canvas'); + offscreen.width = croppedWidth; + offscreen.height = croppedHeight; + const ctx = offscreen.getContext('2d'); + + if (!ctx) { + return null; + } + + 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'); + }); + }; + + if (activeSnapshotRef) { + activeSnapshotRef.current = captureSnapshot; + } + + const syncSize = () => { + if (cancelled) { + return; + } + + const width = getWidth(); + const height = getHeight(); + const logicalWidth = getVirtualWidth(); + const logicalHeight = getVirtualHeight(); + const renderWidth = getRenderWidth(); + const renderHeight = getRenderHeight(); + + renderer.setSize(renderWidth, renderHeight, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + sceneTarget.setSize(renderWidth, renderHeight); + transmissionBacksideTarget.setSize(renderWidth, renderHeight); + transmissionTarget.setSize(renderWidth, renderHeight); + blurTargetA.setSize(renderWidth, renderHeight); + blurTargetB.setSize(renderWidth, renderHeight); + updateViewportUniforms( + logicalWidth, + logicalHeight, + renderWidth, + renderHeight, + ); + }; + + const resizeObserver = new ResizeObserver(syncSize); + resizeObserver.observe(container); + + const updatePointerPosition = ( + event: PointerEvent, + options?: { resetVelocity?: boolean }, + ) => { + const interaction = interactionReference.current; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(rect.width, 1); + const height = Math.max(rect.height, 1); + + const nextMouseX = THREE.MathUtils.clamp( + (event.clientX - rect.left) / width, + 0, + 1, + ); + const nextMouseY = THREE.MathUtils.clamp( + (event.clientY - rect.top) / height, + 0, + 1, + ); + + const deltaX = nextMouseX - interaction.mouseX; + const deltaY = nextMouseY - interaction.mouseY; + + interaction.mouseX = nextMouseX; + interaction.mouseY = nextMouseY; + interaction.pointerInside = + interaction.dragging || + (event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom); + + if (options?.resetVelocity) { + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + interaction.smoothedMouseX = nextMouseX; + interaction.smoothedMouseY = nextMouseY; + } else { + interaction.pointerVelocityX = deltaX; + interaction.pointerVelocityY = deltaY; + } + + return { deltaX, deltaY }; + }; + + const releasePointerCapture = (pointerId: number | null) => { + if (pointerId === null) { + return; + } + + if (!canvas.hasPointerCapture(pointerId)) { + return; + } + + try { + canvas.releasePointerCapture(pointerId); + } catch (error) { + void error; + } + }; + + 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' + ? false + : activeSettings.animation.followDragEnabled; + + updatePointerPosition(event, { resetVelocity: true }); + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + + if (canDrag) { + interaction.dragging = true; + interaction.activePointerId = event.pointerId; + interaction.velocityX = 0; + interaction.velocityY = 0; + + try { + canvas.setPointerCapture(event.pointerId); + } catch (error) { + void error; + } + } + + canvas.style.cursor = getCanvasCursor( + activeSettings, + interaction.dragging, + ); + + markFirstInteraction(); + }; + + const handlePointerMove = (event: PointerEvent) => { + const interaction = interactionReference.current; + const resetVelocity = + !interaction.pointerInside && !interaction.dragging; + updatePointerPosition( + event, + resetVelocity ? { resetVelocity: true } : undefined, + ); + const activeSettings = settingsReference.current; + + if ( + activeSettings.sourceMode === 'image' && + interaction.pointerInside + ) { + markFirstInteraction(); + } + + if (!interaction.dragging) { + return; + } + + if ( + interaction.activePointerId !== null && + event.pointerId !== interaction.activePointerId + ) { + return; + } + + const animation = activeSettings.animation; + + if (!animation.followDragEnabled) { + return; + } + + const deltaX = + (event.clientX - interaction.pointerX) * animation.dragSens; + const deltaY = + (event.clientY - interaction.pointerY) * animation.dragSens; + interaction.velocityX = deltaY; + interaction.velocityY = deltaX; + interaction.targetRotationY += deltaX; + interaction.targetRotationX += deltaY; + interaction.pointerX = event.clientX; + interaction.pointerY = event.clientY; + }; + + const handlePointerLeave = () => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + + if (interaction.dragging) { + return; + } + + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + + if (activeSettings.sourceMode !== 'image') { + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + } + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + }; + + const handlePointerUp = (event: PointerEvent) => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + const animation = activeSettings.animation; + + updatePointerPosition(event, { resetVelocity: true }); + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + const rect = canvas.getBoundingClientRect(); + interaction.pointerInside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + + if ( + !interaction.pointerInside && + activeSettings.sourceMode !== 'image' + ) { + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + } + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + + if (!animation.springReturnEnabled) { + return; + } + + const springImpulse = Math.max(animation.springStrength * 10, 1.2); + interaction.rotationVelocityX += interaction.velocityX * springImpulse; + interaction.rotationVelocityY += interaction.velocityY * springImpulse; + interaction.rotationVelocityZ += + interaction.velocityY * springImpulse * 0.12; + interaction.targetRotationX = 0; + interaction.targetRotationY = 0; + interaction.velocityX = 0; + interaction.velocityY = 0; + }; + + const handlePointerCancel = () => { + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + releasePointerCapture(interaction.activePointerId); + interaction.activePointerId = null; + interaction.dragging = false; + interaction.pointerInside = false; + interaction.pointerVelocityX = 0; + interaction.pointerVelocityY = 0; + + interaction.mouseX = 0.5; + interaction.mouseY = 0.5; + interaction.smoothedMouseX = 0.5; + interaction.smoothedMouseY = 0.5; + + canvas.style.cursor = getCanvasCursor(activeSettings, false); + }; + + const handleWindowBlur = () => { + handlePointerCancel(); + }; + + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerup', handlePointerUp); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + const clock = new THREE.Timer(); + clock.connect(document); + + const renderFrame = (timestamp?: DOMHighResTimeStamp) => { + if (cancelled) { + return; + } + + animationFrameId = window.requestAnimationFrame(renderFrame); + clock.update(timestamp); + + const interaction = interactionReference.current; + const activeSettings = settingsReference.current; + const delta = clock.getDelta(); + const elapsedTime = + (initialPoseReference.current?.timeElapsed ?? 0) + clock.getElapsed(); + const baseDistance = previewDistanceReference.current; + const logicalWidth = getVirtualWidth(); + const logicalHeight = getVirtualHeight(); + const isImageMode = activeSettings.sourceMode === 'image'; + const hasImageTexture = resources.imageTexture !== null; + + halftoneMaterial.uniforms.time.value = elapsedTime; + halftoneMaterial.uniforms.waveAmount.value = + activeSettings.animation.waveEnabled && !isImageMode + ? activeSettings.animation.waveAmount + : 0; + halftoneMaterial.uniforms.waveSpeed.value = + activeSettings.animation.waveSpeed; + + if (isImageMode && !hasImageTexture) { + renderer.setRenderTarget(null); + renderer.clear(); + return; + } + + halftoneMaterial.uniforms.cropToBounds.value = isImageMode ? 1 : 0; + + if (isImageMode) { + const pointerActive = interaction.pointerInside; + + interaction.smoothedMouseX += + (interaction.mouseX - interaction.smoothedMouseX) * + IMAGE_POINTER_FOLLOW; + interaction.smoothedMouseY += + (interaction.mouseY - interaction.smoothedMouseY) * + IMAGE_POINTER_FOLLOW; + interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING; + interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING; + + halftoneMaterial.uniforms.interactionUv.value.set( + interaction.smoothedMouseX, + 1 - interaction.smoothedMouseY, + ); + halftoneMaterial.uniforms.interactionVelocity.value.set( + interaction.pointerVelocityX * logicalWidth, + -interaction.pointerVelocityY * logicalHeight, + ); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = + pointerActive && activeSettings.animation.hoverHalftoneEnabled + ? activeSettings.animation.hoverHalftonePowerShift + : 0; + halftoneMaterial.uniforms.hoverHalftoneRadius.value = + activeSettings.animation.hoverHalftoneRadius; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = + pointerActive && activeSettings.animation.hoverHalftoneEnabled + ? activeSettings.animation.hoverHalftoneWidthShift + : 0; + halftoneMaterial.uniforms.hoverLightStrength.value = + pointerActive && activeSettings.animation.hoverLightEnabled + ? activeSettings.animation.hoverLightIntensity + : 0; + halftoneMaterial.uniforms.hoverLightRadius.value = + activeSettings.animation.hoverLightRadius; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowRadius.value = 0.18; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + + imageMaterial.uniforms.zoom.value = getImagePreviewZoom(baseDistance); + imageMaterial.uniforms.viewportSize.value.set( + logicalWidth, + logicalHeight, + ); + halftoneMaterial.uniforms.footprintScale.value = + getImageHalftoneScale(logicalWidth, logicalHeight, baseDistance); + + poseChangeReference.current({ + autoElapsed: 0, + rotateElapsed: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + targetRotationX: interaction.targetRotationX, + targetRotationY: interaction.targetRotationY, + timeElapsed: elapsedTime, + }); + + if (!activeSettings.halftone.enabled) { + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(imageScene, orthographicCamera); + return; + } + + renderer.setRenderTarget(sceneTarget); + renderer.render(imageScene, orthographicCamera); + } + + if (!isImageMode) { + let baseRotationX = 0; + let baseRotationY = 0; + let baseRotationZ = 0; + let meshOffsetY = 0; + let meshScale = 1; + let lightAngle = activeSettings.lighting.angleDegrees; + let lightHeight = activeSettings.lighting.height; + + if (activeSettings.animation.autoRotateEnabled) { + interaction.autoElapsed += delta; + baseRotationY += + interaction.autoElapsed * activeSettings.animation.autoSpeed; + baseRotationX += + Math.sin(interaction.autoElapsed * 0.2) * + activeSettings.animation.autoWobble; + } + + if (activeSettings.animation.floatEnabled) { + const floatPhase = + elapsedTime * activeSettings.animation.floatSpeed; + const driftAmount = + (activeSettings.animation.driftAmount * Math.PI) / 180; + + meshOffsetY += + Math.sin(floatPhase) * activeSettings.animation.floatAmplitude; + baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45; + baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3; + } + + if (activeSettings.animation.breatheEnabled) { + meshScale *= + 1 + + Math.sin(elapsedTime * activeSettings.animation.breatheSpeed) * + activeSettings.animation.breatheAmount; + } + + if (activeSettings.animation.rotateEnabled) { + interaction.rotateElapsed += delta; + const rotateProgress = activeSettings.animation.rotatePingPong + ? Math.sin( + interaction.rotateElapsed * + activeSettings.animation.rotateSpeed, + ) * Math.PI + : interaction.rotateElapsed * + activeSettings.animation.rotateSpeed; + + if (activeSettings.animation.rotatePreset === 'axis') { + const axisDirection = + activeSettings.animation.rotateAxis.startsWith('-') ? -1 : 1; + const axisProgress = rotateProgress * axisDirection; + + if ( + activeSettings.animation.rotateAxis === 'x' || + activeSettings.animation.rotateAxis === 'xy' || + activeSettings.animation.rotateAxis === '-x' || + activeSettings.animation.rotateAxis === '-xy' + ) { + baseRotationX += axisProgress; + } + + if ( + activeSettings.animation.rotateAxis === 'y' || + activeSettings.animation.rotateAxis === 'xy' || + activeSettings.animation.rotateAxis === '-y' || + activeSettings.animation.rotateAxis === '-xy' + ) { + baseRotationY += axisProgress; + } + + if ( + activeSettings.animation.rotateAxis === 'z' || + activeSettings.animation.rotateAxis === '-z' + ) { + baseRotationZ += axisProgress; + } + } else if (activeSettings.animation.rotatePreset === 'lissajous') { + baseRotationX += Math.sin(rotateProgress * 0.85) * 0.65; + baseRotationY += Math.sin(rotateProgress * 1.35 + 0.8) * 1.05; + baseRotationZ += Math.sin(rotateProgress * 0.55 + 1.6) * 0.32; + } else if (activeSettings.animation.rotatePreset === 'orbit') { + baseRotationX += Math.sin(rotateProgress * 0.75) * 0.42; + baseRotationY += Math.cos(rotateProgress) * 1.2; + baseRotationZ += Math.sin(rotateProgress * 1.25) * 0.24; + } else if (activeSettings.animation.rotatePreset === 'tumble') { + baseRotationX += rotateProgress * 0.55; + baseRotationY += Math.sin(rotateProgress * 0.8) * 0.9; + baseRotationZ += Math.cos(rotateProgress * 1.1) * 0.38; + } + } + + if (activeSettings.animation.lightSweepEnabled) { + const lightPhase = + elapsedTime * activeSettings.animation.lightSweepSpeed; + lightAngle += + Math.sin(lightPhase) * activeSettings.animation.lightSweepRange; + lightHeight += + Math.cos(lightPhase * 0.85) * + activeSettings.animation.lightSweepHeightRange; + } + + let targetX = baseRotationX; + let targetY = baseRotationY; + let easing = 0.12; + + if (activeSettings.animation.followHoverEnabled) { + const rangeRadians = + (activeSettings.animation.hoverRange * Math.PI) / 180; + + if ( + activeSettings.animation.hoverReturn || + interaction.mouseX !== 0.5 || + interaction.mouseY !== 0.5 + ) { + targetX += (interaction.mouseY - 0.5) * rangeRadians; + targetY += (interaction.mouseX - 0.5) * rangeRadians; + } + + easing = activeSettings.animation.hoverEase; + } + + if (activeSettings.animation.followDragEnabled) { + if ( + !interaction.dragging && + activeSettings.animation.dragMomentum + ) { + interaction.targetRotationX += interaction.velocityX; + interaction.targetRotationY += interaction.velocityY; + interaction.velocityX *= + 1 - activeSettings.animation.dragFriction; + interaction.velocityY *= + 1 - activeSettings.animation.dragFriction; + } + + targetX += interaction.targetRotationX; + targetY += interaction.targetRotationY; + easing = activeSettings.animation.dragFriction; + } + + if ( + activeSettings.animation.autoRotateEnabled && + !activeSettings.animation.followHoverEnabled && + !activeSettings.animation.followDragEnabled + ) { + targetX = baseRotationX + interaction.targetRotationX; + targetY = baseRotationY + interaction.targetRotationY; + + if (interaction.dragging) { + targetX = interaction.targetRotationX; + targetY = interaction.targetRotationY; + } + + easing = 0.08; + } + + if (activeSettings.animation.springReturnEnabled) { + const springX = applySpringStep( + interaction.rotationX, + targetX, + interaction.rotationVelocityX, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + const springY = applySpringStep( + interaction.rotationY, + targetY, + interaction.rotationVelocityY, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + const springZ = applySpringStep( + interaction.rotationZ, + baseRotationZ, + interaction.rotationVelocityZ, + activeSettings.animation.springStrength, + activeSettings.animation.springDamping, + ); + + interaction.rotationX = springX.value; + interaction.rotationY = springY.value; + interaction.rotationZ = springZ.value; + interaction.rotationVelocityX = springX.velocity; + interaction.rotationVelocityY = springY.velocity; + interaction.rotationVelocityZ = springZ.velocity; + } else { + interaction.rotationX += (targetX - interaction.rotationX) * easing; + interaction.rotationY += (targetY - interaction.rotationY) * easing; + interaction.rotationZ += + (baseRotationZ - interaction.rotationZ) * + (activeSettings.animation.rotatePingPong ? 0.18 : 0.12); + } + + mesh.rotation.set( + interaction.rotationX, + interaction.rotationY, + interaction.rotationZ, + ); + mesh.position.y = meshOffsetY; + mesh.scale.setScalar(meshScale); + + if (activeSettings.animation.cameraParallaxEnabled) { + const cameraRange = activeSettings.animation.cameraParallaxAmount; + const cameraEase = activeSettings.animation.cameraParallaxEase; + const centeredX = (interaction.mouseX - 0.5) * 2; + const centeredY = (0.5 - interaction.mouseY) * 2; + const orbitYaw = centeredX * cameraRange; + const orbitPitch = centeredY * cameraRange * 0.7; + const horizontalRadius = Math.cos(orbitPitch) * baseDistance; + const targetCameraX = Math.sin(orbitYaw) * horizontalRadius; + const targetCameraY = Math.sin(orbitPitch) * baseDistance * 0.85; + const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius; + + camera.position.x += + (targetCameraX - camera.position.x) * cameraEase; + camera.position.y += + (targetCameraY - camera.position.y) * cameraEase; + camera.position.z += + (targetCameraZ - camera.position.z) * cameraEase; + } else { + camera.position.x += (0 - camera.position.x) * 0.12; + camera.position.y += (0 - camera.position.y) * 0.12; + camera.position.z += (baseDistance - camera.position.z) * 0.12; + } + + 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, + rotateElapsed: interaction.rotateElapsed, + rotationX: interaction.rotationX, + rotationY: interaction.rotationY, + rotationZ: interaction.rotationZ, + targetRotationX: interaction.targetRotationX, + targetRotationY: interaction.targetRotationY, + timeElapsed: elapsedTime, + }); + + if (!activeSettings.halftone.enabled) { + renderHalftoneMaterialScene({ + camera, + elapsedTime, + material, + mesh, + outputTarget: null, + renderer, + scene: scene3d, + transmissionBackground: materialAssets.glassBackgroundTexture, + transmissionScene: materialAssets.glassTransmissionScene, + transmissionBacksideTarget, + transmissionTarget, + }); + return; + } + + renderHalftoneMaterialScene({ + camera, + elapsedTime, + material, + mesh, + outputTarget: sceneTarget, + renderer, + scene: scene3d, + transmissionBackground: materialAssets.glassBackgroundTexture, + transmissionScene: materialAssets.glassTransmissionScene, + transmissionBacksideTarget, + transmissionTarget, + }); + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = 0; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = 0; + halftoneMaterial.uniforms.hoverLightStrength.value = 0; + halftoneMaterial.uniforms.hoverFlowStrength.value = 0; + halftoneMaterial.uniforms.dragFlowStrength.value = 0; + halftoneMaterial.uniforms.interactionVelocity.value.set(0, 0); + halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + } + + blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture; + renderer.setRenderTarget(blurTargetA); + renderer.render(blurHorizontalScene, orthographicCamera); + + blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture; + renderer.setRenderTarget(blurTargetB); + renderer.render(blurVerticalScene, orthographicCamera); + + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(postScene, orthographicCamera); + }; + + renderFrame(); + + cleanup = () => { + resizeObserver.disconnect(); + canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('pointerup', handlePointerUp); + canvas.removeEventListener('pointercancel', handlePointerCancel); + window.removeEventListener('blur', handleWindowBlur); + canvas.removeEventListener('pointerdown', handlePointerDown); + window.cancelAnimationFrame(animationFrameId); + clock.dispose(); + + blurHorizontalMaterial.dispose(); + blurVerticalMaterial.dispose(); + halftoneMaterial.dispose(); + imageMaterial.dispose(); + + if (resources.imageTexture) { + resources.imageTexture.dispose(); + } + + fullScreenGeometry.dispose(); + material.dispose(); + sceneTarget.dispose(); + transmissionBacksideTarget.dispose(); + transmissionTarget.dispose(); + blurTargetA.dispose(); + blurTargetB.dispose(); + disposeHalftoneMaterialAssets(materialAssets); + renderer.dispose(); + resourcesReference.current = null; + + if (activeSnapshotRef) { + activeSnapshotRef.current = null; + } + + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + })(); + + return () => { + cancelled = true; + cleanup(); + }; }, [onFirstInteraction]); return ( diff --git a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx index b8eae98168..0d23fb8ff6 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/HalftoneStudio.tsx @@ -5,6 +5,7 @@ import { type HalftoneSnapshotFn, } from '@/app/halftone/_components/HalftoneCanvas'; import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel'; +import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames'; import { createFallbackGeometry, disposeGeometryCache, @@ -354,6 +355,10 @@ export function HalftoneStudio() { selectedShape, state.settings.sourceMode, ]); + const exportArtifactNames = useMemo( + () => resolveExportArtifactNames(exportName, defaultExportName), + [defaultExportName, exportName], + ); useEffect(() => { halftoneBySourceModeReference.current[state.settings.sourceMode] = { @@ -818,10 +823,8 @@ export function HalftoneStudio() { }, []); const handleExportReact = useCallback(() => { - const componentName = exportName || defaultExportName; - const kebabName = componentName - .replace(/([a-z])([A-Z])/g, '$1-$2') - .toLowerCase(); + const componentName = exportArtifactNames.componentName; + const kebabName = exportArtifactNames.fileBaseName; const isImageMode = state.settings.sourceMode === 'image'; const importedFile = selectedImportedFile; const exportBackgroundColor = exportBackground @@ -857,8 +860,7 @@ export function HalftoneStudio() { downloadBlob(modelFilename ?? importedFile.name, importedFile); } }, [ - defaultExportName, - exportName, + exportArtifactNames, exportBackground, imageFile, previewDistance, @@ -870,10 +872,7 @@ 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(); + const kebabName = exportArtifactNames.fileBaseName; if (!snapshotFn) { return; @@ -891,18 +890,15 @@ export function HalftoneStudio() { downloadBlob(`${kebabName}-${width}x${height}.png`, blob); }, [ - defaultExportName, + exportArtifactNames.fileBaseName, exportBackground, - exportName, state.settings.background.color, ], ); const handleExportHtml = useCallback(async () => { - const componentName = exportName || defaultExportName; - const kebabName = componentName - .replace(/([a-z])([A-Z])/g, '$1-$2') - .toLowerCase(); + const componentName = exportArtifactNames.componentName; + const kebabName = exportArtifactNames.fileBaseName; const isImageMode = state.settings.sourceMode === 'image'; const importedFile = selectedImportedFile; const exportBackgroundColor = exportBackground @@ -936,8 +932,7 @@ export function HalftoneStudio() { downloadBlob(imageExportFilename ?? imageFile.name, imageFile); } }, [ - defaultExportName, - exportName, + exportArtifactNames, exportBackground, imageFile, previewDistance, diff --git a/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx index ed1140bbd7..6351a9bfa5 100644 --- a/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx +++ b/packages/twenty-website-new/src/app/halftone/_components/controls/AnimationsTab.tsx @@ -18,6 +18,9 @@ import { ToggleControl, } from './controls-ui'; +const MIN_ROTATION_SPEED = 0.01; +const ROTATION_SPEED_STEP = 0.01; + type AnimationsTabProps = { onAnimationSettingsChange: ( value: Partial, @@ -41,6 +44,75 @@ export function AnimationsTab({ {isImageMode ? ( <>
+ + onAnimationSettingsChange({ + hoverHalftoneEnabled: event.target.checked, + }) + } + preserveCase + > + {effectLabel( + 'Hover Halftone', + 'Uses the cursor radius to locally push the halftone power and width, so the bars open or tighten around the mouse instead of only brightening.', + )} + + {animation.hoverHalftoneEnabled ? ( + + + onAnimationSettingsChange({ + hoverHalftonePowerShift: Number(event.target.value), + }) + } + step={0.01} + value={animation.hoverHalftonePowerShift} + valueLabel={formatDecimal( + animation.hoverHalftonePowerShift, + 2, + )} + > + Power shift + + + onAnimationSettingsChange({ + hoverHalftoneWidthShift: Number(event.target.value), + }) + } + step={0.01} + value={animation.hoverHalftoneWidthShift} + valueLabel={formatDecimal( + animation.hoverHalftoneWidthShift, + 2, + )} + > + Width shift + + + onAnimationSettingsChange({ + hoverHalftoneRadius: Number(event.target.value), + }) + } + step={0.01} + value={animation.hoverHalftoneRadius} + valueLabel={formatDecimal(animation.hoverHalftoneRadius, 2)} + > + Radius + + + ) : null} +
+ +
@@ -110,15 +182,15 @@ export function AnimationsTab({ <> onAnimationSettingsChange({ autoSpeed: Number(event.target.value), }) } - step={0.05} + step={ROTATION_SPEED_STEP} value={animation.autoSpeed} - valueLabel={formatDecimal(animation.autoSpeed, 1)} + valueLabel={formatDecimal(animation.autoSpeed, 2)} > Speed @@ -195,15 +267,15 @@ export function AnimationsTab({ ) : null} onAnimationSettingsChange({ rotateSpeed: Number(event.target.value), }) } - step={0.1} + step={ROTATION_SPEED_STEP} value={animation.rotateSpeed} - valueLabel={formatDecimal(animation.rotateSpeed, 1)} + valueLabel={formatDecimal(animation.rotateSpeed, 2)} > Speed 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 ffa25adc29..5ee6f87108 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,10 +1,16 @@ 'use client'; import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters'; -import type { - HalftoneBackgroundSettings, - HalftoneSourceMode, - HalftoneStudioSettings, +import { + DEFAULT_GLASS_ANIMATION_SETTINGS, + DEFAULT_GLASS_LIGHTING_SETTINGS, + DEFAULT_GLASS_MATERIAL_SETTINGS, + DEFAULT_SOLID_ANIMATION_SETTINGS, + DEFAULT_SOLID_LIGHTING_SETTINGS, + DEFAULT_SOLID_MATERIAL_SETTINGS, + type HalftoneBackgroundSettings, + type HalftoneSourceMode, + type HalftoneStudioSettings, } from '@/app/halftone/_lib/state'; import { styled } from '@linaria/react'; import { @@ -15,6 +21,7 @@ import { Section, SectionTitle, SectionToggleHeader, + SegmentedControl, SelectInput, ShapeRow, SliderControl, @@ -59,6 +66,9 @@ const ColorSwapButton = styled.button` `; type DesignTabProps = { + onAnimationSettingsChange: ( + value: Partial, + ) => void; imageFileName: string | null; onBackgroundChange: (value: Partial) => void; onDashColorChange: (value: string) => void; @@ -80,7 +90,14 @@ type DesignTabProps = { shapeOptions: Array<{ label: string; value: string }>; }; +function matchesSettings(value: T, target: T) { + return (Object.keys(target) as Array).every( + (key) => value[key] === target[key], + ); +} + export function DesignTab({ + onAnimationSettingsChange, imageFileName, onBackgroundChange, onDashColorChange, @@ -103,6 +120,39 @@ export function DesignTab({ imageFileName === null || imageFileName === DEFAULT_IMAGE_FILE_NAME ? DEFAULT_IMAGE_OPTION_LABEL : imageFileName; + + const handleSurfaceChange = (surface: 'glass' | 'solid') => { + const switchingToGlass = surface === 'glass'; + + onMaterialChange( + switchingToGlass + ? DEFAULT_GLASS_MATERIAL_SETTINGS + : DEFAULT_SOLID_MATERIAL_SETTINGS, + ); + + if (switchingToGlass) { + if (matchesSettings(settings.lighting, DEFAULT_SOLID_LIGHTING_SETTINGS)) { + onLightingChange(DEFAULT_GLASS_LIGHTING_SETTINGS); + } + + if ( + matchesSettings(settings.animation, DEFAULT_SOLID_ANIMATION_SETTINGS) + ) { + onAnimationSettingsChange(DEFAULT_GLASS_ANIMATION_SETTINGS); + } + + return; + } + + if (matchesSettings(settings.lighting, DEFAULT_GLASS_LIGHTING_SETTINGS)) { + onLightingChange(DEFAULT_SOLID_LIGHTING_SETTINGS); + } + + if (matchesSettings(settings.animation, DEFAULT_GLASS_ANIMATION_SETTINGS)) { + onAnimationSettingsChange(DEFAULT_SOLID_ANIMATION_SETTINGS); + } + }; + const handleSwapColors = () => { const nextDashColor = settings.background.color; const nextBackgroundColor = settings.halftone.dashColor; @@ -280,6 +330,18 @@ export function DesignTab({
Material + + handleSurfaceChange(value === 'glass' ? 'glass' : 'solid') + } + options={[ + { label: 'Solid', value: 'solid' }, + { label: 'Glass', value: 'glass' }, + ]} + value={settings.material.surface} + > + Surface + Metalness + {settings.material.surface === 'glass' ? ( + <> + + onMaterialChange({ + thickness: Number(event.target.value), + }) + } + step={0.1} + value={settings.material.thickness} + valueLabel={formatDecimal(settings.material.thickness, 0)} + > + Thickness + + + onMaterialChange({ + refraction: Number(event.target.value), + }) + } + step={0.01} + value={settings.material.refraction} + valueLabel={formatDecimal(settings.material.refraction)} + > + Refraction + + + onMaterialChange({ + environmentPower: Number(event.target.value), + }) + } + step={0.01} + value={settings.material.environmentPower} + valueLabel={formatDecimal( + settings.material.environmentPower, + 2, + )} + > + Power + + + ) : null}
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 0f734e9fb5..b1f53a0d5b 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,5 +1,6 @@ 'use client'; +import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames'; import { formatAnimationName } from '@/app/halftone/_lib/formatters'; import type { HalftoneGeometrySpec, @@ -72,8 +73,11 @@ export function ExportTab({ ? DEFAULT_IMAGE_LABEL : imageFileName : (selectedShape?.label ?? settings.shapeKey); - - const componentName = exportName || defaultExportName; + const inputName = exportName || defaultExportName; + const { componentName } = resolveExportArtifactNames( + exportName, + defaultExportName, + ); const handleDownloadHalftoneImage = () => { const [widthStr, heightStr] = resolution.split('x'); @@ -98,7 +102,7 @@ export function ExportTab({ onFocus={(event) => event.currentTarget.select()} placeholder={defaultExportName} type="text" - value={componentName} + value={inputName} /> ` + background: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.14)' : 'transparent'}; + border: none; + border-radius: 6px; + color: ${(props) => + props.$active ? 'rgba(255, 255, 255, 0.94)' : 'rgba(255, 255, 255, 0.58)'}; + cursor: pointer; + font-family: ${theme.font.family.sans}; + font-size: 11px; + font-weight: ${(props) => (props.$active ? 600 : 500)}; + height: 100%; + padding: 0 10px; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + color: rgba(255, 255, 255, 0.86); + } + + &:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.35); + outline-offset: 1px; + } +`; + const EditableControlValueButton = styled.button` background: transparent; border: none; @@ -250,8 +298,9 @@ export const SelectInput = styled.select` cursor: pointer; font-family: ${theme.font.family.sans}; font-size: 11px; + height: 24px; outline: none; - padding: 7px 34px 7px 10px; + padding: 0 34px 0 10px; transition: border-color 0.15s ease; width: 100%; @@ -576,10 +625,10 @@ export const UploadButton = styled.button` display: flex; flex-shrink: 0; font-size: 13px; - height: 32px; + height: 24px; justify-content: center; transition: all 0.15s ease; - width: 32px; + width: 24px; &:hover { background: rgba(255, 255, 255, 0.12); @@ -874,6 +923,51 @@ export function SelectControl({ ); } +type SegmentedControlProps = { + children: ReactNode; + onChange: (value: string) => void; + value: string; + options: Array<{ label: string; value: string }>; +}; + +export function SegmentedControl({ + children, + onChange, + options, + value, +}: SegmentedControlProps) { + return ( + + {children} + + {options.map((option) => { + const isActive = option.value === value; + + return ( + { + if (!isActive) { + onChange(option.value); + } + }} + role="radio" + type="button" + > + {option.label} + + ); + })} + + + ); +} + type ToggleControlProps = { checked: boolean; label: ReactNode; diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exportNames.ts b/packages/twenty-website-new/src/app/halftone/_lib/exportNames.ts new file mode 100644 index 0000000000..c81dec8f6a --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/exportNames.ts @@ -0,0 +1,51 @@ +function toPascalCase(value: string) { + const tokens = value + .replace(/\.[^.]+$/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + + const joined = tokens + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(''); + + if (!joined) { + return 'HalftoneDashes'; + } + + return /^[A-Za-z_]/.test(joined) ? joined : `Halftone${joined}`; +} + +export function normalizeExportComponentName( + value: string | null | undefined, + fallback = 'HalftoneDashes', +) { + return toPascalCase(value?.trim() || fallback); +} + +export function toKebabCase(value: string) { + const normalized = value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .replace(/([a-zA-Z])([0-9])/g, '$1-$2') + .replace(/([0-9])([a-zA-Z])/g, '$1-$2') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + + return normalized || 'halftone-dashes'; +} + +export function resolveExportArtifactNames( + value: string | null | undefined, + fallback = 'HalftoneDashes', +) { + const componentName = normalizeExportComponentName(value, fallback); + + return { + componentName, + fileBaseName: toKebabCase(componentName), + }; +} diff --git a/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts new file mode 100644 index 0000000000..ac35047609 --- /dev/null +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.test.ts @@ -0,0 +1,37 @@ +import { + generateReactComponent, + generateStandaloneHtml, +} from '@/app/halftone/_lib/exporters'; +import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames'; +import { DEFAULT_HALFTONE_SETTINGS } from '@/app/halftone/_lib/state'; + +describe('halftone export naming', () => { + it('normalizes free-form export names into safe component and file names', () => { + expect(resolveExportArtifactNames('hero export 2026')).toEqual({ + componentName: 'HeroExport2026', + fileBaseName: 'hero-export-2026', + }); + }); + + it('sanitizes generated React component identifiers', () => { + const output = generateReactComponent( + DEFAULT_HALFTONE_SETTINGS, + undefined, + 'hero export 2026', + ); + + expect(output).toContain('type HeroExport2026Props = {'); + expect(output).toContain('export default function HeroExport2026({'); + expect(output).not.toContain('type hero export 2026Props = {'); + }); + + it('sanitizes generated standalone HTML titles', async () => { + const output = await generateStandaloneHtml( + DEFAULT_HALFTONE_SETTINGS, + undefined, + 'hero export 2026', + ); + + expect(output).toContain('HeroExport2026'); + }); +}); 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 6244665f9b..03085470e8 100644 --- a/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts +++ b/packages/twenty-website-new/src/app/halftone/_lib/exporters.ts @@ -1,3 +1,4 @@ +import { normalizeExportComponentName } from '@/app/halftone/_lib/exportNames'; import { HALFTONE_FOOTPRINT_RUNTIME_SOURCE, REFERENCE_PREVIEW_DISTANCE, @@ -10,8 +11,9 @@ import { type HalftoneGeometrySpec, type HalftoneStudioSettings, } from '@/app/halftone/_lib/state'; +import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData'; -const passThroughVertexShader = /* glsl */ ` +const passThroughVertexShader = ` varying vec2 vUv; void main() { @@ -20,7 +22,7 @@ const passThroughVertexShader = /* glsl */ ` } `; -const blurFragmentShader = /* glsl */ ` +const blurFragmentShader = ` precision highp float; uniform sampler2D tInput; @@ -52,7 +54,7 @@ const blurFragmentShader = /* glsl */ ` } `; -const imagePassthroughFragmentShader = /* glsl */ ` +const imagePassthroughFragmentShader = ` precision highp float; uniform sampler2D tImage; @@ -69,7 +71,6 @@ const imagePassthroughFragmentShader = /* glsl */ ` vec2 uv = vUv; - // Contain: show full image, letterbox/pillarbox as needed if (imageAspect > viewAspect) { float scale = viewAspect / imageAspect; uv.y = (uv.y - 0.5) / scale + 0.5; @@ -90,7 +91,7 @@ const imagePassthroughFragmentShader = /* glsl */ ` } `; -const halftoneFragmentShader = /* glsl */ ` +const halftoneFragmentShader = ` precision highp float; uniform sampler2D tScene; @@ -108,6 +109,9 @@ const halftoneFragmentShader = /* glsl */ ` uniform vec2 interactionUv; uniform vec2 interactionVelocity; uniform vec2 dragOffset; + uniform float hoverHalftonePowerShift; + uniform float hoverHalftoneRadius; + uniform float hoverHalftoneWidthShift; uniform float hoverLightStrength; uniform float hoverLightRadius; uniform float hoverFlowStrength; @@ -134,7 +138,6 @@ const halftoneFragmentShader = /* glsl */ ` } void main() { - // Crop to image bounds: discard fragments outside source image (image mode only) if (cropToBounds > 0.5) { vec4 boundsCheck = texture2D(tScene, vUv); if (boundsCheck.a < 0.01) { @@ -164,6 +167,15 @@ const halftoneFragmentShader = /* glsl */ ` hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist); } + float hoverHalftoneMask = 0.0; + if ( + abs(hoverHalftonePowerShift) > 0.0001 || + abs(hoverHalftoneWidthShift) > 0.0001 + ) { + float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y; + hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist); + } + float hoverFlowMask = 0.0; if (hoverFlowStrength > 0.0) { float hoverRadiusPx = hoverFlowRadius * logicalResolution.y; @@ -191,6 +203,16 @@ const halftoneFragmentShader = /* glsl */ ` vec4 sceneSample = texture2D(tScene, sampleUv); float mask = smoothstep(0.02, 0.08, sceneSample.a); + float localPower = clamp( + s_3 + hoverHalftonePowerShift * hoverHalftoneMask, + -1.5, + 1.5 + ); + float localWidth = clamp( + s_4 + hoverHalftoneWidthShift * hoverHalftoneMask, + 0.05, + 1.4 + ); float lightLift = hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22; float bandRadius = clamp( @@ -199,7 +221,7 @@ const halftoneFragmentShader = /* glsl */ ` sceneSample.r + sceneSample.g + sceneSample.b + - s_3 * length(vec2(0.5)) + localPower * length(vec2(0.5)) ) * (1.0 / 3.0) ) + lightLift, @@ -209,7 +231,7 @@ const halftoneFragmentShader = /* glsl */ ` float alpha = 0.0; if (bandRadius > 0.0001) { - float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4); + float signedDistance = lineSimpleEt(cellUv, bandRadius, localWidth); float edge = 0.02; alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask; } @@ -222,6 +244,360 @@ const halftoneFragmentShader = /* glsl */ ` } `; +const HALFTONE_TRANSMISSION_SHADER_PREFIX = String.raw` +uniform float chromaticAberration; +uniform float anisotropicBlur; +uniform float time; +uniform float distortion; +uniform float distortionScale; +uniform float temporalDistortion; +uniform sampler2D buffer; + +vec3 random3(vec3 c) { + float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0))); + vec3 r; + r.z = fract(512.0 * j); + j *= 0.125; + r.x = fract(512.0 * j); + j *= 0.125; + r.y = fract(512.0 * j); + return r - 0.5; +} + +uint hash(uint x) { + x += (x << 10u); + x ^= (x >> 6u); + x += (x << 3u); + x ^= (x >> 11u); + x += (x << 15u); + return x; +} + +uint hash(uvec2 v) { return hash(v.x ^ hash(v.y)); } +uint hash(uvec3 v) { return hash(v.x ^ hash(v.y) ^ hash(v.z)); } +uint hash(uvec4 v) { + return hash(v.x ^ hash(v.y) ^ hash(v.z) ^ hash(v.w)); +} + +float floatConstruct(uint m) { + const uint ieeeMantissa = 0x007FFFFFu; + const uint ieeeOne = 0x3F800000u; + m &= ieeeMantissa; + m |= ieeeOne; + float f = uintBitsToFloat(m); + return f - 1.0; +} + +float randomBase(float x) { + return floatConstruct(hash(floatBitsToUint(x))); +} +float randomBase(vec2 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} +float randomBase(vec3 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} +float randomBase(vec4 v) { + return floatConstruct(hash(floatBitsToUint(v))); +} + +float rand(float seed) { + return randomBase(vec3(gl_FragCoord.xy, seed)); +} + +const float F3 = 0.3333333; +const float G3 = 0.1666667; + +float snoise(vec3 p) { + vec3 s = floor(p + dot(p, vec3(F3))); + vec3 x = p - s + dot(s, vec3(G3)); + vec3 e = step(vec3(0.0), x - x.yzx); + vec3 i1 = e * (1.0 - e.zxy); + vec3 i2 = 1.0 - e.zxy * (1.0 - e); + vec3 x1 = x - i1 + G3; + vec3 x2 = x - i2 + 2.0 * G3; + vec3 x3 = x - 1.0 + 3.0 * G3; + vec4 w; + vec4 d; + w.x = dot(x, x); + w.y = dot(x1, x1); + w.z = dot(x2, x2); + w.w = dot(x3, x3); + w = max(0.6 - w, 0.0); + d.x = dot(random3(s), x); + d.y = dot(random3(s + i1), x1); + d.z = dot(random3(s + i2), x2); + d.w = dot(random3(s + 1.0), x3); + w *= w; + w *= w; + d *= w; + return dot(d, vec4(52.0)); +} + +float snoiseFractal(vec3 m) { + return 0.5333333 * snoise(m) + + 0.2666667 * snoise(2.0 * m) + + 0.1333333 * snoise(4.0 * m) + + 0.0666667 * snoise(8.0 * m); +} +`; + +const HALFTONE_TRANSMISSION_PARS_FRAGMENT = String.raw` +#ifdef USE_TRANSMISSION + uniform float _transmission; + uniform float thickness; + uniform float attenuationDistance; + uniform vec3 attenuationColor; + uniform sampler2D refractionEnvMap; + uniform float useEnvMapRefraction; + #ifdef USE_TRANSMISSIONMAP + uniform sampler2D transmissionMap; + #endif + #ifdef USE_THICKNESSMAP + uniform sampler2D thicknessMap; + #endif + uniform vec2 transmissionSamplerSize; + uniform sampler2D transmissionSamplerMap; + uniform mat4 modelMatrix; + uniform mat4 projectionMatrix; + varying vec3 vWorldPosition; + + vec3 getVolumeTransmissionRay( + const in vec3 n, + const in vec3 v, + const in float thicknessValue, + const in float ior, + const in mat4 modelMatrix + ) { + vec3 refractionVector = refract(-v, normalize(n), 1.0 / ior); + vec3 modelScale; + modelScale.x = length(vec3(modelMatrix[0].xyz)); + modelScale.y = length(vec3(modelMatrix[1].xyz)); + modelScale.z = length(vec3(modelMatrix[2].xyz)); + return normalize(refractionVector) * thicknessValue * modelScale; + } + + float applyIorToRoughness( + const in float roughnessValue, + const in float ior + ) { + return roughnessValue * clamp(ior * 2.0 - 2.0, 0.0, 1.0); + } + + vec2 directionToEquirectUv(const in vec3 direction) { + vec3 dir = normalize(direction); + vec2 uv = vec2( + atan(dir.z, dir.x) * 0.15915494309189535 + 0.5, + asin(clamp(dir.y, -1.0, 1.0)) * 0.3183098861837907 + 0.5 + ); + + return vec2(fract(uv.x), 1.0 - clamp(uv.y, 0.0, 1.0)); + } + + vec4 getTransmissionSample( + const in vec2 fragCoord, + const in vec3 transmissionDirection, + const in float roughnessValue, + const in float ior + ) { + if (useEnvMapRefraction > 0.5) { + return texture2D( + refractionEnvMap, + directionToEquirectUv(transmissionDirection) + ); + } + + float framebufferLod = + log2(transmissionSamplerSize.x) * + applyIorToRoughness(roughnessValue, ior); + return texture2D(buffer, fragCoord.xy); + } + + vec3 applyVolumeAttenuation( + const in vec3 radiance, + const in float transmissionDistance, + const in vec3 attenuationColorValue, + const in float attenuationDistanceValue + ) { + if (isinf(attenuationDistanceValue)) { + return radiance; + } + + vec3 attenuationCoefficient = + -log(attenuationColorValue) / attenuationDistanceValue; + vec3 transmittance = + exp(-attenuationCoefficient * transmissionDistance); + + return transmittance * radiance; + } + + vec4 getIBLVolumeRefraction( + const in vec3 n, + const in vec3 v, + const in float roughnessValue, + const in vec3 diffuseColor, + const in vec3 specularColor, + const in float specularF90, + const in vec3 position, + const in mat4 modelMatrix, + const in mat4 viewMatrix, + const in mat4 projMatrix, + const in float ior, + const in float thicknessValue, + const in vec3 attenuationColorValue, + const in float attenuationDistanceValue + ) { + vec3 transmissionRay = getVolumeTransmissionRay( + n, + v, + thicknessValue, + ior, + modelMatrix + ); + vec3 refractedRayExit = position + transmissionRay; + vec4 ndcPos = + projMatrix * viewMatrix * vec4(refractedRayExit, 1.0); + vec2 refractionCoords = ndcPos.xy / ndcPos.w; + refractionCoords += 1.0; + refractionCoords /= 2.0; + vec3 transmissionDirection = normalize(transmissionRay); + vec4 transmittedLight = getTransmissionSample( + refractionCoords, + transmissionDirection, + roughnessValue, + ior + ); + vec3 attenuatedColor = applyVolumeAttenuation( + transmittedLight.rgb, + length(transmissionRay), + attenuationColorValue, + attenuationDistanceValue + ); + vec3 F = EnvironmentBRDF( + n, + v, + specularColor, + specularF90, + roughnessValue + ); + return vec4( + (1.0 - F) * attenuatedColor * diffuseColor, + transmittedLight.a + ); + } +#endif +`; + +const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = String.raw` +material.transmission = _transmission; +material.transmissionAlpha = 1.0; +material.thickness = thickness; +material.attenuationDistance = attenuationDistance; +material.attenuationColor = attenuationColor; +#ifdef USE_TRANSMISSIONMAP + material.transmission *= texture2D(transmissionMap, vUv).r; +#endif +#ifdef USE_THICKNESSMAP + material.thickness *= texture2D(thicknessMap, vUv).g; +#endif + +vec3 pos = vWorldPosition; +float runningSeed = 0.0; +vec3 v = normalize(cameraPosition - pos); +vec3 n = inverseTransformDirection(normal, viewMatrix); +vec3 transmission = vec3(0.0); +float transmissionR; +float transmissionG; +float transmissionB; +float randomCoords = rand(runningSeed++); +float thicknessSmear = + thickness * max(pow(roughnessFactor, 0.33), anisotropicBlur); +vec3 distortionNormal = vec3(0.0); +vec3 temporalOffset = vec3(time, -time, -time) * temporalDistortion; + +if (distortion > 0.0) { + distortionNormal = distortion * vec3( + snoiseFractal(vec3(pos * distortionScale + temporalOffset)), + snoiseFractal(vec3(pos.zxy * distortionScale - temporalOffset)), + snoiseFractal(vec3(pos.yxz * distortionScale + temporalOffset)) + ); +} + +for (float i = 0.0; i < __SAMPLES__.0; i++) { + vec3 sampleNorm = normalize( + n + + roughnessFactor * roughnessFactor * 2.0 * + normalize( + vec3( + rand(runningSeed++) - 0.5, + rand(runningSeed++) - 0.5, + rand(runningSeed++) - 0.5 + ) + ) * + pow(rand(runningSeed++), 0.33) + + distortionNormal + ); + + transmissionR = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior, + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).r; + + transmissionG = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior * (1.0 + chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).g; + + transmissionB = getIBLVolumeRefraction( + sampleNorm, + v, + material.roughness, + material.diffuseColor, + material.specularColor, + material.specularF90, + pos, + modelMatrix, + viewMatrix, + projectionMatrix, + material.ior * (1.0 + 2.0 * chromaticAberration * (i + randomCoords) / float(__SAMPLES__)), + material.thickness + thicknessSmear * (i + randomCoords) / float(__SAMPLES__), + material.attenuationColor, + material.attenuationDistance + ).b; + + transmission.r += transmissionR; + transmission.g += transmissionG; + transmission.b += transmissionB; +} + +transmission /= __SAMPLES__.0; +totalDiffuse = mix(totalDiffuse, transmission.rgb, material.transmission); +`; + const GEOMETRY_RUNTIME_SOURCE = String.raw` function makePolarShape(radiusFunction, segments = 320) { const shape = new THREE.Shape(); @@ -843,26 +1219,6 @@ function normalizePreviewDistance(previewDistance: number | undefined) { : REFERENCE_PREVIEW_DISTANCE; } -function toPascalCase(value: string) { - const tokens = value - .replace(/\.[^.]+$/, '') - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[^a-zA-Z0-9]+/g, ' ') - .trim() - .split(/\s+/) - .filter(Boolean); - - const joined = tokens - .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) - .join(''); - - if (!joined) { - return 'HalftoneDashes'; - } - - return /^[A-Za-z_]/.test(joined) ? joined : `Halftone${joined}`; -} - function extractSerializedJson( content: string, name: string, @@ -995,7 +1351,7 @@ export function deriveExportComponentName( shape?.key ?? 'HalftoneDashes'; - return toPascalCase(source); + return normalizeExportComponentName(source); } function createShapeDescriptor( @@ -1079,6 +1435,364 @@ async function fileToDataUrl( }); } +const GLASS_MATERIAL_RUNTIME_SOURCE = String.raw` +const GLASS_ENVIRONMENT_DATA_URL = ${JSON.stringify(GLASS_ENVIRONMENT_DATA_URL)}; +const GLASS_THICKNESS_TO_WORLD_UNITS = 1 / 320; +const GLASS_ATTENUATION_DISTANCE_MIN = 0.12; +const GLASS_ENVIRONMENT_INTENSITY_BASE = 0.18; +const GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER = 0.12; +const GLASS_ENVIRONMENT_ZOOM = 1.55; +const GLASS_TRANSMISSION_BACKGROUND = new THREE.Color(0x030303); +const MAX_TEXTURE_ANISOTROPY = 8; +const HALFTONE_TRANSMISSION_SHADER_PREFIX = ${JSON.stringify( + HALFTONE_TRANSMISSION_SHADER_PREFIX, +)}; +const HALFTONE_TRANSMISSION_PARS_FRAGMENT = ${JSON.stringify( + HALFTONE_TRANSMISSION_PARS_FRAGMENT, +)}; +const HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE = ${JSON.stringify( + HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE, +)}; + +class HalftoneTransmissionMaterial extends THREE.MeshPhysicalMaterial { + constructor(samples = 10) { + super(); + + this.halftoneUniforms = { + chromaticAberration: { value: 0.05 }, + transmission: { value: 0 }, + _transmission: { value: 1 }, + transmissionMap: { value: null }, + refractionEnvMap: { value: null }, + useEnvMapRefraction: { value: 0 }, + roughness: { value: 0 }, + thickness: { value: 0 }, + thicknessMap: { value: null }, + attenuationDistance: { value: Infinity }, + attenuationColor: { value: new THREE.Color('white') }, + anisotropicBlur: { value: 0.1 }, + time: { value: 0 }, + distortion: { value: 0 }, + distortionScale: { value: 0.5 }, + temporalDistortion: { value: 0 }, + buffer: { value: null }, + }; + + this.customProgramCacheKey = () => 'halftone-transmission-' + samples; + + this.onBeforeCompile = (shader) => { + shader.uniforms = { + ...shader.uniforms, + ...this.halftoneUniforms, + }; + shader.defines ??= {}; + + if (this.anisotropy > 0) { + shader.defines.USE_ANISOTROPY = ''; + } + + shader.defines.USE_TRANSMISSION = ''; + shader.fragmentShader = + HALFTONE_TRANSMISSION_SHADER_PREFIX + shader.fragmentShader; + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + HALFTONE_TRANSMISSION_PARS_FRAGMENT, + ); + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + HALFTONE_TRANSMISSION_FRAGMENT_TEMPLATE.replaceAll( + '__SAMPLES__', + String(samples), + ), + ); + }; + + Object.keys(this.halftoneUniforms).forEach((key) => { + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + get: () => this.halftoneUniforms[key]?.value, + set: (value) => { + this.halftoneUniforms[key].value = value; + }, + }); + }); + } +} + +function setTextureSampling(texture, renderer) { + texture.generateMipmaps = true; + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.anisotropy = Math.min( + renderer.capabilities.getMaxAnisotropy(), + MAX_TEXTURE_ANISOTROPY, + ); +} + +function disposeEnvironmentScene(scene) { + scene.traverse((object) => { + if (object.geometry) { + object.geometry.dispose(); + } + + if (Array.isArray(object.material)) { + object.material.forEach((material) => material.dispose()); + return; + } + + object.material?.dispose?.(); + }); +} + +function createSolidEnvironmentTexture(renderer) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = pmremGenerator.fromScene( + new RoomEnvironment(), + 0.04, + ).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function getTextureImageSize(texture) { + const image = texture.image; + + return { + height: + image?.naturalHeight ?? image?.videoHeight ?? image?.height ?? undefined, + width: + image?.naturalWidth ?? image?.videoWidth ?? image?.width ?? undefined, + }; +} + +function createZoomedGlassTexture(sourceTexture, renderer, zoom) { + if (zoom <= 1) { + return sourceTexture; + } + + const { width, height } = getTextureImageSize(sourceTexture); + + if (!width || !height) { + return sourceTexture; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + + if (!context) { + return sourceTexture; + } + + const cropWidth = width / zoom; + const cropHeight = height / zoom; + const sourceX = (width - cropWidth) / 2; + const sourceY = (height - cropHeight) / 2; + + context.drawImage( + sourceTexture.image, + sourceX, + sourceY, + cropWidth, + cropHeight, + 0, + 0, + width, + height, + ); + + const zoomedTexture = new THREE.CanvasTexture(canvas); + zoomedTexture.colorSpace = sourceTexture.colorSpace; + zoomedTexture.wrapS = THREE.ClampToEdgeWrapping; + zoomedTexture.wrapT = THREE.ClampToEdgeWrapping; + setTextureSampling(zoomedTexture, renderer); + zoomedTexture.needsUpdate = true; + + return zoomedTexture; +} + +function createStudioGlassEnvironmentTexture(renderer, backdropTexture) { + const pmremGenerator = new THREE.PMREMGenerator(renderer); + const environmentTexture = backdropTexture + ? pmremGenerator.fromEquirectangular(backdropTexture).texture + : pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; + pmremGenerator.dispose(); + + return environmentTexture; +} + +function createFallbackGlassBackdropTexture(renderer) { + const texture = new THREE.DataTexture( + new Uint8Array([3, 3, 3, 255]), + 1, + 1, + THREE.RGBAFormat, + ); + texture.colorSpace = THREE.SRGBColorSpace; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.mapping = THREE.EquirectangularReflectionMapping; + setTextureSampling(texture, renderer); + texture.needsUpdate = true; + + return texture; +} + +function loadTexture(url, renderer, colorSpace) { + const loader = new THREE.TextureLoader(); + + return new Promise((resolve, reject) => { + loader.load( + url, + (texture) => { + texture.colorSpace = colorSpace; + setTextureSampling(texture, renderer); + resolve(texture); + }, + undefined, + reject, + ); + }); +} + +async function loadGlassEnvironmentTexture(renderer) { + const sourceBackgroundTexture = await loadTexture( + GLASS_ENVIRONMENT_DATA_URL, + renderer, + THREE.SRGBColorSpace, + ); + const backgroundTexture = createZoomedGlassTexture( + sourceBackgroundTexture, + renderer, + GLASS_ENVIRONMENT_ZOOM, + ); + if (backgroundTexture !== sourceBackgroundTexture) { + sourceBackgroundTexture.dispose(); + } + backgroundTexture.mapping = THREE.EquirectangularReflectionMapping; + backgroundTexture.wrapS = THREE.ClampToEdgeWrapping; + backgroundTexture.wrapT = THREE.ClampToEdgeWrapping; + backgroundTexture.needsUpdate = true; + const environmentTexture = createStudioGlassEnvironmentTexture( + renderer, + backgroundTexture, + ); + + return { + backgroundTexture, + environmentTexture, + }; +} + +async function createHalftoneMaterialAssets(renderer) { + const solidEnvironmentTexture = createSolidEnvironmentTexture(renderer); + + try { + const glassEnvironmentAssets = await loadGlassEnvironmentTexture(renderer); + + return { + glassBackgroundTexture: glassEnvironmentAssets.backgroundTexture, + glassEnvironmentTexture: glassEnvironmentAssets.environmentTexture, + solidEnvironmentTexture, + }; + } catch { + const fallbackGlassBackdropTexture = + createFallbackGlassBackdropTexture(renderer); + const fallbackGlassEnvironmentTexture = + createStudioGlassEnvironmentTexture(renderer); + + return { + glassBackgroundTexture: fallbackGlassBackdropTexture, + glassEnvironmentTexture: fallbackGlassEnvironmentTexture, + solidEnvironmentTexture, + }; + } +} + +function createHalftoneMaterial() { + return new HalftoneTransmissionMaterial(); +} + +function applyHalftoneMaterialSettings(material, materialSettings, materialAssets) { + const isGlass = materialSettings.surface === 'glass'; + const glassThickness = + materialSettings.thickness * GLASS_THICKNESS_TO_WORLD_UNITS; + const glassEnvironmentIntensity = + GLASS_ENVIRONMENT_INTENSITY_BASE + + materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER; + const glassAttenuationDistance = Math.max( + glassThickness * 4, + GLASS_ATTENUATION_DISTANCE_MIN, + ); + + material.color.set(isGlass ? '#ffffff' : materialSettings.color); + material.roughness = materialSettings.roughness; + material.metalness = materialSettings.metalness; + material.envMap = isGlass + ? materialAssets.glassEnvironmentTexture + : materialAssets.solidEnvironmentTexture; + material.envMapIntensity = isGlass + ? GLASS_ENVIRONMENT_INTENSITY_BASE + + materialSettings.environmentPower * GLASS_ENVIRONMENT_INTENSITY_MULTIPLIER + : 0.25; + material.clearcoat = isGlass ? 1 : 0; + material.clearcoatRoughness = isGlass + ? Math.max(materialSettings.roughness * 0.25, 0.01) + : 0.08; + material.reflectivity = isGlass ? 0.98 : 0.5; + material.transmission = 0; + material._transmission = isGlass ? 1 : 0; + material.refractionEnvMap = isGlass ? materialAssets.glassBackgroundTexture : null; + material.useEnvMapRefraction = isGlass ? 1 : 0; + material.thickness = isGlass ? glassThickness : 0; + material.ior = isGlass ? materialSettings.refraction : 1.5; + material.buffer = null; + material.bumpMap = null; + material.bumpScale = 0; + material.roughnessMap = null; + material.side = THREE.FrontSide; + material.transparent = false; + material.opacity = 1; + material.depthWrite = true; + material.attenuationColor.set(isGlass ? materialSettings.color : 'white'); + material.attenuationDistance = isGlass + ? glassAttenuationDistance + : Infinity; + material.anisotropicBlur = isGlass + ? THREE.MathUtils.lerp(0.03, 0.12, materialSettings.roughness) + : 0.1; + material.chromaticAberration = isGlass ? 0 : 0.05; + material.distortion = 0; + material.distortionScale = 0.5; + material.temporalDistortion = 0; + material.userData.halftoneIsGlass = isGlass; + material.userData.halftoneGlassBacksideThickness = isGlass + ? glassThickness * 2 + : 0; + material.userData.halftoneGlassBacksideEnvIntensity = isGlass + ? glassEnvironmentIntensity * 2.8 + : 0; + material.userData.halftoneUseEnvironmentRefraction = isGlass; + material.envMapIntensity = isGlass ? glassEnvironmentIntensity : 0.25; + + material.needsUpdate = true; +} + +function disposeHalftoneMaterialAssets(materialAssets) { + materialAssets.glassBackgroundTexture.dispose(); + + if (materialAssets.glassEnvironmentTexture !== materialAssets.glassBackgroundTexture) { + materialAssets.glassEnvironmentTexture.dispose(); + } + + materialAssets.solidEnvironmentTexture.dispose(); +} +`; + function serializeRuntimeSource( settings: HalftoneStudioSettings, shape: ExportedShapeDescriptor, @@ -1105,6 +1819,8 @@ ${isImageMode ? '' : GEOMETRY_RUNTIME_SOURCE} ${isImageMode ? '' : IMPORTED_RUNTIME_SOURCE} +${isImageMode ? '' : GLASS_MATERIAL_RUNTIME_SOURCE} + function createRenderTarget(width, height) { return new THREE.WebGLRenderTarget(width, height, { minFilter: THREE.LinearFilter, @@ -1227,12 +1943,7 @@ async function mountHalftoneCanvas(options) { canvas.style.width = '100%'; container.appendChild(canvas); - const pmremGenerator = new THREE.PMREMGenerator(renderer); - const environmentTexture = pmremGenerator.fromScene( - new RoomEnvironment(), - 0.04, - ).texture; - pmremGenerator.dispose(); + const materialAssets = await createHalftoneMaterialAssets(renderer); const scene3d = new THREE.Scene(); scene3d.background = null; @@ -1262,17 +1973,8 @@ async function mountHalftoneCanvas(options) { ); scene3d.add(ambientLight); - const material = new THREE.MeshPhysicalMaterial({ - color: 0xd4d0c8, - roughness: settings.material.roughness, - metalness: settings.material.metalness, - envMap: environmentTexture, - envMapIntensity: 0.25, - clearcoat: 0, - clearcoatRoughness: 0.08, - reflectivity: 0.5, - transmission: 0, - }); + const material = createHalftoneMaterial(); + applyHalftoneMaterialSettings(material, settings.material, materialAssets); const mesh = new THREE.Mesh(geometry, material); scene3d.add(mesh); @@ -1325,6 +2027,9 @@ async function mountHalftoneCanvas(options) { interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, interactionVelocity: { value: new THREE.Vector2(0, 0) }, dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverHalftonePowerShift: { value: 0 }, + hoverHalftoneRadius: { value: 0.2 }, + hoverHalftoneWidthShift: { value: 0 }, hoverLightStrength: { value: 0 }, hoverLightRadius: { value: 0.2 }, hoverFlowStrength: { value: 0 }, @@ -1507,14 +2212,16 @@ async function mountHalftoneCanvas(options) { window.addEventListener('blur', handleWindowBlur); canvas.addEventListener('pointerdown', handlePointerDown); - const clock = new THREE.Clock(); + const clock = new THREE.Timer(); + clock.connect(document); let animationFrameId = 0; - const renderFrame = () => { + const renderFrame = (timestamp) => { animationFrameId = window.requestAnimationFrame(renderFrame); + clock.update(timestamp); const delta = 1 / 60; - const elapsedTime = initialPose.timeElapsed + clock.getElapsedTime(); + const elapsedTime = initialPose.timeElapsed + clock.getElapsed(); halftoneMaterial.uniforms.time.value = elapsedTime; let baseRotationX = 0; @@ -1761,6 +2468,7 @@ async function mountHalftoneCanvas(options) { return () => { window.cancelAnimationFrame(animationFrameId); + clock.dispose(); resizeObserver.disconnect(); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerleave', handlePointerLeave); @@ -1776,7 +2484,7 @@ async function mountHalftoneCanvas(options) { sceneTarget.dispose(); blurTargetA.dispose(); blurTargetB.dispose(); - environmentTexture.dispose(); + disposeHalftoneMaterialAssets(materialAssets); renderer.dispose(); if (canvas.parentNode === container) { @@ -1805,7 +2513,6 @@ async function mountHalftoneCanvas(options) { 1, ); - // Load image const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); @@ -1894,6 +2601,9 @@ async function mountHalftoneCanvas(options) { interactionUv: { value: new THREE.Vector2(0.5, 0.5) }, interactionVelocity: { value: new THREE.Vector2(0, 0) }, dragOffset: { value: new THREE.Vector2(0, 0) }, + hoverHalftonePowerShift: { value: 0 }, + hoverHalftoneRadius: { value: settings.animation.hoverHalftoneRadius }, + hoverHalftoneWidthShift: { value: 0 }, hoverLightStrength: { value: 0 }, hoverLightRadius: { value: settings.animation.hoverLightRadius }, hoverFlowStrength: { value: 0 }, @@ -2013,8 +2723,8 @@ async function mountHalftoneCanvas(options) { try { canvas.releasePointerCapture(pointerId); - } catch { - // Ignore capture release failures during teardown. + } catch (error) { + void error; } }; @@ -2075,13 +2785,15 @@ async function mountHalftoneCanvas(options) { window.addEventListener('blur', handleWindowBlur); canvas.addEventListener('pointerdown', handlePointerDown); - const clock = new THREE.Clock(); + const clock = new THREE.Timer(); + clock.connect(document); let animationFrameId = 0; - const renderFrame = () => { + const renderFrame = (timestamp) => { animationFrameId = window.requestAnimationFrame(renderFrame); + clock.update(timestamp); - const elapsedTime = clock.getElapsedTime(); + const elapsedTime = clock.getElapsed(); halftoneMaterial.uniforms.time.value = elapsedTime; const pointerActive = interaction.pointerInside; @@ -2101,6 +2813,16 @@ async function mountHalftoneCanvas(options) { -interaction.pointerVelocityY * getVirtualHeight(), ); halftoneMaterial.uniforms.dragOffset.value.set(0, 0); + halftoneMaterial.uniforms.hoverHalftonePowerShift.value = + pointerActive && settings.animation.hoverHalftoneEnabled + ? settings.animation.hoverHalftonePowerShift + : 0; + halftoneMaterial.uniforms.hoverHalftoneRadius.value = + settings.animation.hoverHalftoneRadius; + halftoneMaterial.uniforms.hoverHalftoneWidthShift.value = + pointerActive && settings.animation.hoverHalftoneEnabled + ? settings.animation.hoverHalftoneWidthShift + : 0; halftoneMaterial.uniforms.hoverLightStrength.value = pointerActive && settings.animation.hoverLightEnabled ? settings.animation.hoverLightIntensity @@ -2141,6 +2863,7 @@ async function mountHalftoneCanvas(options) { return () => { window.cancelAnimationFrame(animationFrameId); + clock.dispose(); resizeObserver.disconnect(); canvas.removeEventListener('pointermove', handlePointerMove); canvas.removeEventListener('pointerleave', handlePointerLeave); @@ -2197,6 +2920,8 @@ export function generateReactComponent( modelFilenameOverride, ); const pose = normalizeExportPose(initialPose); + const normalizedComponentName = + normalizeExportComponentName(componentName); const defaultModelUrl = modelFilenameOverride ?? shape.filename ?? 'model.glb'; const defaultImageUrl = imageFilename ?? 'image.png'; @@ -2208,15 +2933,15 @@ ${serializeRuntimeSource(settings, shape, pose, previewDistance)} ${createImageMountScript()} -type ${componentName}Props = { +type ${normalizedComponentName}Props = { imageUrl?: string; style?: CSSProperties; }; -export default function ${componentName}({ +export default function ${normalizedComponentName}({ imageUrl = ${JSON.stringify(`./${defaultImageUrl}`)}, style, -}: ${componentName}Props) { +}: ${normalizedComponentName}Props) { const mountReference = useRef(null); useEffect(() => { @@ -2264,15 +2989,15 @@ ${serializeRuntimeSource(settings, shape, pose, previewDistance)} ${createMountScript()} -type ${componentName}Props = { +type ${normalizedComponentName}Props = { modelUrl?: string; style?: CSSProperties; }; -export default function ${componentName}({ +export default function ${normalizedComponentName}({ modelUrl = ${JSON.stringify(`./${defaultModelUrl}`)}, style, -}: ${componentName}Props) { +}: ${normalizedComponentName}Props) { const mountReference = useRef(null); useEffect(() => { @@ -2329,6 +3054,8 @@ export async function generateStandaloneHtml( modelFilenameOverride, ); const pose = normalizeExportPose(initialPose); + const normalizedComponentName = + normalizeExportComponentName(componentName); const defaultImageUrl = imageFilename ?? 'image.png'; const embeddedImportedModelUrl = !isImageMode && shape.kind === 'imported' && importedFile @@ -2383,7 +3110,7 @@ export async function generateStandaloneHtml( - ${componentName} + ${normalizedComponentName}