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 |
|
| 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
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;
|
const meshUrl = object.CustomMesh?.MeshURL;
|
||||||
if (!meshUrl) {
|
if (!meshUrl) {
|
||||||
return (
|
return (
|
||||||
<Scene>
|
|
||||||
<mesh>
|
<mesh>
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
<meshStandardMaterial color="#52525b" />
|
<meshStandardMaterial color="#52525b" />
|
||||||
</mesh>
|
</mesh>
|
||||||
</Scene>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,30 +94,39 @@ 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`. */
|
||||||
|
|||||||
@@ -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 { 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]">
|
||||||
|
|||||||
@@ -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