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;
}