From b6bd4612e0cf00650fb3844ddd0518bb28063f8e Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 17:54:43 +0800 Subject: [PATCH] feat: implement suspense-based alpha tracing for token meshes Refactor `TokenMesh` to use a custom `useTrace` hook that suspends during the alpha channel tracing process. This allows the `Bounds` component in `Scene` to correctly fit the camera to the token's geometry once it has been fully loaded and traced. --- apps/web/src/components/viewers/Scene.tsx | 10 ++- .../src/components/viewers/TokenViewer.tsx | 67 +++++++++++-------- apps/web/src/pages/ModPage.tsx | 4 +- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/viewers/Scene.tsx b/apps/web/src/components/viewers/Scene.tsx index 7be705e..021f230 100644 --- a/apps/web/src/components/viewers/Scene.tsx +++ b/apps/web/src/components/viewers/Scene.tsx @@ -1,6 +1,6 @@ import { Suspense, type ReactNode } from 'react'; import { Canvas } from '@react-three/fiber'; -import { ContactShadows, OrbitControls } from '@react-three/drei'; +import { Bounds, ContactShadows, OrbitControls } from '@react-three/drei'; import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; /** @@ -8,6 +8,10 @@ import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing'; * lighting, orbit controls, a soft contact shadow, and subtle post-processing * (bloom + vignette). Children are wrapped in a Suspense boundary so loading * assets (textures, models) can suspend without blanking the page. + * + * The camera is fitted to the bounds of the content on mount. `Bounds` sits + * inside the Suspense boundary, so it only mounts once the (suspending) content + * has loaded and its geometry is present. */ export default function Scene({ children }: { children: ReactNode }) { return ( @@ -22,7 +26,9 @@ export default function Scene({ children }: { children: ReactNode }) { - {children} + + {children} + (null); - useEffect(() => { - let cancelled = false; - setTrace(null); - if (!url) return; - traceImage(url, 'alpha', -TRACE_INSET) - .then((result) => { - if (!cancelled && result.shape) { - setTrace({ - shape: result.shape, - width: result.width, - height: result.height, - }); - } - }) - .catch(() => { - if (!cancelled) setTrace(null); - }); - return () => { - cancelled = true; - }; - }, [url]); + // Trace the image's alpha channel into a shape. Suspends until the trace + // resolves so the surrounding Suspense boundary (and `Bounds`) only mounts + // once the token geometry is present. Falls back to a circle when there's no + // image or the trace fails. + const trace = useTrace(url); const { front, back, walls } = useMemo(() => { // The traced shape and its UV framing share the same transform, so the @@ -99,6 +76,38 @@ function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { ); } +interface TraceData { + shape: { outline: number[][]; holes?: number[][][] }; + width: number; + height: number; +} + +// Cache trace promises by URL so Suspense doesn't re-issue the request on every +// render while the boundary is held open. +const traceCache = new Map>(); + +/** + * Suspend on the alpha trace for `url`, resolving to the traced shape (or null + * when there's no URL / the trace fails). Throwing a cached promise here lets + * the surrounding Suspense boundary hold rendering until the trace completes. + */ +function useTrace(url: string | undefined): TraceData | null { + if (!url) return null; + let promise = traceCache.get(url); + if (!promise) { + promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => { + if (!result.shape) return null; + return { + shape: result.shape, + width: result.width, + height: result.height, + } as TraceData; + }); + traceCache.set(url, promise); + } + throw promise; +} + /** Convert raw extruded arrays into a three.js `BufferGeometry`. */ function toGeometry(extruded: ExtrudedGeometry) { const { positions, normals, uvs, indices } = extruded; diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx index 9024d1a..2f62951 100644 --- a/apps/web/src/pages/ModPage.tsx +++ b/apps/web/src/pages/ModPage.tsx @@ -90,7 +90,9 @@ export default function ModPage() { } > - + {/* Key by selection path so the Canvas remounts and the + camera refits to the newly selected object. */} + )}