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
@@ -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);