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`). */