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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user