Add 3D object viewers with r3f stack

Add per-class 3D viewers for tiles, tokens, cards, and custom models
using React Three Fiber, drei, and postprocessing. Viewers are
lazy-loaded and registered through the existing viewer registry, with a
shared scene wrapper for lighting, orbit controls, and subtle effects.

Add a CORS-safe /asset proxy route so three.js loaders can fetch
Workshop-hosted textures and models, and extend TTSObject with the
CustomMesh and CustomTile/CustomToken fields the viewers read.
This commit is contained in:
2026-08-08 12:56:46 +08:00
parent f390f170da
commit 001d5eeb54
18 changed files with 1037 additions and 14 deletions
+2
View File
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';
import { Hono } from 'hono';
import { loadEnv, type Bindings } from './env.js';
import asset from './routes/asset.js';
import health from './routes/health.js';
import items from './routes/items.js';
import search from './routes/search.js';
@@ -14,6 +15,7 @@ app.use('*', cors());
app.route('/health', health);
app.route('/search', search);
app.route('/items', items);
app.route('/asset', asset);
serve(
{
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import asset from './asset.js';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('asset route', () => {
it('rejects a missing url', async () => {
const res = await asset.request('/');
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'Missing url query param' });
});
it('rejects a non-http url', async () => {
const res = await asset.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
expect(res.status).toBe(400);
});
it('streams the fetched asset with a content-type header', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'image/png' },
}),
),
);
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
it('returns 502 when the upstream fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
);
const res = await asset.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
expect(res.status).toBe(502);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Hono } from 'hono';
const app = new Hono();
/**
* Fetch an external asset (texture, model, etc.) and stream it back to the
* client. Workshop asset hosts (steamusercontent.com, etc.) often omit CORS
* headers, which would block `TextureLoader` / `GLTFLoader` in the browser.
* Routing through the proxy makes those assets loadable.
*/
app.get('/', async (c) => {
const raw = c.req.query('url');
if (!raw) {
return c.json({ error: 'Missing url query param' }, 400);
}
let url: URL;
try {
url = new URL(raw);
} catch {
return c.json({ error: 'Invalid url query param' }, 400);
}
// Only allow http(s) to avoid SSRF via file://, etc.
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return c.json({ error: 'Only http(s) urls are allowed' }, 400);
}
let res: Response;
try {
res = await fetch(url);
} catch (err) {
return c.json({ error: `Failed to fetch asset: ${String(err)}` }, 502);
}
if (!res.ok) {
return c.json({ error: `Asset responded ${res.status}` }, 502);
}
const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
return new Response(res.body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
},
});
});
export default app;
+5
View File
@@ -15,17 +15,22 @@
"@iconify-json/mdi": "^1.2.3",
"@iconify/react": "^6.0.2",
"@iconify/utils": "^3.1.4",
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4",
"@tts/extract": "workspace:*",
"@tts/shared": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"three": "^0.185.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.0.5",
"tailwindcss": "^4.3.3",
"typescript": "^5.7.2",
@@ -0,0 +1,30 @@
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
/**
* A playing card: a thin box with the face texture on the front and the back
* texture on the rear. Reads `CustomDeck` face/back URLs, falling back to a
* neutral color when absent.
*/
export default function CardViewer({ object }: { object: TTSObject }) {
const deck = object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined;
const faceUrl = deck?.FaceURL;
const backUrl = deck?.BackURL;
const face = faceUrl ? useTexture(assetUrl(faceUrl)) : null;
const back = backUrl ? useTexture(assetUrl(backUrl)) : null;
return (
<Scene>
<mesh>
<boxGeometry args={[1.4, 2, 0.06]} />
<meshStandardMaterial
color={face || back ? '#ffffff' : '#52525b'}
map={face ?? back ?? undefined}
roughness={0.6}
/>
</mesh>
</Scene>
);
}
@@ -0,0 +1,76 @@
import { Suspense, useLayoutEffect } from 'react';
import { useLoader } from '@react-three/fiber';
import { useFBX, useGLTF, useTexture } from '@react-three/drei';
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
import type { TTSObject } from '@tts/shared';
import type * as THREE from 'three';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
/**
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
* and FBX by sniffing the URL extension; falls back to GLTF for unknown
* extensions. `DiffuseURL` is applied to the model's materials when present.
*/
export default function CustomModelViewer({ object }: { object: TTSObject }) {
const meshUrl = object.CustomMesh?.MeshURL;
if (!meshUrl) {
return (
<Scene>
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#52525b" />
</mesh>
</Scene>
);
}
return (
<Scene>
<Suspense fallback={null}>
<Model meshUrl={meshUrl} diffuseUrl={object.CustomMesh?.DiffuseURL} />
</Suspense>
</Scene>
);
}
function Model({
meshUrl,
diffuseUrl,
}: {
meshUrl: string;
diffuseUrl?: string;
}) {
const ext = meshUrl.split('?')[0]!.split('.').pop()!.toLowerCase();
const url = assetUrl(meshUrl);
const diffuse = diffuseUrl ? useTexture(assetUrl(diffuseUrl)) : null;
let root: THREE.Object3D;
if (ext === 'obj') {
root = useLoader(OBJLoader, url);
} else if (ext === 'fbx') {
root = useFBX(url);
} else {
root = useGLTF(url).scene;
}
// Apply the diffuse texture to every mesh material on the loaded model.
useLayoutEffect(() => {
if (!diffuse) return;
root.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
const material = Array.isArray(mesh.material)
? mesh.material[0]
: mesh.material;
if (material && 'map' in material) {
material.map = diffuse;
material.needsUpdate = true;
}
}
});
}, [root, diffuse]);
return <primitive object={root} scale={0.5} />;
}
+50
View File
@@ -0,0 +1,50 @@
import { Suspense, type ReactNode } from 'react';
import { Canvas } from '@react-three/fiber';
import { ContactShadows, OrbitControls } from '@react-three/drei';
import { Bloom, EffectComposer, Vignette } from '@react-three/postprocessing';
/**
* Shared 3D scene wrapper for object viewers. Provides a consistent camera,
* lighting, orbit controls, a soft contact shadow, and subtle post-processing
* (bloom + vignette). Children are wrapped in a Suspense boundary so loading
* assets (textures, models) can suspend without blanking the page.
*/
export default function Scene({ children }: { children: ReactNode }) {
return (
<div className="h-80 w-full overflow-hidden rounded-lg bg-linear-to-b from-zinc-900 to-zinc-950">
<Canvas
camera={{ position: [2.2, 1.8, 2.6], fov: 40 }}
dpr={[1, 2]}
gl={{ antialias: true }}
>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 6, 3]} intensity={1.4} />
<directionalLight position={[-4, 2, -3]} intensity={0.4} color="#b3c7ff" />
<pointLight position={[0, 3, 0]} intensity={0.3} />
<Suspense fallback={null}>{children}</Suspense>
<ContactShadows
position={[0, -0.5, 0]}
opacity={0.55}
scale={8}
blur={2.4}
far={3}
resolution={256}
/>
<OrbitControls
enablePan={false}
minDistance={1}
maxDistance={8}
autoRotate
autoRotateSpeed={1.2}
/>
<EffectComposer>
<Bloom intensity={0.25} luminanceThreshold={0.85} mipmapBlur />
<Vignette eskil={false} offset={0.25} darkness={0.6} />
</EffectComposer>
</Canvas>
</div>
);
}
@@ -0,0 +1,27 @@
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
/**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
* (falling back to `ImageSecondaryURL`), with a neutral color when absent.
*/
export default function TileViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const texture = url ? useTexture(assetUrl(url)) : null;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.1;
return (
<Scene>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<boxGeometry args={[1.6, 1.6, thickness]} />
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
</Scene>
);
}
@@ -0,0 +1,27 @@
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import Scene from './Scene';
import { assetUrl } from './assetUrl';
/**
* A round token: a short cylinder with the texture on its top face. Uses
* `CustomImage.ImageURL`, with a neutral color when absent.
*/
export default function TokenViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const texture = url ? useTexture(assetUrl(url)) : null;
const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;
return (
<Scene>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.9, 0.9, thickness, 48]} />
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
</Scene>
);
}
@@ -0,0 +1,8 @@
/**
* Route an external asset URL through the proxy so it can be loaded by
* three.js loaders (TextureLoader, GLTFLoader, etc.) despite the upstream host
* omitting CORS headers.
*/
export function assetUrl(url: string): string {
return `/asset?url=${encodeURIComponent(url)}`;
}
@@ -0,0 +1,22 @@
import { lazy } from 'react';
import { registerViewer } from '../viewers';
// Register 3D viewers for the object classes that carry renderable assets.
// Importing this module has the side effect of populating the viewer registry.
// The viewers are lazy-loaded so the three.js stack is code-split out of the
// main bundle and only fetched when a 3D-capable object is actually selected.
const TileViewer = lazy(() => import('./TileViewer'));
const TokenViewer = lazy(() => import('./TokenViewer'));
const CardViewer = lazy(() => import('./CardViewer'));
const CustomModelViewer = lazy(() => import('./CustomModelViewer'));
registerViewer('Tile', TileViewer);
registerViewer('Custom_Tile', TileViewer);
registerViewer('Custom_Token', TokenViewer);
registerViewer('Card', CardViewer);
registerViewer('Deck', CardViewer);
registerViewer('DeckCustom', CardViewer);
registerViewer('Custom_Deck', CardViewer);
registerViewer('Custom_Model', CustomModelViewer);
registerViewer('Custom_Model_Bag', CustomModelViewer);
registerViewer('Custom_Model_Infinite_Bag', CustomModelViewer);
+14 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { Icon } from '@iconify/react';
import { useParams } from 'react-router-dom';
import { buildTree, collectRefs } from '@tts/extract';
@@ -8,6 +8,8 @@ import { modFileUrl } from '../api';
import ObjectTree from '../components/ObjectTree';
import { resolveViewer } from '../components/viewers';
import { iconsForObject } from '../components/objectIcons';
// Register the 3D viewers (side effect) so `resolveViewer` can find them.
import '../components/viewers/register';
export default function ModPage() {
const { id } = useParams<{ id: string }>();
@@ -75,7 +77,17 @@ export default function ModPage() {
{selected.object.GUID}
</p>
</header>
{Viewer && <Viewer object={selected.object} />}
{Viewer && (
<Suspense
fallback={
<div className="flex h-80 items-center justify-center text-sm text-zinc-500">
Loading viewer
</div>
}
>
<Viewer object={selected.object} />
</Suspense>
)}
</>
) : (
<p className="text-sm text-zinc-500">
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
'/search': 'http://localhost:3000',
'/items': 'http://localhost:3000',
'/health': 'http://localhost:3000',
'/asset': 'http://localhost:3000',
},
},
});