refactor(web): code-split the three.js stack out of the main bundle

This commit is contained in:
2026-08-10 09:12:38 +08:00
parent 45bf362fbc
commit 6502fdae4f
10 changed files with 600 additions and 571 deletions
+31 -18
View File
@@ -1,15 +1,21 @@
import { lazy, Suspense } from 'react';
import { Link, Route, Routes } from 'react-router-dom'; import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage'; import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage'; import ModPage from './pages/ModPage';
import FullSetupPage from './pages/FullSetupPage';
import BgmPage from './pages/BgmPage'; // The full-setup view and the bgm/tabletop pages pull in the whole three.js
import BgmPackagePage from './pages/BgmPackagePage'; // stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
import PartsPage from './pages/PartsPage'; // stack stays code-split out of the main bundle and is only fetched when one
import PartPage from './pages/PartPage'; // of those routes is actually visited.
import SurfacesPage from './pages/SurfacesPage'; const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
import SurfacePage from './pages/SurfacePage'; const BgmPage = lazy(() => import('./pages/BgmPage'));
import SetupsPage from './pages/SetupsPage'; const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
import SetupPage from './pages/SetupPage'; const PartsPage = lazy(() => import('./pages/PartsPage'));
const PartPage = lazy(() => import('./pages/PartPage'));
const SurfacesPage = lazy(() => import('./pages/SurfacesPage'));
const SurfacePage = lazy(() => import('./pages/SurfacePage'));
const SetupsPage = lazy(() => import('./pages/SetupsPage'));
const SetupPage = lazy(() => import('./pages/SetupPage'));
export default function App() { export default function App() {
return ( return (
@@ -33,15 +39,22 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<SearchPage />} /> <Route path="/" element={<SearchPage />} />
<Route path="/mod/:id" element={<ModPage />} /> <Route path="/mod/:id" element={<ModPage />} />
<Route path="/mod/:id/setup" element={<FullSetupPage />} /> <Route
<Route path="/bgm" element={<BgmPage />} /> path="/mod/:id/setup"
<Route path="/bgm/:id" element={<BgmPackagePage />} /> element={
<Route path="/bgm/:id/parts" element={<PartsPage />} /> <Suspense fallback={<p className="text-sm text-zinc-400">Loading full setup</p>}>
<Route path="/bgm/:id/parts/:type/:part" element={<PartPage />} /> <FullSetupPage />
<Route path="/bgm/:id/surfaces" element={<SurfacesPage />} /> </Suspense>
<Route path="/bgm/:id/surfaces/:type/:surface" element={<SurfacePage />} /> }
<Route path="/bgm/:id/setups" element={<SetupsPage />} /> />
<Route path="/bgm/:id/setups/:type/:setup" element={<SetupPage />} /> <Route path="/bgm" element={<Suspense fallback={null}><BgmPage /></Suspense>} />
<Route path="/bgm/:id" element={<Suspense fallback={null}><BgmPackagePage /></Suspense>} />
<Route path="/bgm/:id/parts" element={<Suspense fallback={null}><PartsPage /></Suspense>} />
<Route path="/bgm/:id/parts/:type/:part" element={<Suspense fallback={null}><PartPage /></Suspense>} />
<Route path="/bgm/:id/surfaces" element={<Suspense fallback={null}><SurfacesPage /></Suspense>} />
<Route path="/bgm/:id/surfaces/:type/:surface" element={<Suspense fallback={null}><SurfacePage /></Suspense>} />
<Route path="/bgm/:id/setups" element={<Suspense fallback={null}><SetupsPage /></Suspense>} />
<Route path="/bgm/:id/setups/:type/:setup" element={<Suspense fallback={null}><SetupPage /></Suspense>} />
</Routes> </Routes>
</main> </main>
</div> </div>
@@ -0,0 +1,157 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import { assetUrl } from '@tts/http';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture';
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
/** Corner radius as a fraction of the shorter card edge. */
const CORNER_RADIUS = 0.05;
/** Thickness of the card. */
const CARD_THICKNESS = 0.06;
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
// Without it, the face/back hooks would be called conditionally, which breaks
// React's rules of hooks when switching between objects with different URL
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
const FALLBACK_URL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/**
* 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}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function CardMesh({
faceUrl,
backUrl,
numWidth,
numHeight,
uniqueBack,
cardId,
tint,
}: {
faceUrl?: string;
backUrl?: string;
numWidth?: number;
numHeight?: number;
uniqueBack: boolean;
cardId?: number;
tint: THREE.Color;
}) {
// Always call both hooks so the hook count is stable across renders. The
// placeholder is used only when a URL is absent; presence is checked via the
// URL strings below, not the texture objects.
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
// Front texture: the sprite cell from the sheet (or the full image when there
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
// that share the same sheet URL (drei caches textures globally by URL).
const faceMap = useMemo(() => {
if (!faceUrl) return null;
const tex = face.clone();
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return tex;
}, [faceUrl, face, cardId, numWidth, numHeight]);
// Back texture: a single full image (tile) unless the deck has unique backs,
// in which case it's a sheet too. Flipped left/right so it reads correctly
// instead of being mirrored on the back face.
const backMap = useMemo(() => {
if (!backUrl) return null;
const tex = back.clone();
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
? spriteUv(cardId, numWidth, numHeight)
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return flipTexture(tex);
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
// 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. 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
| undefined;
const aspect = cardAspect(img, numWidth, numHeight);
const width = CARD_LENGTH * aspect;
const height = CARD_LENGTH;
// 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 parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
return {
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]);
return (
<group>
<mesh geometry={frontGeo}>
<meshStandardMaterial
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
map={faceMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
map={backMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
</mesh>
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
+2 -155
View File
@@ -1,31 +1,6 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import {
extrudeShapeParts,
roundedRectShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from '@tts/http'; import { CardObjectMesh } from './CardMesh';
import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution';
import { flipTexture } from './flipTexture';
import { getSharedGeometry, objectTint, tintedColor } from './sharedResources';
/** Longer card dimension, in world units. */
const CARD_LENGTH = 2;
/** Corner radius as a fraction of the shorter card edge. */
const CORNER_RADIUS = 0.05;
/** Thickness of the card. */
const CARD_THICKNESS = 0.06;
// A 1x1 transparent placeholder so `useTexture` always receives a valid URL.
// Without it, the face/back hooks would be called conditionally, which breaks
// React's rules of hooks when switching between objects with different URL
// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image).
const FALLBACK_URL =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
/** /**
* A playing card: a thin rounded rect with the face texture on the front and * A playing card: a thin rounded rect with the face texture on the front and
@@ -57,132 +32,4 @@ export default function CardViewer({ object }: { object: TTSObject }) {
); );
} }
/** export { CardObjectMesh, CardMesh } from './CardMesh';
* 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}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function CardMesh({
faceUrl,
backUrl,
numWidth,
numHeight,
uniqueBack,
cardId,
tint,
}: {
faceUrl?: string;
backUrl?: string;
numWidth?: number;
numHeight?: number;
uniqueBack: boolean;
cardId?: number;
tint: THREE.Color;
}) {
// Always call both hooks so the hook count is stable across renders. The
// placeholder is used only when a URL is absent; presence is checked via the
// URL strings below, not the texture objects.
const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL);
const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL);
// Front texture: the sprite cell from the sheet (or the full image when there
// is no grid). Cloned so the sprite offset/repeat don't leak into other cards
// that share the same sheet URL (drei caches textures globally by URL).
const faceMap = useMemo(() => {
if (!faceUrl) return null;
const tex = face.clone();
const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight);
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return tex;
}, [faceUrl, face, cardId, numWidth, numHeight]);
// Back texture: a single full image (tile) unless the deck has unique backs,
// in which case it's a sheet too. Flipped left/right so it reads correctly
// instead of being mirrored on the back face.
const backMap = useMemo(() => {
if (!backUrl) return null;
const tex = back.clone();
const { repeatX, repeatY, offsetX, offsetY } = uniqueBack
? spriteUv(cardId, numWidth, numHeight)
: { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 };
tex.repeat.set(repeatX, repeatY);
tex.offset.set(offsetX, offsetY);
return flipTexture(tex);
}, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]);
// 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. 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
| undefined;
const aspect = cardAspect(img, numWidth, numHeight);
const width = CARD_LENGTH * aspect;
const height = CARD_LENGTH;
// 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 parts = extrudeShapeParts(shape, { height: CARD_THICKNESS });
const key = `card:${width}:${height}:${CARD_THICKNESS}`;
return {
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]);
return (
<group>
<mesh geometry={frontGeo}>
<meshStandardMaterial
color={tintedColor(faceMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
map={faceMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial
color={tintedColor(backMap ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint)}
map={backMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color={tintedColor(new THREE.Color('#ffffff'), tint)} roughness={0.6} />
</mesh>
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
@@ -0,0 +1,103 @@
import { Suspense, useLayoutEffect } from 'react';
import { useLoader } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared';
import * as THREE from 'three';
import type { Object3D } from 'three';
import { assetUrl } from '@tts/http';
import { FlexibleModelLoader } from './flexibleModelLoader';
import { objectTint, tintedColor } from './sharedResources';
/**
* 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;
const tint = objectTint(object);
if (!meshUrl) {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
</mesh>
);
}
return (
<Suspense fallback={null}>
<Model
meshUrl={meshUrl}
diffuseUrl={object.CustomMesh?.DiffuseURL}
tint={tint}
/>
</Suspense>
);
}
function Model({
meshUrl,
diffuseUrl,
tint,
}: {
meshUrl: string;
diffuseUrl?: string;
tint: THREE.Color;
}) {
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
return (
<>
<primitive object={root} scale={0.5} />
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
<Tint root={root} tint={tint} />
</>
);
}
// Apply the object's tint to every material on the loaded model, multiplying
// the existing color. Rendered after the model so it runs once the materials
// exist.
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
useLayoutEffect(() => {
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 && 'color' in material) {
(material as THREE.MeshStandardMaterial).color.multiply(tint);
material.needsUpdate = true;
}
}
});
}, [root, tint]);
return null;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
// mounted when a diffuse URL exists, so the hook count stays consistent.
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
const texture = useTexture(assetUrl(url));
// Apply the diffuse texture to every mesh material on the loaded model.
useLayoutEffect(() => {
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 = texture;
material.needsUpdate = true;
}
}
});
}, [root, texture]);
return null;
}
@@ -1,13 +1,6 @@
import { Suspense, useLayoutEffect } from 'react';
import { useLoader } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import * as THREE from 'three';
import type { Object3D } from 'three';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from '@tts/http'; import { CustomModelMesh } from './CustomModelMesh';
import { FlexibleModelLoader } from './flexibleModelLoader';
import { objectTint, tintedColor } from './sharedResources';
/** /**
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ, * A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
@@ -23,96 +16,4 @@ export default function CustomModelViewer({ object }: { object: TTSObject }) {
); );
} }
/** export { CustomModelMesh } from './CustomModelMesh';
* 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;
const tint = objectTint(object);
if (!meshUrl) {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={tintedColor(new THREE.Color('#52525b'), tint)} />
</mesh>
);
}
return (
<Suspense fallback={null}>
<Model
meshUrl={meshUrl}
diffuseUrl={object.CustomMesh?.DiffuseURL}
tint={tint}
/>
</Suspense>
);
}
function Model({
meshUrl,
diffuseUrl,
tint,
}: {
meshUrl: string;
diffuseUrl?: string;
tint: THREE.Color;
}) {
const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
return (
<>
<primitive object={root} scale={0.5} />
{diffuseUrl && <DiffuseTexture root={root} url={diffuseUrl} />}
<Tint root={root} tint={tint} />
</>
);
}
// Apply the object's tint to every material on the loaded model, multiplying
// the existing color. Rendered after the model so it runs once the materials
// exist.
function Tint({ root, tint }: { root: Object3D; tint: THREE.Color }) {
useLayoutEffect(() => {
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 && 'color' in material) {
(material as THREE.MeshStandardMaterial).color.multiply(tint);
material.needsUpdate = true;
}
}
});
}, [root, tint]);
return null;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store. Only
// mounted when a diffuse URL exists, so the hook count stays consistent.
function DiffuseTexture({ root, url }: { root: Object3D; url: string }) {
const texture = useTexture(assetUrl(url));
// Apply the diffuse texture to every mesh material on the loaded model.
useLayoutEffect(() => {
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 = texture;
material.needsUpdate = true;
}
}
});
}, [root, texture]);
return null;
}
@@ -0,0 +1,154 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
hexShape,
rectShape,
roundedRectShape,
scaleShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import { assetUrl } from '@tts/http';
import { flipTexture } from './flipTexture';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
/** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = {
Box: 0,
Hex: 1,
Circle: 2,
Rounded: 3,
} as const;
const TILE_SIZE = 2;
/**
* 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 (
<TileMesh
url={url}
thickness={thickness}
type={type}
stretch={stretch}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TileMesh({
url,
thickness,
type,
stretch,
tint,
}: {
url?: string;
thickness: number;
type: number;
stretch: boolean;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// 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. 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: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [type, thickness, stretch, texture]);
// The back face maps with the same planar UVs as the front, so flip it
// 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.
// The tint is baked into the color and the cache key so tinted variants
// don't collide.
const tintK = tintKey(tint);
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
const faceMat = getSharedMaterial(faceKey, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
const backMat = getSharedMaterial(faceKey + ':back', {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: backMap ?? undefined,
roughness: 0.8,
});
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
color: tintedColor(new THREE.Color('#ffffff'), tint),
roughness: 0.8,
});
return (
<group>
{/* Front face carries the tile texture. */}
<mesh geometry={front} material={faceMat} />
{/* Back face, flipped so it isn't mirrored. */}
<mesh geometry={back} material={backMat} />
{/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls} material={wallMat} />
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
function tileShape(type: number, aspect: number) {
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
const sx = aspect;
const sy = 1;
switch (type) {
case TileType.Hex:
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
case TileType.Circle:
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
case TileType.Rounded:
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
case TileType.Box:
default:
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
}
}
+2 -152
View File
@@ -1,36 +1,6 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
hexShape,
rectShape,
roundedRectShape,
scaleShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from '@tts/http'; import { TileObjectMesh } from './TileMesh';
import { flipTexture } from './flipTexture';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
/** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = {
Box: 0,
Hex: 1,
Circle: 2,
Rounded: 3,
} as const;
const TILE_SIZE = 2;
/** /**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL` * A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
@@ -48,124 +18,4 @@ export default function TileViewer({ object }: { object: TTSObject }) {
); );
} }
/** export { TileObjectMesh, TileMesh } from './TileMesh';
* 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 (
<TileMesh
url={url}
thickness={thickness}
type={type}
stretch={stretch}
tint={objectTint(object)}
/>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TileMesh({
url,
thickness,
type,
stretch,
tint,
}: {
url?: string;
thickness: number;
type: number;
stretch: boolean;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// 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. 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: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [type, thickness, stretch, texture]);
// The back face maps with the same planar UVs as the front, so flip it
// 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.
// The tint is baked into the color and the cache key so tinted variants
// don't collide.
const tintK = tintKey(tint);
const faceKey = `tile-face:${url ?? 'none'}:${tintK}`;
const faceMat = getSharedMaterial(faceKey, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
const backMat = getSharedMaterial(faceKey + ':back', {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: backMap ?? undefined,
roughness: 0.8,
});
const wallMat = getSharedMaterial(`tile-wall:${tintK}`, {
color: tintedColor(new THREE.Color('#ffffff'), tint),
roughness: 0.8,
});
return (
<group>
{/* Front face carries the tile texture. */}
<mesh geometry={front} material={faceMat} />
{/* Back face, flipped so it isn't mirrored. */}
<mesh geometry={back} material={backMat} />
{/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls} material={wallMat} />
</group>
);
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
function tileShape(type: number, aspect: number) {
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
const sx = aspect;
const sy = 1;
switch (type) {
case TileType.Hex:
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
case TileType.Circle:
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
case TileType.Rounded:
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
case TileType.Box:
default:
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
}
}
@@ -0,0 +1,143 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
traceToShape,
traceToUvBounds,
type ExtrudedGeometry,
} from '@tts/mesh';
import { traceImage } from '@tts/http';
import { assetUrl } from '@tts/http';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
const TOKEN_SIZE = 1.8;
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
const TRACE_INSET = 2;
/**
* 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} tint={objectTint(object)} />;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TokenMesh({
url,
thickness,
tint,
}: {
url?: string;
thickness: number;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Trace the image's alpha channel into a shape. Suspends until the trace
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
// once the token geometry is present. Falls back to a circle when there's no
// image or the trace fails.
const trace = useTrace(url);
const { front, back, walls } = useMemo(() => {
// The traced shape and its UV framing share the same transform, so the
// full image rectangle maps to the same bounds in mesh coordinates.
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
const uvBounds = trace ? traceToUvBounds(trace, scale) : 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: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [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. The tint is
// baked into the color and cache key so tinted variants don't collide.
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
return (
<group>
<mesh geometry={front} material={material} />
<mesh geometry={back} material={material} />
<mesh geometry={walls} material={material} />
</group>
);
}
interface TraceData {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}
// 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). 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;
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;
}
if (cached instanceof Promise) throw cached;
return cached;
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
+2 -141
View File
@@ -1,29 +1,6 @@
import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
traceToShape,
traceToUvBounds,
type ExtrudedGeometry,
} from '@tts/mesh';
import { traceImage } from '@tts/http';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from '@tts/http'; import { TokenObjectMesh } from './TokenMesh';
import {
getSharedGeometry,
getSharedMaterial,
objectTint,
tintKey,
tintedColor,
} from './sharedResources';
const TOKEN_SIZE = 1.8;
/** How far (in trace pixels) the token silhouette is inset from the artwork. */
const TRACE_INSET = 2;
/** /**
* A token: a short extruded shape with the texture on its top face. Uses * A token: a short extruded shape with the texture on its top face. Uses
@@ -39,120 +16,4 @@ export default function TokenViewer({ object }: { object: TTSObject }) {
); );
} }
/** export { TokenObjectMesh, TokenMesh } from './TokenMesh';
* 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} tint={objectTint(object)} />;
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TokenMesh({
url,
thickness,
tint,
}: {
url?: string;
thickness: number;
tint: THREE.Color;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// Trace the image's alpha channel into a shape. Suspends until the trace
// resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
// once the token geometry is present. Falls back to a circle when there's no
// image or the trace fails.
const trace = useTrace(url);
const { front, back, walls } = useMemo(() => {
// The traced shape and its UV framing share the same transform, so the
// full image rectangle maps to the same bounds in mesh coordinates.
const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
const uvBounds = trace ? traceToUvBounds(trace, scale) : 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: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
};
}, [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. The tint is
// baked into the color and cache key so tinted variants don't collide.
const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
map: texture ?? undefined,
roughness: 0.8,
});
return (
<group>
<mesh geometry={front} material={material} />
<mesh geometry={back} material={material} />
<mesh geometry={walls} material={material} />
</group>
);
}
interface TraceData {
shape: { outline: number[][]; holes?: number[][][] };
width: number;
height: number;
}
// 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). 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;
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;
}
if (cached instanceof Promise) throw cached;
return cached;
}
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
const { positions, normals, uvs, indices } = extruded;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geo.setIndex(new THREE.BufferAttribute(indices, 1));
return geo;
}
+4 -4
View File
@@ -5,10 +5,10 @@ import type { TTSObject } from '@tts/shared';
import { useModStore } from '../stores/modStore'; import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore'; import { useSearchStore } from '../stores/searchStore';
import Scene from '../components/viewers/Scene'; import Scene from '../components/viewers/Scene';
import { TileObjectMesh } from '../components/viewers/TileViewer'; import { TileObjectMesh } from '../components/viewers/TileMesh';
import { TokenObjectMesh } from '../components/viewers/TokenViewer'; import { TokenObjectMesh } from '../components/viewers/TokenMesh';
import { CardObjectMesh } from '../components/viewers/CardViewer'; import { CardObjectMesh } from '../components/viewers/CardMesh';
import { CustomModelMesh } from '../components/viewers/CustomModelViewer'; import { CustomModelMesh } from '../components/viewers/CustomModelMesh';
import { objectPlacement } from '../components/viewers/transform'; import { objectPlacement } from '../components/viewers/transform';
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */ /** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */