refactor(web): code-split the three.js stack out of the main bundle
This commit is contained in:
+31
-18
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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`). */
|
||||||
|
|||||||
Reference in New Issue
Block a user