feat(web): add full setup view rendering the whole save

Add a /mod/:id/setup page that lays out every renderable object in a single shared scene. Export object-facing mesh wrappers from the viewers and share geometry and materials across objects via module-level caches.
This commit is contained in:
2026-08-08 18:28:03 +08:00
parent 12418898d0
commit e88dd03fac
9 changed files with 469 additions and 98 deletions
+32 -19
View File
@@ -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>
);
}
+43 -27
View File
@@ -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>
);
}
+55 -34
View File
@@ -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;
}