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.
This commit is contained in:
2026-08-08 17:54:43 +08:00
parent 55e0351c1d
commit b6bd4612e0
3 changed files with 49 additions and 32 deletions
+8 -2
View File
@@ -1,6 +1,6 @@
import { Suspense, type ReactNode } from 'react'; import { Suspense, type ReactNode } from 'react';
import { Canvas } from '@react-three/fiber'; 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'; 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 * lighting, orbit controls, a soft contact shadow, and subtle post-processing
* (bloom + vignette). Children are wrapped in a Suspense boundary so loading * (bloom + vignette). Children are wrapped in a Suspense boundary so loading
* assets (textures, models) can suspend without blanking the page. * 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 }) { export default function Scene({ children }: { children: ReactNode }) {
return ( return (
@@ -22,7 +26,9 @@ export default function Scene({ children }: { children: ReactNode }) {
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" /> <directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
<pointLight position={[0, 3, 0]} intensity={0.3} /> <pointLight position={[0, 3, 0]} intensity={0.3} />
<Suspense fallback={null}>{children}</Suspense> <Suspense fallback={null}>
<Bounds>{children}</Bounds>
</Suspense>
<ContactShadows <ContactShadows
position={[0, -0.5, 0]} position={[0, -0.5, 0]}
+38 -29
View File
@@ -1,5 +1,5 @@
import { useTexture } from '@react-three/drei'; import { useTexture } from '@react-three/drei';
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import { import {
@@ -38,34 +38,11 @@ export default function TokenViewer({ object }: { object: TTSObject }) {
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null; const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Trace the image's alpha channel into a shape. Falls back to a circle while // Trace the image's alpha channel into a shape. Suspends until the trace
// loading or when there's no image / the trace fails. // resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
const [trace, setTrace] = useState<{ // once the token geometry is present. Falls back to a circle when there's no
shape: { outline: number[][]; holes?: number[][][] }; // image or the trace fails.
width: number; const trace = useTrace(url);
height: number;
} | null>(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]);
const { front, back, walls } = useMemo(() => { const { front, back, walls } = useMemo(() => {
// The traced shape and its UV framing share the same transform, so the // 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<string, Promise<TraceData | null>>();
/**
* 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`. */ /** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) { function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded; const { positions, normals, uvs, indices } = extruded;
+3 -1
View File
@@ -90,7 +90,9 @@ export default function ModPage() {
</div> </div>
} }
> >
<Viewer object={selected.object} /> {/* Key by selection path so the Canvas remounts and the
camera refits to the newly selected object. */}
<Viewer key={selectedPath} object={selected.object} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}