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 { 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]}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user