Compare commits
2
Commits
12418898d0
...
c04d99fe38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04d99fe38 | ||
|
|
e88dd03fac |
@@ -7,10 +7,11 @@ analyze their contents. A lightweight, client-only pnpm monorepo.
|
||||
|
||||
| Package | Role | Runtime |
|
||||
| ------------------- | ------------------------------------------------------ | ---------- |
|
||||
| `apps/web` | React frontend: search + mod pages | Browser |
|
||||
| `apps/proxy` | Hono HTTP server: Workshop search + save fetch | Node |
|
||||
| `apps/web` | React frontend: search + mod pages + 3D object viewers | Browser |
|
||||
| `apps/proxy` | Hono HTTP server: Workshop search, save fetch, tracing | Node |
|
||||
| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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 | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
||||
| 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 |
|
||||
|
||||
## Commands
|
||||
@@ -51,6 +53,24 @@ pnpm test # run the unit tests (vitest)
|
||||
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
|
||||
|
||||
Steam has no official search API. The proxy fetches the Workshop browse page
|
||||
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/" element={<SearchPage />} />
|
||||
<Route path="/mod/:id" element={<ModPage />} />
|
||||
<Route path="/mod/:id/setup" element={<FullSetupPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Scene>
|
||||
<CardMesh
|
||||
faceUrl={faceUrl}
|
||||
backUrl={backUrl}
|
||||
numWidth={numWidth}
|
||||
numHeight={numHeight}
|
||||
uniqueBack={uniqueBack}
|
||||
cardId={cardId}
|
||||
/>
|
||||
<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 } =
|
||||
resolveCardConfig(object);
|
||||
|
||||
return (
|
||||
<CardMesh
|
||||
faceUrl={faceUrl}
|
||||
backUrl={backUrl}
|
||||
numWidth={numWidth}
|
||||
numHeight={numHeight}
|
||||
uniqueBack={uniqueBack}
|
||||
cardId={cardId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
|
||||
@@ -15,24 +15,33 @@ import { FlexibleModelLoader } from './flexibleModelLoader';
|
||||
* model's materials when present.
|
||||
*/
|
||||
export default function CustomModelViewer({ object }: { object: TTSObject }) {
|
||||
return (
|
||||
<Scene>
|
||||
<CustomModelMesh object={object} />
|
||||
</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 (
|
||||
<Scene>
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#52525b" />
|
||||
</mesh>
|
||||
</Scene>
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#52525b" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Scene>
|
||||
<Suspense fallback={null}>
|
||||
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
|
||||
</Suspense>
|
||||
</Scene>
|
||||
<Suspense fallback={null}>
|
||||
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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 thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
||||
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
||||
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
||||
|
||||
return (
|
||||
<Scene>
|
||||
<TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
|
||||
</Scene>
|
||||
);
|
||||
return <TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<group>
|
||||
{/* Front face carries the tile texture. */}
|
||||
<mesh geometry={front}>
|
||||
<meshStandardMaterial
|
||||
color={texture ? '#ffffff' : '#52525b'}
|
||||
map={texture ?? undefined}
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={front} material={faceMat} />
|
||||
{/* Back face, flipped so it isn't mirrored. */}
|
||||
<mesh geometry={back}>
|
||||
<meshStandardMaterial
|
||||
color={texture ? '#ffffff' : '#52525b'}
|
||||
map={backMap ?? undefined}
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh geometry={back} material={backMat} />
|
||||
{/* Sides are a solid white, matching TTS tile tinting. */}
|
||||
<mesh geometry={walls}>
|
||||
<meshStandardMaterial color="#ffffff" roughness={0.8} />
|
||||
</mesh>
|
||||
<mesh geometry={walls} material={wallMat} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Scene>
|
||||
<TokenMesh url={url} thickness={thickness} />
|
||||
<TokenObjectMesh object={object} />
|
||||
</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.
|
||||
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 = (
|
||||
<meshStandardMaterial
|
||||
color={texture ? '#ffffff' : '#52525b'}
|
||||
map={texture ?? undefined}
|
||||
roughness={0.8}
|
||||
/>
|
||||
);
|
||||
const material = getSharedMaterial(`token:${url ?? 'none'}`, {
|
||||
color: texture ? '#ffffff' : '#52525b',
|
||||
map: texture ?? undefined,
|
||||
roughness: 0.8,
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={front}>{material}</mesh>
|
||||
<mesh geometry={back}>{material}</mesh>
|
||||
<mesh geometry={walls}>{material}</mesh>
|
||||
<mesh geometry={front} material={material} />
|
||||
<mesh geometry={back} material={material} />
|
||||
<mesh geometry={walls} material={material} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Promise<TraceData | null>>();
|
||||
// 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<string, TraceData | null | 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.
|
||||
* 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`. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
</p>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
Download save file
|
||||
</a>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a
|
||||
href={modFileUrl(id!, item?.fileUrl)}
|
||||
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
|
||||
</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 className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user