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