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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user