Lift the trace-to-shape geometry into @tts/mesh (traceToShape/ traceToUvBounds), parameterized by scale so the web token viewer and @tts/tabletop share one implementation. Move the CORS proxy HTTP helpers (assetUrl, resolveAssetUrl, traceImage) into a new @tts/http package, and make @tts/tabletop's ErrorBoundary the single source used by the web app. This removes the duplicated ErrorBoundary, assetUrl, tabletopHttp, and trace-to-shape code from apps/web and packages/tabletop.
188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* A playing card: a thin rounded rect with the face texture on the front and
|
|
* the back texture on the rear. Covers `Card`/`Deck`/`DeckCustom`/`Custom_Deck`
|
|
* (via `CustomDeck` face/back URLs) and `CardCustom` (via `CustomImage`).
|
|
*
|
|
* A deck image is a sheet divided into a `NumWidth` x `NumHeight` grid of
|
|
* sprites. The card footprint is sized to a single sprite's aspect ratio, and
|
|
* the face material uses UV offset/scaling to show the sprite selected by
|
|
* `CardID`. The corners stay circular because the rounded rect is built from
|
|
* the final width/height rather than scaling a square.
|
|
*
|
|
* `CardID` encodes the deck index in the hundreds place and the 1-based card
|
|
* number in the last two digits (e.g. 354 -> deck 3, card 54). The deck config
|
|
* (grid, face/back URLs) is resolved from the containing deck object's
|
|
* `CustomDeck[deckIndex]`, since a card's own `CustomDeck` may be keyed
|
|
* differently or absent.
|
|
*
|
|
* The back is treated like a tile (a single full image) unless the deck has
|
|
* `UniqueBack`, in which case it is a sheet too and gets the same sprite cell.
|
|
* It is flipped left/right so it isn't mirrored when viewed from the back of
|
|
* the card.
|
|
*/
|
|
export default function CardViewer({ object }: { object: TTSObject }) {
|
|
return (
|
|
<Scene>
|
|
<CardObjectMesh object={object} />
|
|
</Scene>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
} |