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