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. */}
+
)}