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:
@@ -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 }) {
|
||||
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
|
||||
<pointLight position={[0, 3, 0]} intensity={0.3} />
|
||||
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<Bounds>{children}</Bounds>
|
||||
</Suspense>
|
||||
|
||||
<ContactShadows
|
||||
position={[0, -0.5, 0]}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTexture } from '@react-three/drei';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import type { TTSObject } from '@tts/shared';
|
||||
import {
|
||||
@@ -38,34 +38,11 @@ export default function TokenViewer({ object }: { object: TTSObject }) {
|
||||
function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
|
||||
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
|
||||
// loading or when there's no image / the trace fails.
|
||||
const [trace, setTrace] = useState<{
|
||||
shape: { outline: number[][]; holes?: number[][][] };
|
||||
width: number;
|
||||
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]);
|
||||
// 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<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`. */
|
||||
function toGeometry(extruded: ExtrudedGeometry) {
|
||||
const { positions, normals, uvs, indices } = extruded;
|
||||
|
||||
@@ -90,7 +90,9 @@ export default function ModPage() {
|
||||
</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>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user