diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 18c8f80c72..7a869a1098 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@sniptt/guards": "^0.2.0", + "ai": "6.0.97", "class-validator": "^0.14.0", "expr-eval-fork": "3.0.3", "handlebars": "^4.7.9", diff --git a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts index bedbf19434..49dddac6e6 100644 --- a/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts +++ b/packages/twenty-website-new/src/lib/halftone/geometry-registry.ts @@ -21,10 +21,28 @@ type GeometryCacheEntry = THREE.BufferGeometry | Promise; export type ImportedGeometryNormalizationOptions = { postRotateZ?: number; + scaleTarget?: number; useLegacyNormalization?: boolean; }; const LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET = 2.75; +const importedGeometryCache = new Map(); + +function getImportedGeometryCacheKey({ + geometryOptions, + loader, + modelUrl, +}: { + geometryOptions?: ImportedGeometryNormalizationOptions; + loader: HalftoneModelLoader; + modelUrl: string; +}) { + return JSON.stringify({ + geometryOptions: geometryOptions ?? null, + loader, + modelUrl, + }); +} function mergeGeometries(geometries: THREE.BufferGeometry[]) { if (geometries.length === 1) { @@ -128,7 +146,11 @@ function normalizeImportedGeometry( geometry: THREE.BufferGeometry, options: ImportedGeometryNormalizationOptions = {}, ) { - const { postRotateZ = 0, useLegacyNormalization = false } = options; + const { + postRotateZ = 0, + scaleTarget, + useLegacyNormalization = false, + } = options; geometry.computeBoundingBox(); @@ -156,9 +178,9 @@ function normalizeImportedGeometry( const radius = geometry.boundingSphere?.radius || 1; const scale = useLegacyNormalization - ? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET / + ? (scaleTarget ?? LEGACY_IMPORTED_GEOMETRY_SCALE_TARGET) / Math.max(size.x, size.y, size.z, 0.001) - : 1.6 / radius; + : (scaleTarget ?? 1.6) / radius; geometry.scale(scale, scale, scale); geometry.computeBoundingBox(); @@ -309,19 +331,44 @@ export async function loadImportedGeometryFromUrl( label: string, geometryOptions?: ImportedGeometryNormalizationOptions, ) { - const response = await fetch(modelUrl); + const cacheKey = getImportedGeometryCacheKey({ + geometryOptions, + loader, + modelUrl, + }); + const cachedGeometry = importedGeometryCache.get(cacheKey); - if (!response.ok) { - throw new Error(`Unable to load ${label} from ${modelUrl}.`); + if (cachedGeometry) { + return (await cachedGeometry).clone(); } - const buffer = await response.arrayBuffer(); + const geometryPromise = (async () => { + const response = await fetch(modelUrl); - if (loader === 'fbx') { - return parseFbxGeometry(buffer, '', label, geometryOptions); + if (!response.ok) { + throw new Error(`Unable to load ${label} from ${modelUrl}.`); + } + + const buffer = await response.arrayBuffer(); + + if (loader === 'fbx') { + return parseFbxGeometry(buffer, '', label, geometryOptions); + } + + return parseGlbGeometry(buffer, '', label, geometryOptions); + })(); + importedGeometryCache.set(cacheKey, geometryPromise); + + try { + const geometry = await geometryPromise; + + importedGeometryCache.set(cacheKey, geometry); + + return geometry.clone(); + } catch (error) { + importedGeometryCache.delete(cacheKey); + throw error; } - - return parseGlbGeometry(buffer, '', label, geometryOptions); } function makePolarShape( diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx index c8683f2991..894e6cf0a9 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/halftone-canvas.tsx @@ -309,6 +309,8 @@ export type HalftoneSnapshotFn = ( }, ) => Promise; +export type HalftoneRenderStrategy = 'continuous' | 'static'; + export type HalftoneImageInteractionSettings = { hoverFadeIn: number; hoverFadeOut: number; @@ -332,6 +334,7 @@ type HalftoneCanvasProps = { onFirstInteraction: () => void; onPoseChange: (pose: HalftoneExportPose) => void; previewDistance: number; + renderStrategy?: HalftoneRenderStrategy; settings: HalftoneStudioSettings; snapshotRef?: MutableRefObject; virtualRenderHeight?: number; @@ -557,6 +560,7 @@ export function HalftoneCanvas({ onFirstInteraction, onPoseChange, previewDistance, + renderStrategy = 'continuous', settings, snapshotRef, virtualRenderHeight = VIRTUAL_RENDER_HEIGHT, @@ -738,10 +742,14 @@ export function HalftoneCanvas({ renderer.setSize(getRenderWidth(), getRenderHeight(), false); const canvas = renderer.domElement; - canvas.style.cursor = getCanvasCursor(settingsReference.current, false); + canvas.style.cursor = + renderStrategy === 'static' + ? 'default' + : getCanvasCursor(settingsReference.current, false); canvas.style.display = 'block'; canvas.style.height = '100%'; - canvas.style.touchAction = 'none'; + canvas.style.pointerEvents = renderStrategy === 'static' ? 'none' : 'auto'; + canvas.style.touchAction = renderStrategy === 'static' ? 'auto' : 'none'; canvas.style.width = '100%'; container.appendChild(canvas); @@ -1312,8 +1320,6 @@ export function HalftoneCanvas({ ); }; - const stopObservingSize = observeElementSize(container, syncSize); - const updatePointerPosition = ( event: PointerEvent, options?: { resetVelocity?: boolean }, @@ -1546,13 +1552,6 @@ export function HalftoneCanvas({ 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); @@ -1985,18 +1984,36 @@ export function HalftoneCanvas({ renderer.render(postScene, orthographicCamera); }; - renderLoop = createVisualRenderLoop({ - renderFrame, - shouldRender: () => !cancelled, - target: container, - targetVisibilityOptions: { rootMargin: '100px' }, - }); - renderLoop.start(); + const renderCurrentFrame = () => { + renderFrame( + typeof performance === 'undefined' ? undefined : performance.now(), + ); + }; - cleanup = () => { - runCleanupTasks([ - () => stopObservingSize(), - () => renderLoop?.dispose(), + const syncSizeAndRenderIfStatic = () => { + syncSize(); + + if (renderStrategy === 'static') { + renderCurrentFrame(); + } + }; + + const stopObservingSize = observeElementSize( + container, + syncSizeAndRenderIfStatic, + ); + + const interactionCleanupTasks: Array<() => void> = []; + + if (renderStrategy !== 'static') { + canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('pointerup', handlePointerUp); + canvas.addEventListener('pointercancel', handlePointerCancel); + window.addEventListener('blur', handleWindowBlur); + canvas.addEventListener('pointerdown', handlePointerDown); + + interactionCleanupTasks.push( () => canvas.removeEventListener('pointermove', handlePointerMove), () => canvas.removeEventListener('pointerleave', handlePointerLeave), () => canvas.removeEventListener('pointerup', handlePointerUp), @@ -2004,6 +2021,24 @@ export function HalftoneCanvas({ canvas.removeEventListener('pointercancel', handlePointerCancel), () => window.removeEventListener('blur', handleWindowBlur), () => canvas.removeEventListener('pointerdown', handlePointerDown), + ); + + renderLoop = createVisualRenderLoop({ + renderFrame, + shouldRender: () => !cancelled, + target: container, + targetVisibilityOptions: { rootMargin: '100px' }, + }); + renderLoop.start(); + } else { + renderCurrentFrame(); + } + + cleanup = () => { + runCleanupTasks([ + () => stopObservingSize(), + () => renderLoop?.dispose(), + ...interactionCleanupTasks, () => clock.dispose(), () => blurHorizontalMaterial.dispose(), @@ -2050,6 +2085,7 @@ export function HalftoneCanvas({ }, [ onFirstInteraction, poseChangeReference, + renderStrategy, snapshotReference, virtualRenderHeight, ]); diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx index ab0d2f1652..67eba9dd8e 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/halftone-image-canvas.tsx @@ -1,6 +1,7 @@ 'use client'; import { useLatestRef } from '@/lib/react'; +import { loadVisualImage } from '@/lib/visual-runtime'; import { type MutableRefObject, type RefObject, @@ -74,22 +75,22 @@ function useImageElement({ useEffect(() => { let cancelled = false; - const image = new Image(); - setImageElement(null); - if (typeof crossOrigin !== 'undefined') { - image.crossOrigin = crossOrigin; - } + void loadVisualImage(imageUrl, { + crossOrigin, + label: 'halftone image', + }) + .then((image) => { + if (!cancelled) { + setImageElement(image); + } + }) + .catch(() => { + if (cancelled) { + return; + } - image.decoding = 'async'; - image.onload = () => { - if (!cancelled) { - setImageElement(image); - } - }; - image.onerror = () => { - if (!cancelled) { const handler = onImageLoadErrorReference.current; const error = createImageLoadError(imageUrl); @@ -101,15 +102,10 @@ function useImageElement({ if (process.env.NODE_ENV !== 'production') { console.error(error); } - } - }; - image.src = imageUrl; + }); return () => { cancelled = true; - image.onload = null; - image.onerror = null; - image.src = ''; }; }, [crossOrigin, imageUrl, onImageLoadErrorReference]); diff --git a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx b/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx index 67d2a7c8f9..3935ccb566 100644 --- a/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx +++ b/packages/twenty-website-new/src/lib/halftone/halftone-model-canvas.tsx @@ -14,7 +14,11 @@ import { loadImportedGeometryFromUrl, type ImportedGeometryNormalizationOptions, } from './geometry-registry'; -import { HalftoneCanvas, type HalftoneSnapshotFn } from './halftone-canvas'; +import { + HalftoneCanvas, + type HalftoneRenderStrategy, + type HalftoneSnapshotFn, +} from './halftone-canvas'; import type { HalftoneExportPose, HalftoneModelLoader, @@ -31,6 +35,7 @@ type HalftoneModelCanvasProps = { onGeometryLoadError?: (error: Error) => void; onPoseChange?: (pose: HalftoneExportPose) => void; previewDistance: number; + renderStrategy?: HalftoneRenderStrategy; settings: HalftoneStudioSettings; snapshotRef?: MutableRefObject; virtualRenderHeight?: number; @@ -121,6 +126,7 @@ export function HalftoneModelCanvas({ onGeometryLoadError, onPoseChange = noopPoseChange, previewDistance, + renderStrategy, settings, snapshotRef, virtualRenderHeight, @@ -146,6 +152,7 @@ export function HalftoneModelCanvas({ onFirstInteraction={onFirstInteraction} onPoseChange={onPoseChange} previewDistance={previewDistance} + renderStrategy={renderStrategy} settings={settings} snapshotRef={snapshotRef} virtualRenderHeight={virtualRenderHeight} diff --git a/packages/twenty-website-new/src/lib/halftone/index.ts b/packages/twenty-website-new/src/lib/halftone/index.ts index f3d0ccb274..f245679e54 100644 --- a/packages/twenty-website-new/src/lib/halftone/index.ts +++ b/packages/twenty-website-new/src/lib/halftone/index.ts @@ -1,6 +1,7 @@ export { HalftoneCanvas, type HalftoneImageInteractionSettings, + type HalftoneRenderStrategy, type HalftoneSnapshotFn, } from './halftone-canvas'; export { HalftoneImageCanvas } from './halftone-image-canvas'; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts new file mode 100644 index 0000000000..8dc865dd9b --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-mount-scheduler.test.ts @@ -0,0 +1,112 @@ +import { createVisualMountScheduler } from '../visual-mount-scheduler'; + +function createManualIdleHost() { + let nextHandle = 1; + const callbacks = new Map< + number, + { + callback: IdleRequestCallback; + options?: IdleRequestOptions; + } + >(); + + return { + callbacks, + cancelIdleCallback: jest.fn((handle: number) => { + callbacks.delete(handle); + }), + requestIdleCallback: jest.fn( + (callback: IdleRequestCallback, options?: IdleRequestOptions) => { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, { callback, options }); + + return handle; + }, + ), + runIdle: (handle: number) => { + const task = callbacks.get(handle); + callbacks.delete(handle); + task?.callback({ + didTimeout: false, + timeRemaining: () => 50, + }); + }, + }; +} + +describe('createVisualMountScheduler', () => { + it('runs scheduled visual mounts during idle time', () => { + const host = createManualIdleHost(); + const scheduler = createVisualMountScheduler(host); + const callback = jest.fn(); + + scheduler.schedule(callback); + + expect(callback).not.toHaveBeenCalled(); + expect(host.requestIdleCallback).toHaveBeenCalledWith( + expect.any(Function), + { timeout: 80 }, + ); + + host.runIdle(1); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancels pending visual mounts before they run', () => { + const host = createManualIdleHost(); + const scheduler = createVisualMountScheduler(host); + const callback = jest.fn(); + + const cancel = scheduler.schedule(callback); + cancel(); + + host.runIdle(1); + + expect(host.cancelIdleCallback).toHaveBeenCalledWith(1); + expect(callback).not.toHaveBeenCalled(); + }); + + it('processes one visual mount per idle slot', () => { + const host = createManualIdleHost(); + const scheduler = createVisualMountScheduler(host); + const firstCallback = jest.fn(); + const secondCallback = jest.fn(); + + scheduler.schedule(firstCallback); + scheduler.schedule(secondCallback); + + expect(host.requestIdleCallback).toHaveBeenCalledTimes(1); + + host.runIdle(1); + + expect(firstCallback).toHaveBeenCalledTimes(1); + expect(secondCallback).not.toHaveBeenCalled(); + expect(host.requestIdleCallback).toHaveBeenCalledTimes(2); + + host.runIdle(2); + + expect(secondCallback).toHaveBeenCalledTimes(1); + }); + + it('moves priority visual mounts ahead of normal visual mounts', () => { + const host = createManualIdleHost(); + const scheduler = createVisualMountScheduler(host); + const calls: string[] = []; + + scheduler.schedule(() => calls.push('normal')); + scheduler.schedule(() => calls.push('priority'), { priority: 'priority' }); + + expect(host.cancelIdleCallback).toHaveBeenCalledWith(1); + expect(host.requestIdleCallback).toHaveBeenLastCalledWith( + expect.any(Function), + { timeout: 0 }, + ); + + host.runIdle(2); + host.runIdle(3); + + expect(calls).toEqual(['priority', 'normal']); + }); +}); diff --git a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts index b8d18b3feb..2930691747 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/__tests__/visual-render-loop.test.ts @@ -125,6 +125,27 @@ describe('createVisualRenderLoop', () => { expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(2); }); + it('does not reschedule when stopped during a frame callback', () => { + const scheduler = createAnimationFrameScheduler(); + let loop: ReturnType; + const renderFrame = jest.fn(() => { + loop.stop(); + }); + loop = createVisualRenderLoop({ + cancelAnimationFrame: scheduler.cancelAnimationFrame, + document: null, + renderFrame, + requestAnimationFrame: scheduler.requestAnimationFrame, + }); + + loop.start(); + scheduler.runFrame(1, 16); + + expect(renderFrame).toHaveBeenCalledTimes(1); + expect(loop.isRunning()).toBe(false); + expect(scheduler.requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + it('pauses and resumes with document visibility', () => { const scheduler = createAnimationFrameScheduler(); const { documentStub, setHidden } = createDocumentVisibilityStub(); diff --git a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts b/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts index d680ee1150..31df0a5779 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/active-webgl-context-budget.ts @@ -1,4 +1,4 @@ -const DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS = 8; +const DEFAULT_MAX_ACTIVE_WEBGL_CONTEXTS = 6; function readNumberEnv(value: string | undefined, fallback: number): number { if (value === undefined) { diff --git a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts b/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts index c0e7d3a713..9f33a01c10 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/create-site-webgl-renderer.ts @@ -4,6 +4,8 @@ export type SiteWebGlRendererParameters = THREE.WebGLRendererParameters & { onContextLost?: (event: WebGLContextEvent) => void; }; +export const SITE_WEBGL_CONTEXT_LOST_EVENT = 'sitewebglcontextlost'; + export function createSiteWebGlRenderer( parameters?: SiteWebGlRendererParameters, ): THREE.WebGLRenderer { @@ -42,6 +44,10 @@ export function createSiteWebGlRenderer( } } + canvas.dispatchEvent( + new CustomEvent(SITE_WEBGL_CONTEXT_LOST_EVENT, { bubbles: true }), + ); + safeDispose(); }; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/index.ts b/packages/twenty-website-new/src/lib/visual-runtime/index.ts index 97219956a3..310af48e66 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/index.ts +++ b/packages/twenty-website-new/src/lib/visual-runtime/index.ts @@ -18,9 +18,19 @@ export { export { useWebGlPolicy } from './use-webgl-policy'; export { WebGlErrorBoundary } from './webgl-error-boundary'; export { WebGlMount } from './webgl-mount'; +export { loadVisualImage } from './load-visual-image'; +export { + createVisualMountScheduler, + scheduleVisualMount, + visualMountScheduler, + type ScheduleVisualMountOptions, + type VisualMountPriority, + type VisualMountScheduler, +} from './visual-mount-scheduler'; export { createSiteWebGlRenderer, reportSiteWebGlRendererCreationFailure, + SITE_WEBGL_CONTEXT_LOST_EVENT, tryCreateSiteWebGlRenderer, type SiteWebGlRendererCreationFailureHandler, type SiteWebGlRendererParameters, diff --git a/packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts b/packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts new file mode 100644 index 0000000000..7e056cdc33 --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/load-visual-image.ts @@ -0,0 +1,67 @@ +type LoadVisualImageOptions = { + crossOrigin?: HTMLImageElement['crossOrigin']; + label?: string; +}; + +const visualImageCache = new Map>(); + +function getVisualImageCacheKey( + imageUrl: string, + crossOrigin: HTMLImageElement['crossOrigin'] | undefined, +) { + return JSON.stringify({ crossOrigin: crossOrigin ?? null, imageUrl }); +} + +function createVisualImageLoadError(imageUrl: string, label: string) { + return new Error(`Failed to load ${label}: ${imageUrl}`); +} + +async function settleImageDecode(image: HTMLImageElement) { + if (typeof image.decode !== 'function') { + return; + } + + try { + await image.decode(); + } catch { + // Some engines reject decode() after a successful load for progressive or + // cached images. The loaded element is still usable as a WebGL texture. + } +} + +export function loadVisualImage( + imageUrl: string, + { crossOrigin, label = 'visual image' }: LoadVisualImageOptions = {}, +) { + const cacheKey = getVisualImageCacheKey(imageUrl, crossOrigin); + const cachedImage = visualImageCache.get(cacheKey); + + if (cachedImage) { + return cachedImage; + } + + const imagePromise = new Promise((resolve, reject) => { + const image = new Image(); + + image.decoding = 'async'; + + if (typeof crossOrigin !== 'undefined') { + image.crossOrigin = crossOrigin; + } + + image.onload = () => { + void settleImageDecode(image).then(() => resolve(image)); + }; + image.onerror = () => { + reject(createVisualImageLoadError(imageUrl, label)); + }; + image.src = imageUrl; + }).catch((error: unknown) => { + visualImageCache.delete(cacheKey); + throw error; + }); + + visualImageCache.set(cacheKey, imagePromise); + + return imagePromise; +} diff --git a/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts b/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts new file mode 100644 index 0000000000..00a6789414 --- /dev/null +++ b/packages/twenty-website-new/src/lib/visual-runtime/visual-mount-scheduler.ts @@ -0,0 +1,212 @@ +export type VisualMountPriority = 'normal' | 'priority'; + +type TimeoutHandle = ReturnType; + +type VisualMountSchedulerHost = { + cancelAnimationFrame?: (handle: number) => void; + cancelIdleCallback?: (handle: number) => void; + clearTimeout?: (handle: TimeoutHandle) => void; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number; + setTimeout?: (callback: () => void, delayMs: number) => TimeoutHandle; +}; + +export type ScheduleVisualMountOptions = { + priority?: VisualMountPriority; + timeoutMs?: number; +}; + +export type VisualMountScheduler = { + schedule: ( + callback: () => void, + options?: ScheduleVisualMountOptions, + ) => () => void; +}; + +type VisualMountJob = { + callback: () => void; + cancelled: boolean; + priority: VisualMountPriority; + timeoutMs: number; +}; + +const NORMAL_MOUNT_TIMEOUT_MS = 80; +const PRIORITY_MOUNT_TIMEOUT_MS = 0; + +function getDefaultHost(): VisualMountSchedulerHost { + if (typeof window === 'undefined') { + return {}; + } + + return { + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + cancelIdleCallback: window.cancelIdleCallback?.bind(window), + clearTimeout: window.clearTimeout.bind(window), + requestAnimationFrame: window.requestAnimationFrame.bind(window), + requestIdleCallback: window.requestIdleCallback?.bind(window), + setTimeout: window.setTimeout.bind(window), + }; +} + +function getJobTimeoutMs({ + priority, + timeoutMs, +}: { + priority: VisualMountPriority; + timeoutMs?: number; +}) { + if (typeof timeoutMs === 'number') { + return timeoutMs; + } + + return priority === 'priority' + ? PRIORITY_MOUNT_TIMEOUT_MS + : NORMAL_MOUNT_TIMEOUT_MS; +} + +function insertJobByPriority(queue: VisualMountJob[], job: VisualMountJob) { + if (job.priority === 'normal') { + queue.push(job); + return; + } + + const firstNormalIndex = queue.findIndex( + (queuedJob) => queuedJob.priority === 'normal', + ); + + if (firstNormalIndex === -1) { + queue.push(job); + return; + } + + queue.splice(firstNormalIndex, 0, job); +} + +function removeJob(queue: VisualMountJob[], job: VisualMountJob) { + const index = queue.indexOf(job); + + if (index === -1) { + return; + } + + queue.splice(index, 1); +} + +export function createVisualMountScheduler( + host: VisualMountSchedulerHost = getDefaultHost(), +): VisualMountScheduler { + const { + cancelAnimationFrame, + cancelIdleCallback, + clearTimeout = globalThis.clearTimeout, + requestAnimationFrame, + requestIdleCallback, + setTimeout = globalThis.setTimeout, + } = host; + + const queue: VisualMountJob[] = []; + let cancelScheduledDrain: (() => void) | null = null; + + const cancelDrainIfIdle = () => { + if (queue.length > 0 || cancelScheduledDrain === null) { + return; + } + + cancelScheduledDrain(); + cancelScheduledDrain = null; + }; + + const scheduleDrain = () => { + if (cancelScheduledDrain !== null || queue.length === 0) { + return; + } + + const nextJob = queue[0]; + const runNextJob = () => { + cancelScheduledDrain = null; + + while (queue.length > 0) { + const job = queue.shift(); + + if (!job || job.cancelled) { + continue; + } + + job.callback(); + break; + } + + scheduleDrain(); + }; + + if (requestIdleCallback && cancelIdleCallback) { + const idleHandle = requestIdleCallback(runNextJob, { + timeout: nextJob.timeoutMs, + }); + cancelScheduledDrain = () => cancelIdleCallback(idleHandle); + + return; + } + + if (requestAnimationFrame && cancelAnimationFrame) { + let timeoutHandle: TimeoutHandle | null = null; + const animationFrameHandle = requestAnimationFrame(() => { + timeoutHandle = setTimeout(runNextJob, 0); + }); + + cancelScheduledDrain = () => { + cancelAnimationFrame(animationFrameHandle); + + if (timeoutHandle !== null) { + clearTimeout(timeoutHandle); + } + }; + + return; + } + + const timeoutHandle = setTimeout(runNextJob, nextJob.timeoutMs); + cancelScheduledDrain = () => clearTimeout(timeoutHandle); + }; + + return { + schedule: (callback, options = {}) => { + const priority = options.priority ?? 'normal'; + const job: VisualMountJob = { + callback, + cancelled: false, + priority, + timeoutMs: getJobTimeoutMs({ + priority, + timeoutMs: options.timeoutMs, + }), + }; + + insertJobByPriority(queue, job); + + if (priority === 'priority' && cancelScheduledDrain !== null) { + cancelScheduledDrain(); + cancelScheduledDrain = null; + } + + scheduleDrain(); + + return () => { + if (job.cancelled) { + return; + } + + job.cancelled = true; + removeJob(queue, job); + cancelDrainIfIdle(); + }; + }, + }; +} + +export const visualMountScheduler = createVisualMountScheduler(); + +export const scheduleVisualMount = visualMountScheduler.schedule; diff --git a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx b/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx index 7fbc181f72..832aa77ab3 100644 --- a/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx +++ b/packages/twenty-website-new/src/lib/visual-runtime/webgl-mount.tsx @@ -9,12 +9,20 @@ import { subscribeToActiveWebGlContextCount, tryReserveWebGlContextSlot, } from './active-webgl-context-budget'; +import { SITE_WEBGL_CONTEXT_LOST_EVENT } from './create-site-webgl-renderer'; +import { + scheduleVisualMount, + type VisualMountPriority, +} from './visual-mount-scheduler'; import { useWebGlPolicy } from './use-webgl-policy'; import { WebGlErrorBoundary } from './webgl-error-boundary'; const NON_PRIORITY_ROOT_MARGIN = '50% 0px 50% 0px'; +const PRIORITY_ROOT_MARGIN = '125% 0px 125% 0px'; +const EAGER_ROOT_MARGIN = '600% 0px 600% 0px'; const OUT_OF_VIEW_DISPOSE_MS = 4_000; +const PRIORITY_OUT_OF_VIEW_DISPOSE_MS = 1_500; const ObserverRoot = styled.div<{ detachFromLayout: boolean }>` height: 100%; @@ -40,10 +48,13 @@ const ObserverRoot = styled.div<{ detachFromLayout: boolean }>` `} `; +type WebGlMountLoading = 'lazy' | 'eager'; + type WebGlMountProps = { children: ReactNode; fallback?: ReactNode; detachFromLayout?: boolean; + loading?: WebGlMountLoading; priority?: boolean; }; @@ -51,6 +62,7 @@ export function WebGlMount({ children, fallback, detachFromLayout = false, + loading = 'lazy', priority = false, }: WebGlMountProps) { const policy = useWebGlPolicy(); @@ -58,18 +70,28 @@ export function WebGlMount({ const [isInViewport, setIsInViewport] = useState(priority); + const [isMountReady, setIsMountReady] = useState(false); + const [hasContextSlot, setHasContextSlot] = useState(false); - useEffect(() => { - if (priority) { - return; - } + const [contextEpoch, setContextEpoch] = useState(0); + useEffect(() => { const element = rootReference.current; if (!element) { return; } + const isEager = loading === 'eager'; + const effectiveDisposeDelayMs = + priority || isEager + ? PRIORITY_OUT_OF_VIEW_DISPOSE_MS + : OUT_OF_VIEW_DISPOSE_MS; + const effectiveRootMargin = isEager + ? EAGER_ROOT_MARGIN + : priority + ? PRIORITY_ROOT_MARGIN + : NON_PRIORITY_ROOT_MARGIN; let disposeTimer: ReturnType | null = null; const clearDisposeTimer = () => { if (disposeTimer !== null) { @@ -91,24 +113,65 @@ export function WebGlMount({ disposeTimer = setTimeout(() => { setIsInViewport(false); disposeTimer = null; - }, OUT_OF_VIEW_DISPOSE_MS); + }, effectiveDisposeDelayMs); + }, + { + root: null, + rootMargin: effectiveRootMargin, + threshold: 0, }, - { root: null, rootMargin: NON_PRIORITY_ROOT_MARGIN, threshold: 0 }, ); return () => { clearDisposeTimer(); stopObservingVisibility(); }; - }, [priority]); - - const wantsScene = policy.allowed && isInViewport; + }, [loading, priority]); useEffect(() => { + const element = rootReference.current; + if (!element) { + return; + } + + const handleContextLost = () => { + setHasContextSlot(false); + setIsMountReady(false); + setContextEpoch((epoch) => epoch + 1); + }; + + element.addEventListener(SITE_WEBGL_CONTEXT_LOST_EVENT, handleContextLost); + + return () => { + element.removeEventListener( + SITE_WEBGL_CONTEXT_LOST_EVENT, + handleContextLost, + ); + }; + }, []); + + const wantsScene = policy.allowed && isInViewport; + const wantsContextSlot = wantsScene && isMountReady; + const effectiveMountPriority: VisualMountPriority = + priority || loading === 'eager' ? 'priority' : 'normal'; + + useEffect(() => { + setIsMountReady(false); + if (!wantsScene) { return; } + return scheduleVisualMount(() => setIsMountReady(true), { + priority: effectiveMountPriority, + }); + }, [contextEpoch, effectiveMountPriority, wantsScene]); + + useEffect(() => { + if (!wantsContextSlot) { + return; + } + let release: (() => void) | null = null; let unsubscribe: (() => void) | null = null; @@ -145,14 +208,16 @@ export function WebGlMount({ } setHasContextSlot(false); }; - }, [wantsScene]); + }, [wantsContextSlot]); - const renderInner = wantsScene && hasContextSlot; + const renderInner = wantsContextSlot && hasContextSlot; return ( {renderInner ? ( - {children} + + {children} + ) : ( (fallback ?? null) )} diff --git a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx index 6d3124bb7d..45d1b39baa 100644 --- a/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Faq/visuals/Background.tsx @@ -1,12 +1,11 @@ 'use client'; import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { loadImportedGeometryFromUrl } from '@/lib/halftone'; import { theme } from '@/theme'; import { styled } from '@linaria/react'; import { useEffect, useLayoutEffect, useRef } from 'react'; import * as THREE from 'three'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { createAnimationFrameLoop } from '@/lib/animation'; import { observeElementSize } from '@/lib/dom/observe-element-size'; import { @@ -15,7 +14,6 @@ import { type VisualRenderLoop, type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; const VIRTUAL_RENDER_HEIGHT = 768; @@ -283,9 +281,6 @@ const INITIAL_TIME_ELAPSED = 249.21849999998116; const BASE_CAMERA_DISTANCE = 4; const MODEL_OFFSET_Y = 0.52; -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { const pmremGenerator = new THREE.PMREMGenerator(renderer); const environmentTexture = pmremGenerator.fromScene( @@ -305,121 +300,6 @@ function createRenderTarget(width: number, height: number) { }); } -function disposeMaterial(material: THREE.Material | THREE.Material[]) { - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - return; - } - - material.dispose(); -} - -function disposeObjectSubtree(root: THREE.Object3D) { - root.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - sceneObject.geometry?.dispose(); - disposeMaterial(sceneObject.material); - }); -} - -function mergeGeometries(geometries: THREE.BufferGeometry[]) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - function setPrimaryLightPosition( light: THREE.DirectionalLight, angleDegrees: number, @@ -433,120 +313,6 @@ function setPrimaryLightPosition( ); } -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - - return loadingManager; -} - -function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 1.6 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { - root.updateMatrixWorld(true); - - const geometries: THREE.BufferGeometry[] = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} - -async function loadFaqGeometry(modelUrl: string) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error(`Unable to load FAQ model from ${modelUrl}.`); - } - - const buffer = await response.arrayBuffer(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const gltfLoader = new GLTFLoader(createLoadingManager()); - gltfLoader.setDRACOLoader(dracoLoader); - - return await new Promise((resolve, reject) => { - gltfLoader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - 'FAQ model did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - disposeObjectSubtree(gltf.scene); - } - }, - reject, - ); - }); -} - function createFaqMaterial(environmentTexture: THREE.Texture) { return new THREE.MeshPhysicalMaterial({ color: 0xd4d0c8, @@ -827,7 +593,7 @@ export function FaqBackground() { applySize(); const stopObservingSize = observeElementSize(container, applySize); - loadFaqGeometry(GLB_URL) + loadImportedGeometryFromUrl('glb', GLB_URL, 'FAQ model') .then((geometry) => { if (cancelled) { geometry.dispose(); diff --git a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx index b5f56f8be4..dfcbe1aaef 100644 --- a/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx +++ b/packages/twenty-website-new/src/sections/Footer/visuals/Background.tsx @@ -1,11 +1,10 @@ 'use client'; import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'; +import { loadImportedGeometryFromUrl } from '@/lib/halftone'; import { styled } from '@linaria/react'; import { useEffect, useRef } from 'react'; import * as THREE from 'three'; -import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { observeElementSize } from '@/lib/dom/observe-element-size'; import { createVisualRenderLoop, @@ -13,7 +12,6 @@ import { type VisualRenderLoop, type VisualRenderLoopFrame, } from '@/lib/visual-runtime'; -import { DRACO_DECODER_PATH } from '@/lib/visual-runtime/draco-decoder-path'; const VIRTUAL_RENDER_HEIGHT = 768; @@ -287,9 +285,6 @@ const INITIAL_TARGET_ROTATION_Y = 0.4573343542024313; const INITIAL_TIME_ELAPSED = 287.372499999977; const BASE_CAMERA_DISTANCE = 4; -const EMPTY_TEXTURE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO8B7Q8AAAAASUVORK5CYII='; - function createEnvironmentTexture(renderer: THREE.WebGLRenderer) { const pmremGenerator = new THREE.PMREMGenerator(renderer); const environmentTexture = pmremGenerator.fromScene( @@ -341,121 +336,6 @@ function createRenderTarget(width: number, height: number) { }); } -function disposeMaterial(material: THREE.Material | THREE.Material[]) { - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - return; - } - - material.dispose(); -} - -function disposeObjectSubtree(root: THREE.Object3D) { - root.traverse((sceneObject) => { - if (!(sceneObject instanceof THREE.Mesh)) { - return; - } - - sceneObject.geometry?.dispose(); - disposeMaterial(sceneObject.material); - }); -} - -function mergeGeometries(geometries: THREE.BufferGeometry[]) { - if (geometries.length === 1) { - return geometries[0]; - } - - let totalVertices = 0; - let totalIndices = 0; - let hasUv = false; - - const geometryInfos = geometries.map((geometry) => { - const position = geometry.attributes.position; - const normal = geometry.attributes.normal; - const uv = geometry.attributes.uv ?? null; - const index = geometry.index; - const indexCount = index ? index.count : position.count; - - totalVertices += position.count; - totalIndices += indexCount; - hasUv = hasUv || uv !== null; - - return { - index, - indexCount, - normal, - position, - uv, - vertexCount: position.count, - }; - }); - - const positions = new Float32Array(totalVertices * 3); - const normals = new Float32Array(totalVertices * 3); - const uvs = hasUv ? new Float32Array(totalVertices * 2) : null; - const indices = new Uint32Array(totalIndices); - - let vertexOffset = 0; - let indexOffset = 0; - - for (const geometryInfo of geometryInfos) { - for ( - let vertexIndex = 0; - vertexIndex < geometryInfo.vertexCount; - vertexIndex += 1 - ) { - const positionOffset = (vertexOffset + vertexIndex) * 3; - positions[positionOffset] = geometryInfo.position.getX(vertexIndex); - positions[positionOffset + 1] = geometryInfo.position.getY(vertexIndex); - positions[positionOffset + 2] = geometryInfo.position.getZ(vertexIndex); - normals[positionOffset] = geometryInfo.normal.getX(vertexIndex); - normals[positionOffset + 1] = geometryInfo.normal.getY(vertexIndex); - normals[positionOffset + 2] = geometryInfo.normal.getZ(vertexIndex); - - if (uvs !== null) { - const uvOffset = (vertexOffset + vertexIndex) * 2; - uvs[uvOffset] = geometryInfo.uv?.getX(vertexIndex) ?? 0; - uvs[uvOffset + 1] = geometryInfo.uv?.getY(vertexIndex) ?? 0; - } - } - - if (geometryInfo.index) { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = - geometryInfo.index.getX(localIndex) + vertexOffset; - } - } else { - for ( - let localIndex = 0; - localIndex < geometryInfo.indexCount; - localIndex += 1 - ) { - indices[indexOffset + localIndex] = localIndex + vertexOffset; - } - } - - vertexOffset += geometryInfo.vertexCount; - indexOffset += geometryInfo.indexCount; - } - - const merged = new THREE.BufferGeometry(); - merged.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - merged.setAttribute('normal', new THREE.BufferAttribute(normals, 3)); - - if (uvs !== null) { - merged.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)); - } - - merged.setIndex(new THREE.BufferAttribute(indices, 1)); - - return merged; -} - function setPrimaryLightPosition( light: THREE.DirectionalLight, angleDegrees: number, @@ -469,120 +349,6 @@ function setPrimaryLightPosition( ); } -function createLoadingManager() { - const loadingManager = new THREE.LoadingManager(); - loadingManager.setURLModifier((url) => - /\.(png|jpe?g|webp|gif|bmp)$/i.test(url) ? EMPTY_TEXTURE_DATA_URL : url, - ); - - return loadingManager; -} - -function normalizeImportedGeometry(geometry: THREE.BufferGeometry) { - geometry.computeBoundingBox(); - - let boundingBox = geometry.boundingBox; - let center = new THREE.Vector3(); - let size = new THREE.Vector3(); - - boundingBox?.getCenter(center); - boundingBox?.getSize(size); - geometry.translate(-center.x, -center.y, -center.z); - - const dimensions = [size.x, size.y, size.z]; - const thinnestAxis = dimensions.indexOf(Math.min(...dimensions)); - - if (thinnestAxis === 0) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.PI / 2)); - } else if (thinnestAxis === 1) { - geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(Math.PI / 2)); - } - - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - const radius = geometry.boundingSphere?.radius || 1; - const scale = 2 / radius; - geometry.scale(scale, scale, scale); - - geometry.computeBoundingBox(); - boundingBox = geometry.boundingBox; - center = new THREE.Vector3(); - boundingBox?.getCenter(center); - geometry.translate(-center.x, -center.y, -center.z); - - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - - return geometry; -} - -function extractMergedGeometry(root: THREE.Object3D, emptyMessage: string) { - root.updateMatrixWorld(true); - - const geometries: THREE.BufferGeometry[] = []; - - root.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.geometry) { - return; - } - - const geometry = object.geometry.clone(); - - if (!geometry.attributes.normal) { - geometry.computeVertexNormals(); - } - - geometry.applyMatrix4(object.matrixWorld); - geometries.push(geometry); - }); - - if (geometries.length === 0) { - throw new Error(emptyMessage); - } - - return normalizeImportedGeometry(mergeGeometries(geometries)); -} - -async function loadFooterGeometry(modelUrl: string) { - const response = await fetch(modelUrl); - - if (!response.ok) { - throw new Error(`Unable to load footer model from ${modelUrl}.`); - } - - const buffer = await response.arrayBuffer(); - - const dracoLoader = new DRACOLoader(); - dracoLoader.setDecoderPath(DRACO_DECODER_PATH); - - const gltfLoader = new GLTFLoader(createLoadingManager()); - gltfLoader.setDRACOLoader(dracoLoader); - - return await new Promise((resolve, reject) => { - gltfLoader.parse( - buffer, - '', - (gltf) => { - try { - resolve( - extractMergedGeometry( - gltf.scene, - 'Footer model did not contain any mesh geometry.', - ), - ); - } catch (error) { - reject(error); - } finally { - disposeObjectSubtree(gltf.scene); - } - }, - reject, - ); - }); -} - function createFooterMaterial(environmentTexture: THREE.Texture) { return new THREE.MeshPhysicalMaterial({ color: 0xd4d0c8, @@ -892,7 +658,9 @@ export function FooterBackground() { window.addEventListener('pointermove', handleWindowPointerMove); window.addEventListener('blur', handleWindowBlur); - loadFooterGeometry(GLB_URL) + loadImportedGeometryFromUrl('glb', GLB_URL, 'Footer model', { + scaleTarget: 2, + }) .then((geometry) => { if (cancelled) { geometry.dispose(); diff --git a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx b/packages/twenty-website-new/src/sections/Helped/components/Card.tsx index 04bbab69c9..03a9a5a417 100644 --- a/packages/twenty-website-new/src/sections/Helped/components/Card.tsx +++ b/packages/twenty-website-new/src/sections/Helped/components/Card.tsx @@ -21,11 +21,6 @@ const CardRoot = styled.article` position: relative; row-gap: ${theme.spacing(2.5)}; width: 100%; - transition: - transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), - filter 0.6s ease; - transform-style: preserve-3d; - perspective: 1200px; `; const LogoRow = styled.div` @@ -50,14 +45,6 @@ const VisualShell = styled.div` width: 100%; `; -const VisualFallback = styled.div` - align-items: center; - display: flex; - height: 100%; - justify-content: center; - width: 100%; -`; - const CopyBlock = styled.div` display: grid; grid-template-columns: 1fr; @@ -81,8 +68,6 @@ const CtaRow = styled.div` `; const LOGO_FILL = theme.colors.secondary.text[100]; -const FALLBACK_LOGO_FILL = theme.colors.secondary.text[80]; -const FALLBACK_LOGO_WIDTH = 160; type CardProps = { card: HeadingCardType; @@ -93,15 +78,6 @@ export function Card({ card }: CardProps) { const Visual = HELPED_VISUALS[card.illustration]; const logoWidth = 104; - const visualFallback = IconComponent ? ( - - ) : null; - return (