diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 88a194f..bfce742 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,6 +1,7 @@
import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage';
+import FullSetupPage from './pages/FullSetupPage';
export default function App() {
return (
@@ -21,6 +22,7 @@ export default function App() {
} />
} />
+ } />
diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx
index db5b91a..bb7f034 100644
--- a/apps/web/src/components/viewers/CardViewer.tsx
+++ b/apps/web/src/components/viewers/CardViewer.tsx
@@ -11,6 +11,7 @@ import Scene from './Scene';
import { assetUrl } from './assetUrl';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture';
+import { getSharedGeometry } from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
@@ -49,25 +50,36 @@ const FALLBACK_URL =
* the card.
*/
export default function CardViewer({ object }: { object: TTSObject }) {
- const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
- resolveCardConfig(object);
-
return (
-
+
);
}
+/**
+ * The card mesh for an object, exported so the full-setup view can compose it
+ * into a shared scene.
+ */
+export function CardObjectMesh({ object }: { object: TTSObject }) {
+ const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
+ resolveCardConfig(object);
+
+ return (
+
+ );
+}
+
// Rendered inside the Canvas so `useTexture` can access the R3F store.
-function CardMesh({
+// Exported so the full-setup view can compose it into a shared scene.
+export function CardMesh({
faceUrl,
backUrl,
numWidth,
@@ -116,7 +128,9 @@ function CardMesh({
// Build the rounded-rect geometry from the card sprite's aspect ratio. The
// front and back faces each get their own material; the walls are a solid
- // white, matching TTS card tinting.
+ // white, matching TTS card tinting. Geometry is shared across cards of the
+ // same size so the full-setup view reuses it; the face/back materials stay
+ // per-card because each card clones its texture for sprite UVs.
const { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement
@@ -127,13 +141,12 @@ function CardMesh({
// Radius scales with the shorter edge so corners look proportional and
// stay circular (no scaling distortion).
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
- const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, {
- height: CARD_THICKNESS,
- });
+ const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
+ const key = `card:${width}:${height}:${CARD_THICKNESS}`;
return {
- frontGeo: toGeometry(frontGeo),
- backGeo: toGeometry(backGeo),
- wallsGeo: toGeometry(wallsGeo),
+ frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
+ backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
+ wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [faceUrl, face, backUrl, back, numWidth, numHeight]);
diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx
index e6b44e4..bd08f2e 100644
--- a/apps/web/src/components/viewers/CustomModelViewer.tsx
+++ b/apps/web/src/components/viewers/CustomModelViewer.tsx
@@ -15,24 +15,33 @@ import { FlexibleModelLoader } from './flexibleModelLoader';
* model's materials when present.
*/
export default function CustomModelViewer({ object }: { object: TTSObject }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The mesh content for a custom model, exported so the full-setup view can
+ * compose it into a shared scene. Renders the model from `CustomMesh.MeshURL`
+ * (or a neutral box placeholder when absent).
+ */
+export function CustomModelMesh({ object }: { object: TTSObject }) {
const meshUrl = object.CustomMesh?.MeshURL;
if (!meshUrl) {
return (
-
-
-
-
-
-
+
+
+
+
);
}
return (
-
-
-
-
-
+
+
+
);
}
diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx
index 04daf95..606bb5e 100644
--- a/apps/web/src/components/viewers/TileViewer.tsx
+++ b/apps/web/src/components/viewers/TileViewer.tsx
@@ -14,6 +14,7 @@ import {
import Scene from './Scene';
import { assetUrl } from './assetUrl';
import { flipTexture } from './flipTexture';
+import { getSharedGeometry, getSharedMaterial } from './sharedResources';
/** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = {
@@ -34,20 +35,29 @@ const TILE_SIZE = 2;
* source image instead of being forced square.
*/
export default function TileViewer({ object }: { object: TTSObject }) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The tile mesh for an object, exported so the full-setup view can compose it
+ * into a shared scene.
+ */
+export function TileObjectMesh({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
- return (
-
-
-
- );
+ return ;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
-function TileMesh({
+// Exported so the full-setup view can compose it into a shared scene.
+export function TileMesh({
url,
thickness,
type,
@@ -62,16 +72,18 @@ function TileMesh({
// Build the extruded geometry from the tile shape. When `stretch` is false
// and a texture is available, scale the shape to the image's aspect ratio so
- // the tile matches the source proportions instead of being square.
+ // the tile matches the source proportions instead of being square. Shared
+ // across tiles with the same shape so the full-setup view reuses geometry.
const { front, back, walls } = useMemo(() => {
const img = texture?.image as HTMLImageElement;
const aspect = stretch ? img.width / img.height : 1;
const shape = tileShape(type, aspect);
const parts = extrudeShapeParts(shape, { height: thickness });
+ const key = `tile:${type}:${aspect}:${thickness}`;
return {
- front: toGeometry(parts.front),
- back: toGeometry(parts.back),
- walls: toGeometry(parts.walls),
+ front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
+ back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
+ walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [type, thickness, stretch, texture]);
@@ -79,28 +91,32 @@ function TileMesh({
// left/right to avoid a mirrored texture when viewed from behind.
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]);
+ // Shared materials: the front/back carry the tile texture (or a neutral
+ // color when absent); the walls are a solid white, matching TTS tinting.
+ const faceKey = `tile-face:${url ?? 'none'}`;
+ const faceMat = getSharedMaterial(faceKey, {
+ color: texture ? '#ffffff' : '#52525b',
+ map: texture ?? undefined,
+ roughness: 0.8,
+ });
+ const backMat = getSharedMaterial(faceKey + ':back', {
+ color: texture ? '#ffffff' : '#52525b',
+ map: backMap ?? undefined,
+ roughness: 0.8,
+ });
+ const wallMat = getSharedMaterial('tile-wall', {
+ color: '#ffffff',
+ roughness: 0.8,
+ });
+
return (
{/* Front face carries the tile texture. */}
-
-
-
+
{/* Back face, flipped so it isn't mirrored. */}
-
-
-
+
{/* Sides are a solid white, matching TTS tile tinting. */}
-
-
-
+
);
}
diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx
index 6715c4c..deed3ca 100644
--- a/apps/web/src/components/viewers/TokenViewer.tsx
+++ b/apps/web/src/components/viewers/TokenViewer.tsx
@@ -11,6 +11,7 @@ import {
import { traceImage } from '../../api';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
+import { getSharedGeometry, getSharedMaterial } from './sharedResources';
const TOKEN_SIZE = 1.8;
@@ -24,18 +25,27 @@ const TRACE_INSET = 2;
* the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
*/
export default function TokenViewer({ object }: { object: TTSObject }) {
- const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
- const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
-
return (
-
+
);
}
+/**
+ * The token mesh for an object, exported so the full-setup view can compose it
+ * into a shared scene.
+ */
+export function TokenObjectMesh({ object }: { object: TTSObject }) {
+ const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
+ const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
+
+ return ;
+}
+
// Rendered inside the Canvas so `useTexture` can access the R3F store.
-function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
+// Exported so the full-setup view can compose it into a shared scene.
+export 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. Suspends until the trace
@@ -50,28 +60,30 @@ function TokenMesh({ url, thickness }: { url?: string; thickness: number }) {
const shape = trace ? toMeshShape(trace) : circleShape();
const uvBounds = trace ? toUvBounds(trace) : undefined;
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
+ // Shared across tokens with the same source image (the trace is cached per
+ // URL, so the silhouette is deterministic) so the full-setup view reuses
+ // geometry.
+ const key = `token:${url ?? 'none'}:${thickness}`;
return {
- front: toGeometry(parts.front),
- back: toGeometry(parts.back),
- walls: toGeometry(parts.walls),
+ front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
+ back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
+ walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
- }, [trace, thickness]);
+ }, [trace, thickness, url]);
// A token is solid: front, back, and walls all carry the texture (projected
// UV), unlike tiles/cards where only the faces are textured.
- const material = (
-
- );
+ const material = getSharedMaterial(`token:${url ?? 'none'}`, {
+ color: texture ? '#ffffff' : '#52525b',
+ map: texture ?? undefined,
+ roughness: 0.8,
+ });
return (
- {material}
- {material}
- {material}
+
+
+
);
}
@@ -82,30 +94,39 @@ interface TraceData {
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>();
+// Cache traces by URL so Suspense doesn't re-issue the request on every render
+// while the boundary is held open. A URL maps to either a pending promise (while
+// loading) or the resolved value (once loaded).
+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.
+ * when there's no URL / the trace fails). Throws the cached promise only while
+ * it's pending; once resolved, the value is returned directly so the retry
+ * render completes instead of suspending forever.
*/
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;
+ const cached = traceCache.get(url);
+ if (cached === undefined) {
+ const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
+ const value: TraceData | null = result.shape
+ ? {
+ shape: result.shape,
+ width: result.width,
+ height: result.height,
+ }
+ : null;
+ // Replace the pending promise with the resolved value so later renders
+ // return it instead of re-suspending on a settled promise.
+ traceCache.set(url, value);
+ return value;
});
traceCache.set(url, promise);
+ throw promise;
}
- throw promise;
+ if (cached instanceof Promise) throw cached;
+ return cached;
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
diff --git a/apps/web/src/components/viewers/sharedResources.ts b/apps/web/src/components/viewers/sharedResources.ts
new file mode 100644
index 0000000..434d8f6
--- /dev/null
+++ b/apps/web/src/components/viewers/sharedResources.ts
@@ -0,0 +1,42 @@
+import * as THREE from 'three';
+
+/**
+ * Module-level caches so the full-setup view can share geometry and materials
+ * across many objects instead of rebuilding them per object. Keyed by a
+ * canonical string describing the resource, so identical objects reuse one
+ * instance. drei already caches textures globally by URL, so sharing the
+ * material on top avoids per-object material allocation for tiles/tokens with
+ * the same image.
+ *
+ * These caches live for the session (like drei's global texture cache) and are
+ * not disposed on unmount; see `docs/full-setup-view.md`.
+ */
+
+const geometryCache = new Map();
+const materialCache = new Map();
+
+/** Get or create a geometry for `key`. */
+export function getSharedGeometry(
+ key: string,
+ build: () => THREE.BufferGeometry,
+): THREE.BufferGeometry {
+ let geo = geometryCache.get(key);
+ if (!geo) {
+ geo = build();
+ geometryCache.set(key, geo);
+ }
+ return geo;
+}
+
+/** Get or create a standard material for `key`. */
+export function getSharedMaterial(
+ key: string,
+ params: THREE.MeshStandardMaterialParameters,
+): THREE.MeshStandardMaterial {
+ let mat = materialCache.get(key);
+ if (!mat) {
+ mat = new THREE.MeshStandardMaterial(params);
+ materialCache.set(key, mat);
+ }
+ return mat;
+}
\ No newline at end of file
diff --git a/apps/web/src/pages/FullSetupPage.tsx b/apps/web/src/pages/FullSetupPage.tsx
new file mode 100644
index 0000000..ec344b3
--- /dev/null
+++ b/apps/web/src/pages/FullSetupPage.tsx
@@ -0,0 +1,145 @@
+import { Suspense, useEffect, useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+import { flattenObjects } from '@tts/extract';
+import type { TTSObject } from '@tts/shared';
+import { useModStore } from '../stores/modStore';
+import { useSearchStore } from '../stores/searchStore';
+import Scene from '../components/viewers/Scene';
+import { TileObjectMesh } from '../components/viewers/TileViewer';
+import { TokenObjectMesh } from '../components/viewers/TokenViewer';
+import { CardObjectMesh } from '../components/viewers/CardViewer';
+import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
+
+/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */
+const RENDERABLE = new Set([
+ 'Tile',
+ 'Custom_Tile',
+ 'Custom_Token',
+ 'Card',
+ 'CardCustom',
+ 'Deck',
+ 'DeckCustom',
+ 'Custom_Deck',
+ 'Custom_Model',
+ 'Custom_Model_Bag',
+ 'Custom_Model_Infinite_Bag',
+]);
+
+/** Grid cell spacing (world units) and columns per row. */
+const CELL = 2.5;
+const COLS = 8;
+
+/**
+ * A "full setup" view: renders every loadable object in a save into a single
+ * shared scene, laid out on a virtual table. Objects are grouped by type so
+ * like objects sit together. Geometry and materials are shared across objects
+ * (see `components/viewers/sharedResources.ts`).
+ */
+export default function FullSetupPage() {
+ const { id } = useParams<{ id: string }>();
+ const { mod, loading, error, load } = useModStore();
+ const item = useSearchStore((s) => s.items.find((i) => i.id === id));
+
+ useEffect(() => {
+ if (id) load(id, item?.fileUrl);
+ }, [id, item?.fileUrl, load]);
+
+ const objects = useMemo(
+ () => (mod ? flattenObjects(mod) : []),
+ [mod],
+ );
+
+ const renderable = useMemo(
+ () => objects.filter((o) => RENDERABLE.has(o.Name)),
+ [objects],
+ );
+ const skipped = objects.length - renderable.length;
+
+ const placed = useMemo(() => layoutObjects(renderable), [renderable]);
+
+ if (loading) return Loading mod…
;
+ if (error) return {error}
;
+ if (!mod) return No mod loaded.
;
+
+ return (
+
+
+
Full setup
+
+ {objects.length} objects · {renderable.length} rendered
+ {skipped > 0 ? ` · ${skipped} skipped` : ''}
+
+
+
+
+
+ {/* Key by index, not GUID: cards in a deck share the deck's GUID. */}
+ {placed.map(({ object, position }, index) => (
+
+
+
+ ))}
+
+
+
+ );
+}
+
+/** Dispatch a renderable object to the mesh component for its class. */
+function RenderObject({ object }: { object: TTSObject }) {
+ switch (object.Name) {
+ case 'Tile':
+ case 'Custom_Tile':
+ return ;
+ case 'Custom_Token':
+ return ;
+ case 'Card':
+ case 'CardCustom':
+ case 'Deck':
+ case 'DeckCustom':
+ case 'Custom_Deck':
+ return ;
+ case 'Custom_Model':
+ case 'Custom_Model_Bag':
+ case 'Custom_Model_Infinite_Bag':
+ return ;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Assign each renderable object a grid position, grouping by class so like
+ * objects sit together. Returns the object plus its `[x, y, z]` position.
+ */
+function layoutObjects(
+ objects: TTSObject[],
+): { object: TTSObject; position: [number, number, number] }[] {
+ // Group by type so like objects sit together.
+ const groups = new Map();
+ for (const o of objects) {
+ const arr = groups.get(o.Name) ?? [];
+ arr.push(o);
+ groups.set(o.Name, arr);
+ }
+
+ const result: { object: TTSObject; position: [number, number, number] }[] = [];
+ let row = 0;
+ let col = 0;
+ for (const objs of groups.values()) {
+ for (const o of objs) {
+ result.push({ object: o, position: [col * CELL, 0, row * CELL] });
+ col++;
+ if (col >= COLS) {
+ col = 0;
+ row++;
+ }
+ }
+ // Start a fresh row after each group.
+ if (col !== 0) {
+ col = 0;
+ row++;
+ }
+ }
+ return result;
+}
\ No newline at end of file
diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx
index 2f62951..b83b4c1 100644
--- a/apps/web/src/pages/ModPage.tsx
+++ b/apps/web/src/pages/ModPage.tsx
@@ -1,6 +1,6 @@
import { Suspense, useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
-import { useParams } from 'react-router-dom';
+import { Link, useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
@@ -47,12 +47,20 @@ export default function ModPage() {
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
asset refs
-
- Download save file
-
+
diff --git a/docs/full-setup-view.md b/docs/full-setup-view.md
new file mode 100644
index 0000000..28bd1fb
--- /dev/null
+++ b/docs/full-setup-view.md
@@ -0,0 +1,115 @@
+# Full Setup View — Plan
+
+> **Scope:** A new page that renders every loadable object in a save into a
+> single shared 3D scene, sharing materials and geometry where possible. This
+> complements the existing per-object inspection on the mod page.
+
+## Motivation
+
+The mod page (`apps/web/src/pages/ModPage.tsx`) inspects one object at a time
+inside a shared `Scene`. A "full setup" view renders the whole save at once —
+every tile, token, card, and custom model laid out on a virtual table — so a
+user can see the entire scene at a glance.
+
+## Current architecture (relevant pieces)
+
+- Each viewer owns its own `` wrapper (`components/viewers/Scene.tsx`),
+ which provides the camera, lights, orbit controls, contact shadows, and
+ post-processing. The mesh content lives inside each viewer:
+ - `TileViewer` → `` (extruded tile, textured top face)
+ - `TokenViewer` → `` (alpha-traced extruded token)
+ - `CardViewer` → `` (rounded-rect card, sprite UVs)
+ - `CustomModelViewer` → `` (GLTF/OBJ/FBX via `FlexibleModelLoader`)
+- `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate
+ every object in a save.
+- `@tts/mesh` provides the shape + extrusion helpers used by the viewers.
+- `TTSObject` carries no position data, so exact table placement is not
+ recoverable — the full setup lays objects out itself.
+
+## Plan
+
+### 1. Refactor viewers to expose their mesh content (no `Scene`)
+
+Each viewer currently wraps its mesh in ``. To compose everything into
+one scene, extract and export the inner mesh components, keeping the existing
+viewers as thin wrappers:
+
+- `TileViewer` → export `TileObjectMesh` (accepts a `TTSObject`)
+- `TokenViewer` → export `TokenObjectMesh`
+- `CardViewer` → export `CardObjectMesh`
+- `CustomModelViewer` → export `CustomModelMesh` (the `` + fallback box)
+
+Each viewer now renders ``, so the
+object-facing wrapper is the single source of truth for both the per-object
+view and the full-setup view.
+
+### 2. New page `FullSetupPage.tsx` at `/mod/:id/setup`
+
+- Reuse `useModStore` (same load path as `ModPage`).
+- `flattenObjects(mod)` → filter to renderable classes (those registered in
+ `components/viewers/register.ts`: `Tile`, `Custom_Tile`, `Custom_Token`,
+ `Card`, `CardCustom`, `Deck`, `DeckCustom`, `Custom_Deck`, `Custom_Model*`).
+- Render **one** `` containing all renderable objects as
+ `` entries, dispatching to the right `*Mesh` by `Name`.
+- Show a summary header (total objects, rendered count, skipped count) and
+ loading/error states matching `ModPage`.
+
+### 3. Layout
+
+- Arrange objects in a grid on the "table" (fixed spacing, wrapping by row),
+ grouping by type so cards sit together, tiles together, etc.
+- `Scene`'s `Bounds fit` already auto-fits the camera to the full layout, so no
+ camera work is needed.
+- Non-renderable objects (bags, dice, boards without assets) are skipped and
+ counted, not dropped silently.
+
+### 4. Share materials & geometry
+
+- **Geometry cache (module-level `Map`):** key by a
+ canonical string — `card:{w}:{h}:{t}`, `tile:{type}:{aspect}:{t}`,
+ `token:{url}:{t}`, `model:{meshUrl}`. All cards of the same size, or tiles
+ of the same type/size, reuse one geometry instead of rebuilding per object.
+ Token geometry is keyed by the source image URL (the trace is cached per
+ URL, so the silhouette is deterministic).
+- **Material cache (module-level `Map`):** key by
+ `textureUrl + color + roughness`. drei already caches textures by URL
+ globally, so sharing the material on top avoids per-object material
+ allocation for tiles/tokens with the same image.
+- **Cards are the exception:** each card clones its texture for sprite UVs, so
+ its face material cannot be shared — but its geometry still can (same card
+ size).
+- Dispose shared resources on page unmount, or accept a module-level cache for
+ the session (see Open decisions).
+
+### 5. Routing & navigation
+
+- Add `} />` in
+ `apps/web/src/App.tsx`.
+- Add a "Full setup" link/button on `ModPage` next to the download button.
+
+### 6. Edge cases
+
+- Objects with no asset (no `ImageURL`/`MeshURL`) render as neutral-colored
+ placeholders (matching the current viewers' fallback behavior).
+- Token tracing suspends per URL (already cached in `TokenViewer`); the shared
+ `Scene` Suspense boundary handles it.
+- Large saves: the grid + shared geometry keeps it performant, but cap or warn
+ on very large object counts if needed.
+
+## Files touched
+
+- `apps/web/src/components/viewers/{Tile,Token,Card,CustomModel}Viewer.tsx` —
+ export object-facing mesh wrappers
+- `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material
+ caches
+- `apps/web/src/pages/FullSetupPage.tsx` — new
+- `apps/web/src/App.tsx` — route
+- `apps/web/src/pages/ModPage.tsx` — nav link
+- Possibly a small `geometryCache`/`materialCache` helper under
+ `components/viewers/`
+
+## Open decisions (defaults in bold)
+
+- **Layout style** — **grouped grid** vs a fan/stack for cards.
+- **Geometry/material cache lifetime** — **session-level module cache**
+ (simplest, consistent with drei's global texture cache) vs dispose-on-unmount.
\ No newline at end of file