From 6502fdae4f556f6a2a8975fde408ce4365300075 Mon Sep 17 00:00:00 2001
From: hypercross
Date: Mon, 10 Aug 2026 09:12:38 +0800
Subject: [PATCH] refactor(web): code-split the three.js stack out of the main
bundle
---
apps/web/src/App.tsx | 49 ++++--
apps/web/src/components/viewers/CardMesh.tsx | 157 ++++++++++++++++++
.../web/src/components/viewers/CardViewer.tsx | 157 +-----------------
.../components/viewers/CustomModelMesh.tsx | 103 ++++++++++++
.../components/viewers/CustomModelViewer.tsx | 103 +-----------
apps/web/src/components/viewers/TileMesh.tsx | 154 +++++++++++++++++
.../web/src/components/viewers/TileViewer.tsx | 154 +----------------
apps/web/src/components/viewers/TokenMesh.tsx | 143 ++++++++++++++++
.../src/components/viewers/TokenViewer.tsx | 143 +---------------
apps/web/src/pages/FullSetupPage.tsx | 8 +-
10 files changed, 600 insertions(+), 571 deletions(-)
create mode 100644 apps/web/src/components/viewers/CardMesh.tsx
create mode 100644 apps/web/src/components/viewers/CustomModelMesh.tsx
create mode 100644 apps/web/src/components/viewers/TileMesh.tsx
create mode 100644 apps/web/src/components/viewers/TokenMesh.tsx
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 622998f..92936cc 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,15 +1,21 @@
+import { lazy, Suspense } from 'react';
import { Link, Route, Routes } from 'react-router-dom';
import SearchPage from './pages/SearchPage';
import ModPage from './pages/ModPage';
-import FullSetupPage from './pages/FullSetupPage';
-import BgmPage from './pages/BgmPage';
-import BgmPackagePage from './pages/BgmPackagePage';
-import PartsPage from './pages/PartsPage';
-import PartPage from './pages/PartPage';
-import SurfacesPage from './pages/SurfacesPage';
-import SurfacePage from './pages/SurfacePage';
-import SetupsPage from './pages/SetupsPage';
-import SetupPage from './pages/SetupPage';
+
+// The full-setup view and the bgm/tabletop pages pull in the whole three.js
+// stack (Scene + all mesh viewers + @tts/tabletop). Lazy-load them so that
+// stack stays code-split out of the main bundle and is only fetched when one
+// of those routes is actually visited.
+const FullSetupPage = lazy(() => import('./pages/FullSetupPage'));
+const BgmPage = lazy(() => import('./pages/BgmPage'));
+const BgmPackagePage = lazy(() => import('./pages/BgmPackagePage'));
+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() {
return (
@@ -33,15 +39,22 @@ export default function App() {
} />
} />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ Loading full setup…
}>
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/apps/web/src/components/viewers/CardMesh.tsx b/apps/web/src/components/viewers/CardMesh.tsx
new file mode 100644
index 0000000..1d40b21
--- /dev/null
+++ b/apps/web/src/components/viewers/CardMesh.tsx
@@ -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 (
+
+ );
+}
+
+// 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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** 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;
+}
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx
index e81a6c4..98cea35 100644
--- a/apps/web/src/components/viewers/CardViewer.tsx
+++ b/apps/web/src/components/viewers/CardViewer.tsx
@@ -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 {
- extrudeShapeParts,
- roundedRectShape,
- type ExtrudedGeometry,
-} from '@tts/mesh';
import Scene from './Scene';
-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';
+import { CardObjectMesh } from './CardMesh';
/**
* 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 }) {
);
}
-/**
- * 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 (
-
- );
-}
-
-// 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 (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-/** 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;
-}
\ No newline at end of file
+export { CardObjectMesh, CardMesh } from './CardMesh';
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/CustomModelMesh.tsx b/apps/web/src/components/viewers/CustomModelMesh.tsx
new file mode 100644
index 0000000..c9adfec
--- /dev/null
+++ b/apps/web/src/components/viewers/CustomModelMesh.tsx
@@ -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 (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function Model({
+ meshUrl,
+ diffuseUrl,
+ tint,
+}: {
+ meshUrl: string;
+ diffuseUrl?: string;
+ tint: THREE.Color;
+}) {
+ const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
+
+ return (
+ <>
+
+ {diffuseUrl && }
+
+ >
+ );
+}
+
+// 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;
+}
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/CustomModelViewer.tsx b/apps/web/src/components/viewers/CustomModelViewer.tsx
index 667d34c..a3eb613 100644
--- a/apps/web/src/components/viewers/CustomModelViewer.tsx
+++ b/apps/web/src/components/viewers/CustomModelViewer.tsx
@@ -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 * as THREE from 'three';
-import type { Object3D } from 'three';
import Scene from './Scene';
-import { assetUrl } from '@tts/http';
-import { FlexibleModelLoader } from './flexibleModelLoader';
-import { objectTint, tintedColor } from './sharedResources';
+import { CustomModelMesh } from './CustomModelMesh';
/**
* A custom 3D model loaded from `CustomMesh.MeshURL`. Supports GLTF/GLB, OBJ,
@@ -23,96 +16,4 @@ export default function CustomModelViewer({ object }: { object: TTSObject }) {
);
}
-/**
- * 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 (
-
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
-
-function Model({
- meshUrl,
- diffuseUrl,
- tint,
-}: {
- meshUrl: string;
- diffuseUrl?: string;
- tint: THREE.Color;
-}) {
- const root = useLoader(FlexibleModelLoader, assetUrl(meshUrl));
-
- return (
- <>
-
- {diffuseUrl && }
-
- >
- );
-}
-
-// 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;
-}
\ No newline at end of file
+export { CustomModelMesh } from './CustomModelMesh';
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/TileMesh.tsx b/apps/web/src/components/viewers/TileMesh.tsx
new file mode 100644
index 0000000..0e8eec5
--- /dev/null
+++ b/apps/web/src/components/viewers/TileMesh.tsx
@@ -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 (
+
+ );
+}
+
+// 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 (
+
+ {/* Front face carries the tile texture. */}
+
+ {/* Back face, flipped so it isn't mirrored. */}
+
+ {/* Sides are a solid white, matching TTS tile tinting. */}
+
+
+ );
+}
+
+/** 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);
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx
index 7981395..b72150d 100644
--- a/apps/web/src/components/viewers/TileViewer.tsx
+++ b/apps/web/src/components/viewers/TileViewer.tsx
@@ -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 {
- circleShape,
- extrudeShapeParts,
- hexShape,
- rectShape,
- roundedRectShape,
- scaleShape,
- type ExtrudedGeometry,
-} from '@tts/mesh';
import Scene from './Scene';
-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;
+import { TileObjectMesh } from './TileMesh';
/**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
@@ -48,124 +18,4 @@ export default function TileViewer({ object }: { object: TTSObject }) {
);
}
-/**
- * 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 (
-
- );
-}
-
-// 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 (
-
- {/* Front face carries the tile texture. */}
-
- {/* Back face, flipped so it isn't mirrored. */}
-
- {/* Sides are a solid white, matching TTS tile tinting. */}
-
-
- );
-}
-
-/** 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);
- }
-}
+export { TileObjectMesh, TileMesh } from './TileMesh';
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/TokenMesh.tsx b/apps/web/src/components/viewers/TokenMesh.tsx
new file mode 100644
index 0000000..cec6cc7
--- /dev/null
+++ b/apps/web/src/components/viewers/TokenMesh.tsx
@@ -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 ;
+}
+
+// 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 (
+
+
+
+
+
+ );
+}
+
+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>();
+
+/**
+ * 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;
+}
\ No newline at end of file
diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx
index ef85dcf..04d77f5 100644
--- a/apps/web/src/components/viewers/TokenViewer.tsx
+++ b/apps/web/src/components/viewers/TokenViewer.tsx
@@ -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 {
- circleShape,
- extrudeShapeParts,
- traceToShape,
- traceToUvBounds,
- type ExtrudedGeometry,
-} from '@tts/mesh';
-import { traceImage } from '@tts/http';
import Scene from './Scene';
-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;
+import { TokenObjectMesh } from './TokenMesh';
/**
* 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 }) {
);
}
-/**
- * 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 ;
-}
-
-// 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 (
-
-
-
-
-
- );
-}
-
-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>();
-
-/**
- * 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;
-}
+export { TokenObjectMesh, TokenMesh } from './TokenMesh';
\ No newline at end of file
diff --git a/apps/web/src/pages/FullSetupPage.tsx b/apps/web/src/pages/FullSetupPage.tsx
index ebee470..9775e26 100644
--- a/apps/web/src/pages/FullSetupPage.tsx
+++ b/apps/web/src/pages/FullSetupPage.tsx
@@ -5,10 +5,10 @@ import type { TTSObject } from '@tts/shared';
import { useModStore } from '../stores/modStore';
import { useSearchStore } from '../stores/searchStore';
import Scene from '../components/viewers/Scene';
-import { TileObjectMesh } from '../components/viewers/TileViewer';
-import { TokenObjectMesh } from '../components/viewers/TokenViewer';
-import { CardObjectMesh } from '../components/viewers/CardViewer';
-import { CustomModelMesh } from '../components/viewers/CustomModelViewer';
+import { TileObjectMesh } from '../components/viewers/TileMesh';
+import { TokenObjectMesh } from '../components/viewers/TokenMesh';
+import { CardObjectMesh } from '../components/viewers/CardMesh';
+import { CustomModelMesh } from '../components/viewers/CustomModelMesh';
import { objectPlacement } from '../components/viewers/transform';
/** Object classes that carry renderable assets (mirrors `viewers/register.ts`). */