diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 88a194f..bfce742 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,7 @@ import { Link, Route, Routes } from 'react-router-dom'; import SearchPage from './pages/SearchPage'; import ModPage from './pages/ModPage'; +import FullSetupPage from './pages/FullSetupPage'; export default function App() { return ( @@ -21,6 +22,7 @@ export default function App() { } /> } /> + } /> diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx index db5b91a..bb7f034 100644 --- a/apps/web/src/components/viewers/CardViewer.tsx +++ b/apps/web/src/components/viewers/CardViewer.tsx @@ -11,6 +11,7 @@ import Scene from './Scene'; import { assetUrl } from './assetUrl'; import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; import { flipTexture } from './flipTexture'; +import { getSharedGeometry } from './sharedResources'; /** Longer card dimension, in world units. */ const CARD_LENGTH = 2; @@ -49,25 +50,36 @@ const FALLBACK_URL = * the card. */ export default function CardViewer({ object }: { object: TTSObject }) { - const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } = - resolveCardConfig(object); - return ( - + ); } +/** + * The card mesh for an object, exported so the full-setup view can compose it + * into a shared scene. + */ +export function CardObjectMesh({ object }: { object: TTSObject }) { + const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } = + resolveCardConfig(object); + + return ( + + ); +} + // Rendered inside the Canvas so `useTexture` can access the R3F store. -function CardMesh({ +// Exported so the full-setup view can compose it into a shared scene. +export function CardMesh({ faceUrl, backUrl, numWidth, @@ -116,7 +128,9 @@ function CardMesh({ // Build the rounded-rect geometry from the card sprite's aspect ratio. The // front and back faces each get their own material; the walls are a solid - // white, matching TTS card tinting. + // white, matching TTS card tinting. Geometry is shared across cards of the + // same size so the full-setup view reuses it; the face/back materials stay + // per-card because each card clones its texture for sprite UVs. const { frontGeo, backGeo, wallsGeo } = useMemo(() => { const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as | HTMLImageElement @@ -127,13 +141,12 @@ function CardMesh({ // Radius scales with the shorter edge so corners look proportional and // stay circular (no scaling distortion). const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height)); - const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, { - height: CARD_THICKNESS, - }); + const parts = extrudeShapeParts(shape, { height: CARD_THICKNESS }); + const key = `card:${width}:${height}:${CARD_THICKNESS}`; return { - frontGeo: toGeometry(frontGeo), - backGeo: toGeometry(backGeo), - wallsGeo: toGeometry(wallsGeo), + frontGeo: getSharedGeometry(key + ':front', () => toGeometry(parts.front)), + backGeo: getSharedGeometry(key + ':back', () => toGeometry(parts.back)), + wallsGeo: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)), }; }, [faceUrl, face, backUrl, back, numWidth, numHeight]); diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx index e6b44e4..bd08f2e 100644 --- a/apps/web/src/components/viewers/CustomModelViewer.tsx +++ b/apps/web/src/components/viewers/CustomModelViewer.tsx @@ -15,24 +15,33 @@ import { FlexibleModelLoader } from './flexibleModelLoader'; * model's materials when present. */ export default function CustomModelViewer({ object }: { object: TTSObject }) { + return ( + + + + ); +} + +/** + * The mesh content for a custom model, exported so the full-setup view can + * compose it into a shared scene. Renders the model from `CustomMesh.MeshURL` + * (or a neutral box placeholder when absent). + */ +export function CustomModelMesh({ object }: { object: TTSObject }) { const meshUrl = object.CustomMesh?.MeshURL; if (!meshUrl) { return ( - - - - - - + + + + ); } return ( - - - - - + + + ); } diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx index 04daf95..606bb5e 100644 --- a/apps/web/src/components/viewers/TileViewer.tsx +++ b/apps/web/src/components/viewers/TileViewer.tsx @@ -14,6 +14,7 @@ import { import Scene from './Scene'; import { assetUrl } from './assetUrl'; import { flipTexture } from './flipTexture'; +import { getSharedGeometry, getSharedMaterial } from './sharedResources'; /** `CustomTile.Type` enum from Tabletop Simulator. */ const TileType = { @@ -34,20 +35,29 @@ const TILE_SIZE = 2; * source image instead of being forced square. */ export default function TileViewer({ object }: { object: TTSObject }) { + return ( + + + + ); +} + +/** + * The tile mesh for an object, exported so the full-setup view can compose it + * into a shared scene. + */ +export function TileObjectMesh({ object }: { object: TTSObject }) { const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2; const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box; const stretch = object.CustomImage?.CustomTile?.Stretch ?? true; - return ( - - - - ); + return ; } // Rendered inside the Canvas so `useTexture` can access the R3F store. -function TileMesh({ +// Exported so the full-setup view can compose it into a shared scene. +export function TileMesh({ url, thickness, type, @@ -62,16 +72,18 @@ function TileMesh({ // Build the extruded geometry from the tile shape. When `stretch` is false // and a texture is available, scale the shape to the image's aspect ratio so - // the tile matches the source proportions instead of being square. + // the tile matches the source proportions instead of being square. Shared + // across tiles with the same shape so the full-setup view reuses geometry. const { front, back, walls } = useMemo(() => { const img = texture?.image as HTMLImageElement; const aspect = stretch ? img.width / img.height : 1; const shape = tileShape(type, aspect); const parts = extrudeShapeParts(shape, { height: thickness }); + const key = `tile:${type}:${aspect}:${thickness}`; return { - front: toGeometry(parts.front), - back: toGeometry(parts.back), - walls: toGeometry(parts.walls), + front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)), + back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)), + walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)), }; }, [type, thickness, stretch, texture]); @@ -79,28 +91,32 @@ function TileMesh({ // left/right to avoid a mirrored texture when viewed from behind. const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]); + // Shared materials: the front/back carry the tile texture (or a neutral + // color when absent); the walls are a solid white, matching TTS tinting. + const faceKey = `tile-face:${url ?? 'none'}`; + const faceMat = getSharedMaterial(faceKey, { + color: texture ? '#ffffff' : '#52525b', + map: texture ?? undefined, + roughness: 0.8, + }); + const backMat = getSharedMaterial(faceKey + ':back', { + color: texture ? '#ffffff' : '#52525b', + map: backMap ?? undefined, + roughness: 0.8, + }); + const wallMat = getSharedMaterial('tile-wall', { + color: '#ffffff', + roughness: 0.8, + }); + return ( {/* Front face carries the tile texture. */} - - - + {/* Back face, flipped so it isn't mirrored. */} - - - + {/* Sides are a solid white, matching TTS tile tinting. */} - - - + ); } diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index 6715c4c..deed3ca 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -11,6 +11,7 @@ import { import { traceImage } from '../../api'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; +import { getSharedGeometry, getSharedMaterial } from './sharedResources'; const TOKEN_SIZE = 1.8; @@ -24,18 +25,27 @@ const TRACE_INSET = 2; * the proxy `/trace` endpoint, so the token matches the artwork's silhouette. */ export default function TokenViewer({ object }: { object: TTSObject }) { - const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; - const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1; - return ( - + ); } +/** + * The token mesh for an object, exported so the full-setup view can compose it + * into a shared scene. + */ +export function TokenObjectMesh({ object }: { object: TTSObject }) { + const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; + const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1; + + return ; +} + // Rendered inside the Canvas so `useTexture` can access the R3F store. -function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { +// Exported so the full-setup view can compose it into a shared scene. +export function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null; // Trace the image's alpha channel into a shape. Suspends until the trace @@ -50,28 +60,30 @@ function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { const shape = trace ? toMeshShape(trace) : circleShape(); const uvBounds = trace ? toUvBounds(trace) : undefined; const parts = extrudeShapeParts(shape, { height: thickness, uvBounds }); + // Shared across tokens with the same source image (the trace is cached per + // URL, so the silhouette is deterministic) so the full-setup view reuses + // geometry. + const key = `token:${url ?? 'none'}:${thickness}`; return { - front: toGeometry(parts.front), - back: toGeometry(parts.back), - walls: toGeometry(parts.walls), + front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)), + back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)), + walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)), }; - }, [trace, thickness]); + }, [trace, thickness, url]); // A token is solid: front, back, and walls all carry the texture (projected // UV), unlike tiles/cards where only the faces are textured. - const material = ( - - ); + const material = getSharedMaterial(`token:${url ?? 'none'}`, { + color: texture ? '#ffffff' : '#52525b', + map: texture ?? undefined, + roughness: 0.8, + }); return ( - {material} - {material} - {material} + + + ); } @@ -82,30 +94,39 @@ interface TraceData { height: number; } -// Cache trace promises by URL so Suspense doesn't re-issue the request on every -// render while the boundary is held open. -const traceCache = new Map>(); +// Cache traces by URL so Suspense doesn't re-issue the request on every render +// while the boundary is held open. A URL maps to either a pending promise (while +// loading) or the resolved value (once loaded). +const traceCache = new Map>(); /** * Suspend on the alpha trace for `url`, resolving to the traced shape (or null - * when there's no URL / the trace fails). Throwing a cached promise here lets - * the surrounding Suspense boundary hold rendering until the trace completes. + * when there's no URL / the trace fails). Throws the cached promise only while + * it's pending; once resolved, the value is returned directly so the retry + * render completes instead of suspending forever. */ function useTrace(url: string | undefined): TraceData | null { if (!url) return null; - let promise = traceCache.get(url); - if (!promise) { - promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => { - if (!result.shape) return null; - return { - shape: result.shape, - width: result.width, - height: result.height, - } as TraceData; + const cached = traceCache.get(url); + if (cached === undefined) { + const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => { + const value: TraceData | null = result.shape + ? { + shape: result.shape, + width: result.width, + height: result.height, + } + : null; + // Replace the pending promise with the resolved value so later renders + // return it instead of re-suspending on a settled promise. + traceCache.set(url, value); + return value; }); traceCache.set(url, promise); + throw promise; } - throw promise; + if (cached instanceof Promise) throw cached; + return cached; } /** Convert raw extruded arrays into a three.js `BufferGeometry`. */ diff --git a/apps/web/src/components/viewers/sharedResources.ts b/apps/web/src/components/viewers/sharedResources.ts new file mode 100644 index 0000000..434d8f6 --- /dev/null +++ b/apps/web/src/components/viewers/sharedResources.ts @@ -0,0 +1,42 @@ +import * as THREE from 'three'; + +/** + * Module-level caches so the full-setup view can share geometry and materials + * across many objects instead of rebuilding them per object. Keyed by a + * canonical string describing the resource, so identical objects reuse one + * instance. drei already caches textures globally by URL, so sharing the + * material on top avoids per-object material allocation for tiles/tokens with + * the same image. + * + * These caches live for the session (like drei's global texture cache) and are + * not disposed on unmount; see `docs/full-setup-view.md`. + */ + +const geometryCache = new Map(); +const materialCache = new Map(); + +/** Get or create a geometry for `key`. */ +export function getSharedGeometry( + key: string, + build: () => THREE.BufferGeometry, +): THREE.BufferGeometry { + let geo = geometryCache.get(key); + if (!geo) { + geo = build(); + geometryCache.set(key, geo); + } + return geo; +} + +/** Get or create a standard material for `key`. */ +export function getSharedMaterial( + key: string, + params: THREE.MeshStandardMaterialParameters, +): THREE.MeshStandardMaterial { + let mat = materialCache.get(key); + if (!mat) { + mat = new THREE.MeshStandardMaterial(params); + materialCache.set(key, mat); + } + return mat; +} \ No newline at end of file diff --git a/apps/web/src/pages/FullSetupPage.tsx b/apps/web/src/pages/FullSetupPage.tsx new file mode 100644 index 0000000..ec344b3 --- /dev/null +++ b/apps/web/src/pages/FullSetupPage.tsx @@ -0,0 +1,145 @@ +import { Suspense, useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { flattenObjects } from '@tts/extract'; +import type { TTSObject } from '@tts/shared'; +import { useModStore } from '../stores/modStore'; +import { useSearchStore } from '../stores/searchStore'; +import Scene from '../components/viewers/Scene'; +import { TileObjectMesh } from '../components/viewers/TileViewer'; +import { TokenObjectMesh } from '../components/viewers/TokenViewer'; +import { CardObjectMesh } from '../components/viewers/CardViewer'; +import { CustomModelMesh } from '../components/viewers/CustomModelViewer'; + +/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */ +const RENDERABLE = new Set([ + 'Tile', + 'Custom_Tile', + 'Custom_Token', + 'Card', + 'CardCustom', + 'Deck', + 'DeckCustom', + 'Custom_Deck', + 'Custom_Model', + 'Custom_Model_Bag', + 'Custom_Model_Infinite_Bag', +]); + +/** Grid cell spacing (world units) and columns per row. */ +const CELL = 2.5; +const COLS = 8; + +/** + * A "full setup" view: renders every loadable object in a save into a single + * shared scene, laid out on a virtual table. Objects are grouped by type so + * like objects sit together. Geometry and materials are shared across objects + * (see `components/viewers/sharedResources.ts`). + */ +export default function FullSetupPage() { + const { id } = useParams<{ id: string }>(); + const { mod, loading, error, load } = useModStore(); + const item = useSearchStore((s) => s.items.find((i) => i.id === id)); + + useEffect(() => { + if (id) load(id, item?.fileUrl); + }, [id, item?.fileUrl, load]); + + const objects = useMemo( + () => (mod ? flattenObjects(mod) : []), + [mod], + ); + + const renderable = useMemo( + () => objects.filter((o) => RENDERABLE.has(o.Name)), + [objects], + ); + const skipped = objects.length - renderable.length; + + const placed = useMemo(() => layoutObjects(renderable), [renderable]); + + if (loading) return

Loading mod…

; + if (error) return

{error}

; + if (!mod) return

No mod loaded.

; + + return ( +
+
+

Full setup

+

+ {objects.length} objects · {renderable.length} rendered + {skipped > 0 ? ` · ${skipped} skipped` : ''} +

+
+ + + + {/* Key by index, not GUID: cards in a deck share the deck's GUID. */} + {placed.map(({ object, position }, index) => ( + + + + ))} + + +
+ ); +} + +/** Dispatch a renderable object to the mesh component for its class. */ +function RenderObject({ object }: { object: TTSObject }) { + switch (object.Name) { + case 'Tile': + case 'Custom_Tile': + return ; + case 'Custom_Token': + return ; + case 'Card': + case 'CardCustom': + case 'Deck': + case 'DeckCustom': + case 'Custom_Deck': + return ; + case 'Custom_Model': + case 'Custom_Model_Bag': + case 'Custom_Model_Infinite_Bag': + return ; + default: + return null; + } +} + +/** + * Assign each renderable object a grid position, grouping by class so like + * objects sit together. Returns the object plus its `[x, y, z]` position. + */ +function layoutObjects( + objects: TTSObject[], +): { object: TTSObject; position: [number, number, number] }[] { + // Group by type so like objects sit together. + const groups = new Map(); + for (const o of objects) { + const arr = groups.get(o.Name) ?? []; + arr.push(o); + groups.set(o.Name, arr); + } + + const result: { object: TTSObject; position: [number, number, number] }[] = []; + let row = 0; + let col = 0; + for (const objs of groups.values()) { + for (const o of objs) { + result.push({ object: o, position: [col * CELL, 0, row * CELL] }); + col++; + if (col >= COLS) { + col = 0; + row++; + } + } + // Start a fresh row after each group. + if (col !== 0) { + col = 0; + row++; + } + } + return result; +} \ No newline at end of file diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx index 2f62951..b83b4c1 100644 --- a/apps/web/src/pages/ModPage.tsx +++ b/apps/web/src/pages/ModPage.tsx @@ -1,6 +1,6 @@ import { Suspense, useEffect, useMemo, useState } from 'react'; import { Icon } from '@iconify/react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { buildTree, collectRefs } from '@tts/extract'; import { useModStore } from '../stores/modStore'; import { useSearchStore } from '../stores/searchStore'; @@ -47,12 +47,20 @@ export default function ModPage() { {mod.GameMode} · {mod.Date} · {tree.length} objects · {refs.length}{' '} asset refs

- - Download save file - +
+ + Download save file + + + Full setup + +
diff --git a/docs/full-setup-view.md b/docs/full-setup-view.md new file mode 100644 index 0000000..28bd1fb --- /dev/null +++ b/docs/full-setup-view.md @@ -0,0 +1,115 @@ +# Full Setup View — Plan + +> **Scope:** A new page that renders every loadable object in a save into a +> single shared 3D scene, sharing materials and geometry where possible. This +> complements the existing per-object inspection on the mod page. + +## Motivation + +The mod page (`apps/web/src/pages/ModPage.tsx`) inspects one object at a time +inside a shared `Scene`. A "full setup" view renders the whole save at once — +every tile, token, card, and custom model laid out on a virtual table — so a +user can see the entire scene at a glance. + +## Current architecture (relevant pieces) + +- Each viewer owns its own `` wrapper (`components/viewers/Scene.tsx`), + which provides the camera, lights, orbit controls, contact shadows, and + post-processing. The mesh content lives inside each viewer: + - `TileViewer` → `` (extruded tile, textured top face) + - `TokenViewer` → `` (alpha-traced extruded token) + - `CardViewer` → `` (rounded-rect card, sprite UVs) + - `CustomModelViewer` → `` (GLTF/OBJ/FBX via `FlexibleModelLoader`) +- `@tts/extract` provides `flattenObjects(mod)` / `traverseMod` to enumerate + every object in a save. +- `@tts/mesh` provides the shape + extrusion helpers used by the viewers. +- `TTSObject` carries no position data, so exact table placement is not + recoverable — the full setup lays objects out itself. + +## Plan + +### 1. Refactor viewers to expose their mesh content (no `Scene`) + +Each viewer currently wraps its mesh in ``. To compose everything into +one scene, extract and export the inner mesh components, keeping the existing +viewers as thin wrappers: + +- `TileViewer` → export `TileObjectMesh` (accepts a `TTSObject`) +- `TokenViewer` → export `TokenObjectMesh` +- `CardViewer` → export `CardObjectMesh` +- `CustomModelViewer` → export `CustomModelMesh` (the `` + fallback box) + +Each viewer now renders ``, so the +object-facing wrapper is the single source of truth for both the per-object +view and the full-setup view. + +### 2. New page `FullSetupPage.tsx` at `/mod/:id/setup` + +- Reuse `useModStore` (same load path as `ModPage`). +- `flattenObjects(mod)` → filter to renderable classes (those registered in + `components/viewers/register.ts`: `Tile`, `Custom_Tile`, `Custom_Token`, + `Card`, `CardCustom`, `Deck`, `DeckCustom`, `Custom_Deck`, `Custom_Model*`). +- Render **one** `` containing all renderable objects as + `` entries, dispatching to the right `*Mesh` by `Name`. +- Show a summary header (total objects, rendered count, skipped count) and + loading/error states matching `ModPage`. + +### 3. Layout + +- Arrange objects in a grid on the "table" (fixed spacing, wrapping by row), + grouping by type so cards sit together, tiles together, etc. +- `Scene`'s `Bounds fit` already auto-fits the camera to the full layout, so no + camera work is needed. +- Non-renderable objects (bags, dice, boards without assets) are skipped and + counted, not dropped silently. + +### 4. Share materials & geometry + +- **Geometry cache (module-level `Map`):** key by a + canonical string — `card:{w}:{h}:{t}`, `tile:{type}:{aspect}:{t}`, + `token:{url}:{t}`, `model:{meshUrl}`. All cards of the same size, or tiles + of the same type/size, reuse one geometry instead of rebuilding per object. + Token geometry is keyed by the source image URL (the trace is cached per + URL, so the silhouette is deterministic). +- **Material cache (module-level `Map`):** key by + `textureUrl + color + roughness`. drei already caches textures by URL + globally, so sharing the material on top avoids per-object material + allocation for tiles/tokens with the same image. +- **Cards are the exception:** each card clones its texture for sprite UVs, so + its face material cannot be shared — but its geometry still can (same card + size). +- Dispose shared resources on page unmount, or accept a module-level cache for + the session (see Open decisions). + +### 5. Routing & navigation + +- Add `} />` in + `apps/web/src/App.tsx`. +- Add a "Full setup" link/button on `ModPage` next to the download button. + +### 6. Edge cases + +- Objects with no asset (no `ImageURL`/`MeshURL`) render as neutral-colored + placeholders (matching the current viewers' fallback behavior). +- Token tracing suspends per URL (already cached in `TokenViewer`); the shared + `Scene` Suspense boundary handles it. +- Large saves: the grid + shared geometry keeps it performant, but cap or warn + on very large object counts if needed. + +## Files touched + +- `apps/web/src/components/viewers/{Tile,Token,Card,CustomModel}Viewer.tsx` — + export object-facing mesh wrappers +- `apps/web/src/components/viewers/sharedResources.ts` — new geometry/material + caches +- `apps/web/src/pages/FullSetupPage.tsx` — new +- `apps/web/src/App.tsx` — route +- `apps/web/src/pages/ModPage.tsx` — nav link +- Possibly a small `geometryCache`/`materialCache` helper under + `components/viewers/` + +## Open decisions (defaults in bold) + +- **Layout style** — **grouped grid** vs a fan/stack for cards. +- **Geometry/material cache lifetime** — **session-level module cache** + (simplest, consistent with drei's global texture cache) vs dispose-on-unmount. \ No newline at end of file