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; // Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as // `NoColorSpace`, which double-decodes it in the shader and washes out // contrast. Mark it sRGB so the GPU decodes it once, correctly. if (texture) texture.colorSpace = THREE.SRGBColorSpace; // 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; }