Compare commits

..
2 Commits
Author SHA1 Message Date
hypercross c04d99fe38 docs: document object viewers and asset proxy 2026-08-08 18:28:56 +08:00
hypercross e88dd03fac feat(web): add full setup view rendering the whole save
Add a /mod/:id/setup page that lays out every renderable object in a single shared scene. Export object-facing mesh wrappers from the viewers and share geometry and materials across objects via module-level caches.
2026-08-08 18:28:03 +08:00
10 changed files with 492 additions and 101 deletions
+23 -3
View File
@@ -7,10 +7,11 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
| Package | Role | Runtime | | Package | Role | Runtime |
| ------------------- | ------------------------------------------------------ | ---------- | | ------------------- | ------------------------------------------------------ | ---------- |
| `apps/web` | React frontend: search + mod pages | Browser | | `apps/web` | React frontend: search + mod pages + 3D object viewers | Browser |
| `apps/proxy` | Hono HTTP server: Workshop search + save fetch | Node | | `apps/proxy` | Hono HTTP server: Workshop search, save fetch, tracing | Node |
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node | | `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
| `packages/extract` | Analyze a `TTSMod`: objects, asset refs, downloads | Isomorphic | | `packages/extract` | Analyze a `TTSMod`: objects, asset refs, downloads | Isomorphic |
| `packages/mesh` | 2D shapes + extrusion into 3D mesh geometry | Isomorphic |
| `packages/shared` | Shared types + zod schemas | Isomorphic | | `packages/shared` | Shared types + zod schemas | Isomorphic |
See [`docs/architecture.md`](docs/architecture.md) for the architecture and See [`docs/architecture.md`](docs/architecture.md) for the architecture and
@@ -28,7 +29,7 @@ Get a Steam Web API key at https://steamcommunity.com/dev/apikey (free).
To run the frontend alongside the proxy, open a second terminal and run To run the frontend alongside the proxy, open a second terminal and run
`pnpm --filter @tts/web dev` (serves at http://localhost:5173 and proxies `pnpm --filter @tts/web dev` (serves at http://localhost:5173 and proxies
`/search`, `/items`, and `/health` to the backend). `/search`, `/items`, `/health`, `/asset`, and `/trace` to the backend).
## API ## API
@@ -38,6 +39,7 @@ To run the frontend alongside the proxy, open a second terminal and run
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) | | GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) | | GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
| GET | `/items/:id/file` | Raw save bytes, filename from header | | GET | `/items/:id/file` | Raw save bytes, filename from header |
| GET | `/asset?url=` | CORS-safe proxy for external assets (textures, models) |
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels | | GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
## Commands ## Commands
@@ -51,6 +53,24 @@ pnpm test # run the unit tests (vitest)
pnpm lint # lint all packages pnpm lint # lint all packages
``` ```
## Object viewers
The mod page renders each selected object in 3D. Tiles, tokens, cards, and
custom models each have a viewer built on `@react-three/fiber`, `@react-three/drei`,
and `@react-three/postprocessing`, registered per object class and lazy-loaded
so the three.js stack is code-split out of the main bundle.
- **Tiles / tokens** — extruded from a 2D shape; tokens trace the image's alpha
channel via `/trace` to match the artwork's silhouette.
- **Cards** — a thin rounded rect. Deck images are sheets divided into a
`NumWidth` x `NumHeight` grid; the face/back sprite is selected by `CardID`
from the containing deck's config.
- **Custom models** — GLTF/OBJ/FBX loaded from `CustomMesh.MeshURL`.
The camera fits the object's bounds on load, and back faces are flipped so
they aren't mirrored. See [`docs/decisions.md`](docs/decisions.md) for the
rationale behind these choices.
## How search works ## How search works
Steam has no official search API. The proxy fetches the Workshop browse page Steam has no official search API. The proxy fetches the Workshop browse page
+2
View File
@@ -1,6 +1,7 @@
import { Link, Route, Routes } from 'react-router-dom'; import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage'; import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage'; import ModPage from './pages/ModPage';
import FullSetupPage from './pages/FullSetupPage';
export default function App() { export default function App() {
return ( return (
@@ -21,6 +22,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<SearchPage />} /> <Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} /> <Route path="/mod/:id" element={<ModPage />} />
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
</Routes> </Routes>
</main> </main>
</div> </div>
+23 -10
View File
@@ -11,6 +11,7 @@ import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from './assetUrl';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture'; import { flipTexture } from './flipTexture';
import { getSharedGeometry } from './sharedResources';
/** Longer card dimension, in world units. */ /** Longer card dimension, in world units. */
const CARD_LENGTH = 2; const CARD_LENGTH = 2;
@@ -49,11 +50,22 @@ const FALLBACK_URL =
* the card. * the card.
*/ */
export default function CardViewer({ object }: { object: TTSObject }) { export default function CardViewer({ object }: { object: TTSObject }) {
return (
<Scene>
<CardObjectMesh object={object} />
</Scene>
);
}
/**
* 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 } = const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } =
resolveCardConfig(object); resolveCardConfig(object);
return ( return (
<Scene>
<CardMesh <CardMesh
faceUrl={faceUrl} faceUrl={faceUrl}
backUrl={backUrl} backUrl={backUrl}
@@ -62,12 +74,12 @@ export default function CardViewer({ object }: { object: TTSObject }) {
uniqueBack={uniqueBack} uniqueBack={uniqueBack}
cardId={cardId} cardId={cardId}
/> />
</Scene>
); );
} }
// Rendered inside the Canvas so `useTexture` can access the R3F store. // 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, faceUrl,
backUrl, backUrl,
numWidth, numWidth,
@@ -116,7 +128,9 @@ function CardMesh({
// Build the rounded-rect geometry from the card sprite's aspect ratio. The // 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 // 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 { frontGeo, backGeo, wallsGeo } = useMemo(() => {
const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as
| HTMLImageElement | HTMLImageElement
@@ -127,13 +141,12 @@ function CardMesh({
// Radius scales with the shorter edge so corners look proportional and // Radius scales with the shorter edge so corners look proportional and
// stay circular (no scaling distortion). // stay circular (no scaling distortion).
const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height)); const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height));
const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, { const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
height: CARD_THICKNESS, const key = `card:${width}:${height}:${CARD_THICKNESS}`;
});
return { return {
frontGeo: toGeometry(frontGeo), frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
backGeo: toGeometry(backGeo), backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
wallsGeo: toGeometry(wallsGeo), wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
}; };
}, [faceUrl, face, backUrl, back, numWidth, numHeight]); }, [faceUrl, face, backUrl, back, numWidth, numHeight]);
@@ -15,24 +15,33 @@ import { FlexibleModelLoader } from './flexibleModelLoader';
* model's materials when present. * model's materials when present.
*/ */
export default function CustomModelViewer({ object }: { object: TTSObject }) { export default function CustomModelViewer({ object }: { object: TTSObject }) {
const meshUrl = object.CustomMesh?.MeshURL;
if (!meshUrl) {
return ( return (
<Scene> <Scene>
<mesh> <CustomModelMesh object={object} />
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#52525b" />
</mesh>
</Scene> </Scene>
); );
} }
/**
* 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 (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#52525b" />
</mesh>
);
}
return ( return (
<Scene>
<Suspense fallback={null}> <Suspense fallback={null}>
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} /> <Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
</Suspense> </Suspense>
</Scene>
); );
} }
+43 -27
View File
@@ -14,6 +14,7 @@ import {
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from './assetUrl';
import { flipTexture } from './flipTexture'; import { flipTexture } from './flipTexture';
import { getSharedGeometry, getSharedMaterial } from './sharedResources';
/** `CustomTile.Type` enum from Tabletop Simulator. */ /** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = { const TileType = {
@@ -34,20 +35,29 @@ const TILE_SIZE = 2;
* source image instead of being forced square. * source image instead of being forced square.
*/ */
export default function TileViewer({ object }: { object: TTSObject }) { export default function TileViewer({ object }: { object: TTSObject }) {
return (
<Scene>
<TileObjectMesh object={object} />
</Scene>
);
}
/**
* 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 url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2; const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box; const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true; const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
return ( return <TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />;
<Scene>
<TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
</Scene>
);
} }
// Rendered inside the Canvas so `useTexture` can access the R3F store. // 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, url,
thickness, thickness,
type, type,
@@ -62,16 +72,18 @@ function TileMesh({
// Build the extruded geometry from the tile shape. When `stretch` is false // 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 // 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 { front, back, walls } = useMemo(() => {
const img = texture?.image as HTMLImageElement; const img = texture?.image as HTMLImageElement;
const aspect = stretch ? img.width / img.height : 1; const aspect = stretch ? img.width / img.height : 1;
const shape = tileShape(type, aspect); const shape = tileShape(type, aspect);
const parts = extrudeShapeParts(shape, { height: thickness }); const parts = extrudeShapeParts(shape, { height: thickness });
const key = `tile:${type}:${aspect}:${thickness}`;
return { return {
front: toGeometry(parts.front), front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: toGeometry(parts.back), back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: toGeometry(parts.walls), walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
}; };
}, [type, thickness, stretch, texture]); }, [type, thickness, stretch, texture]);
@@ -79,28 +91,32 @@ function TileMesh({
// left/right to avoid a mirrored texture when viewed from behind. // left/right to avoid a mirrored texture when viewed from behind.
const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]); 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 ( return (
<group> <group>
{/* Front face carries the tile texture. */} {/* Front face carries the tile texture. */}
<mesh geometry={front}> <mesh geometry={front} material={faceMat} />
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
{/* Back face, flipped so it isn't mirrored. */} {/* Back face, flipped so it isn't mirrored. */}
<mesh geometry={back}> <mesh geometry={back} material={backMat} />
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={backMap ?? undefined}
roughness={0.8}
/>
</mesh>
{/* Sides are a solid white, matching TTS tile tinting. */} {/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls}> <mesh geometry={walls} material={wallMat} />
<meshStandardMaterial color="#ffffff" roughness={0.8} />
</mesh>
</group> </group>
); );
} }
+52 -31
View File
@@ -11,6 +11,7 @@ import {
import { traceImage } from '../../api'; import { traceImage } from '../../api';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; import { assetUrl } from './assetUrl';
import { getSharedGeometry, getSharedMaterial } from './sharedResources';
const TOKEN_SIZE = 1.8; 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. * the proxy `/trace` endpoint, so the token matches the artwork's silhouette.
*/ */
export default function TokenViewer({ object }: { object: TTSObject }) { export default function TokenViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
return ( return (
<Scene> <Scene>
<TokenMesh url={url} thickness={thickness} /> <TokenObjectMesh object={object} />
</Scene> </Scene>
); );
} }
/**
* 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 <TokenMesh url={url} thickness={thickness} />;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store. // 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; const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Trace the image's alpha channel into a shape. Suspends until the trace // 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 shape = trace ? toMeshShape(trace) : circleShape();
const uvBounds = trace ? toUvBounds(trace) : undefined; const uvBounds = trace ? toUvBounds(trace) : undefined;
const parts = extrudeShapeParts(shape, { height: thickness, uvBounds }); 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 { return {
front: toGeometry(parts.front), front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: toGeometry(parts.back), back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: toGeometry(parts.walls), 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 // A token is solid: front, back, and walls all carry the texture (projected
// UV), unlike tiles/cards where only the faces are textured. // UV), unlike tiles/cards where only the faces are textured.
const material = ( const material = getSharedMaterial(`token:${url ?? 'none'}`, {
<meshStandardMaterial color: texture ? '#ffffff' : '#52525b',
color={texture ? '#ffffff' : '#52525b'} map: texture ?? undefined,
map={texture ?? undefined} roughness: 0.8,
roughness={0.8} });
/>
);
return ( return (
<group> <group>
<mesh geometry={front}>{material}</mesh> <mesh geometry={front} material={material} />
<mesh geometry={back}>{material}</mesh> <mesh geometry={back} material={material} />
<mesh geometry={walls}>{material}</mesh> <mesh geometry={walls} material={material} />
</group> </group>
); );
} }
@@ -82,31 +94,40 @@ interface TraceData {
height: number; height: number;
} }
// Cache trace promises by URL so Suspense doesn't re-issue the request on every // Cache traces by URL so Suspense doesn't re-issue the request on every render
// render while the boundary is held open. // while the boundary is held open. A URL maps to either a pending promise (while
const traceCache = new Map<string, Promise<TraceData | null>>(); // loading) or the resolved value (once loaded).
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();
/** /**
* Suspend on the alpha trace for `url`, resolving to the traced shape (or 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 * when there's no URL / the trace fails). Throws the cached promise only while
* the surrounding Suspense boundary hold rendering until the trace completes. * 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 { function useTrace(url: string | undefined): TraceData | null {
if (!url) return null; if (!url) return null;
let promise = traceCache.get(url); const cached = traceCache.get(url);
if (!promise) { if (cached === undefined) {
promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => { const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
if (!result.shape) return null; const value: TraceData | null = result.shape
return { ? {
shape: result.shape, shape: result.shape,
width: result.width, width: result.width,
height: result.height, height: result.height,
} as TraceData; }
: 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); 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`. */ /** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) { function toGeometry(extruded: ExtrudedGeometry) {
@@ -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<string, THREE.BufferGeometry>();
const materialCache = new Map<string, THREE.MeshStandardMaterial>();
/** 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;
}
+145
View File
@@ -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 <p className="text-sm text-zinc-400">Loading mod</p>;
if (error) return <p className="text-sm text-red-400">{error}</p>;
if (!mod) return <p className="text-sm text-zinc-500">No mod loaded.</p>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Full setup</h1>
<p className="mt-1 text-sm text-zinc-400">
{objects.length} objects · {renderable.length} rendered
{skipped > 0 ? ` · ${skipped} skipped` : ''}
</p>
</div>
<Scene>
<Suspense fallback={null}>
{/* Key by index, not GUID: cards in a deck share the deck's GUID. */}
{placed.map(({ object, position }, index) => (
<group key={index} position={position}>
<RenderObject object={object} />
</group>
))}
</Suspense>
</Scene>
</div>
);
}
/** 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 <TileObjectMesh object={object} />;
case 'Custom_Token':
return <TokenObjectMesh object={object} />;
case 'Card':
case 'CardCustom':
case 'Deck':
case 'DeckCustom':
case 'Custom_Deck':
return <CardObjectMesh object={object} />;
case 'Custom_Model':
case 'Custom_Model_Bag':
case 'Custom_Model_Infinite_Bag':
return <CustomModelMesh object={object} />;
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<string, TTSObject[]>();
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;
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { Suspense, useEffect, useMemo, useState } from 'react'; import { Suspense, useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/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 { buildTree, collectRefs } from '@tts/extract';
import { useModStore } from '../stores/modStore'; import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore'; import { useSearchStore } from '../stores/searchStore';
@@ -47,12 +47,20 @@ export default function ModPage() {
{mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '} {mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '}
asset refs asset refs
</p> </p>
<div className="mt-3 flex gap-2">
<a <a
href={modFileUrl(id!, item?.fileUrl)} href={modFileUrl(id!, item?.fileUrl)}
className="mt-3 inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300" className="inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
> >
Download save file Download save file
</a> </a>
<Link
to={`/mod/${id}/setup`}
className="inline-block rounded-lg border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-800"
>
Full setup
</Link>
</div>
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
+115
View File
@@ -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 `<Scene>` 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``<TileMesh>` (extruded tile, textured top face)
- `TokenViewer``<TokenMesh>` (alpha-traced extruded token)
- `CardViewer``<CardMesh>` (rounded-rect card, sprite UVs)
- `CustomModelViewer``<Model>` (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 `<Scene>`. 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 `<Model>` + fallback box)
Each viewer now renders `<Scene><XxxObjectMesh object={…} /></Scene>`, 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** `<Scene>` containing all renderable objects as
`<group position={…}>` 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<string, BufferGeometry>`):** 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<string, Material>`):** 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 `<Route path="/mod/:id/setup" element={<FullSetupPage />} />` 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.