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 ( ); } /** * 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; }