feat(tile): render tiles with the mesh generator

Replace the per-shape tile geometries with @tts/mesh extrusion, honoring
the tile type and stretch aspect ratio. Add extrudeShapeParts so the
caps carry the texture while the walls are solid white, matching TTS
tinting. Fix bottom-face UVs to mirror horizontally and make wall UVs
inherit the front mapping independent of z.
This commit is contained in:
2026-08-08 14:21:53 +08:00
parent 14f83f476c
commit d554d6bde1
11 changed files with 246 additions and 45 deletions
+1
View File
@@ -19,6 +19,7 @@
"@react-three/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4",
"@tts/extract": "workspace:*",
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+98 -13
View File
@@ -1,34 +1,119 @@
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 Scene from './Scene';
import { assetUrl } from './assetUrl';
/** `CustomTile.Type` enum from Tabletop Simulator. */
const TileType = {
Box: 0,
Hex: 1,
Circle: 2,
Rounded: 3,
} as const;
const TILE_SIZE = 2;
/**
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
* (falling back to `ImageSecondaryURL`), with a neutral color when absent.
* The footprint follows `CustomTile.Type` (box, hex, circle, or rounded).
*
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
* source image instead of being forced square.
*/
export default function TileViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.1;
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
return (
<Scene>
<TileMesh url={url} thickness={thickness} />
<TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
</Scene>
);
}
// Rendered inside the Canvas so `useTexture` can access the R3F store.
function TileMesh({ url, thickness }: { url?: string; thickness: number }) {
const texture = url ? useTexture(assetUrl(url)) : null;
function TileMesh({
url,
thickness,
type,
stretch,
}: {
url?: string;
thickness: number;
type: number;
stretch: boolean;
}) {
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
// 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.
const { caps, walls } = useMemo(() => {
const img = texture?.image as HTMLImageElement;
const aspect = stretch ? img.width / img.height : 1;
const shape = tileShape(type, aspect);
const { caps, walls } = extrudeShapeParts(shape, { height: thickness });
return { caps: toGeometry(caps), walls: toGeometry(walls) };
}, [type, thickness, stretch, texture]);
return (
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<boxGeometry args={[1.6, 1.6, thickness]} />
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
<group>
{/* Top/bottom faces carry the tile texture. */}
<mesh geometry={caps}>
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
{/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls}>
<meshStandardMaterial color="#ffffff" roughness={0.8} />
</mesh>
</group>
);
}
}
/** 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);
}
}