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