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/fiber": "^9.7.0",
"@react-three/postprocessing": "^3.0.4", "@react-three/postprocessing": "^3.0.4",
"@tts/extract": "workspace:*", "@tts/extract": "workspace:*",
"@tts/mesh": "workspace:*",
"@tts/shared": "workspace:*", "@tts/shared": "workspace:*",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
+97 -12
View File
@@ -1,34 +1,119 @@
import { useTexture } from '@react-three/drei'; import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared'; import type { TTSObject } from '@tts/shared';
import {
circleShape,
extrudeShapeParts,
hexShape,
rectShape,
roundedRectShape,
scaleShape,
type ExtrudedGeometry,
} from '@tts/mesh';
import Scene from './Scene'; import Scene from './Scene';
import { assetUrl } from './assetUrl'; 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` * A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
* (falling back to `ImageSecondaryURL`), with a neutral color when absent. * (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 }) { export default function TileViewer({ object }: { object: TTSObject }) {
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL; 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 ( return (
<Scene> <Scene>
<TileMesh url={url} thickness={thickness} /> <TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
</Scene> </Scene>
); );
} }
// Rendered inside the Canvas so `useTexture` can access the R3F store. // Rendered inside the Canvas so `useTexture` can access the R3F store.
function TileMesh({ url, thickness }: { url?: string; thickness: number }) { function TileMesh({
const texture = url ? useTexture(assetUrl(url)) : null; 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 ( return (
<mesh rotation={[-Math.PI / 2, 0, 0]}> <group>
<boxGeometry args={[1.6, 1.6, thickness]} /> {/* Top/bottom faces carry the tile texture. */}
<meshStandardMaterial <mesh geometry={caps}>
color={texture ? '#ffffff' : '#52525b'} <meshStandardMaterial
map={texture ?? undefined} color={texture ? '#ffffff' : '#52525b'}
roughness={0.8} map={texture ?? undefined}
/> roughness={0.8}
</mesh> />
</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);
}
}
+32 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { extrudeShape, mergeFaces } from './extrude.js'; import { extrudeShape, extrudeShapeParts, mergeFaces } from './extrude.js';
import { rectShape, hexShape, circleShape, roundedRectShape } from './shapes.js'; import { rectShape, hexShape, circleShape, roundedRectShape, scaleShape } from './shapes.js';
import { capFaces } from './tessellate.js'; import { capFaces } from './tessellate.js';
import { wallFaces } from './walls.js'; import { wallFaces } from './walls.js';
import type { FaceGeometry } from './types.js'; import type { FaceGeometry } from './types.js';
@@ -44,6 +44,36 @@ describe('extrudeShape', () => {
expect(geo.uvs[1]).toBeCloseTo(0, 5); expect(geo.uvs[1]).toBeCloseTo(0, 5);
expect(geo.uvs[4]).toBeCloseTo(2, 5); expect(geo.uvs[4]).toBeCloseTo(2, 5);
}); });
it('produces non-square geometry when scaled by an aspect ratio', () => {
const geo = extrudeShape(scaleShape(rectShape(2, 2), 2, 1), { height: 0.5 });
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (let i = 0; i < geo.positions.length; i += 3) {
minX = Math.min(minX, geo.positions[i]!);
maxX = Math.max(maxX, geo.positions[i]!);
minY = Math.min(minY, geo.positions[i + 1]!);
maxY = Math.max(maxY, geo.positions[i + 1]!);
}
expect(maxX - minX).toBeCloseTo(4, 5); // 2 * aspect 2
expect(maxY - minY).toBeCloseTo(2, 5);
});
});
describe('extrudeShapeParts', () => {
it('returns caps and walls as separate geometries', () => {
const { caps, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
// Caps: 2 faces * 4 outline points = 8 vertices.
expect(caps.positions.length / 3).toBe(8);
// Walls: 4 outline points * 2 vertices = 8 vertices.
expect(walls.positions.length / 3).toBe(8);
// Combined, they match `extrudeShape`.
const combined = extrudeShape(rectShape(2, 2), { height: 1 });
expect(caps.positions.length + walls.positions.length).toBe(combined.positions.length);
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length);
});
}); });
describe('mergeFaces', () => { describe('mergeFaces', () => {
+30 -4
View File
@@ -19,17 +19,43 @@ export interface ExtrudeOptions {
* *
* The top face normal points toward +Z and its UVs span the shape's bounding * The top face normal points toward +Z and its UVs span the shape's bounding
* box, so a texture maps across the whole face. The bottom face is mirrored * box, so a texture maps across the whole face. The bottom face is mirrored
* (normal -Z) with vertically-flipped UVs so the texture isn't upside down. * (normal -Z) with horizontally-flipped UVs so the texture isn't upside down.
*/ */
export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): ExtrudedGeometry { export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): ExtrudedGeometry {
const height = options.height ?? 1; const height = options.height ?? 1;
const capUvScale = options.capUvScale ?? 1; const capUvScale = options.capUvScale ?? 1;
const wallUvScale = options.wallUvScale ?? 1; const wallUvScale = options.wallUvScale ?? 1;
const top = capFaces(shape, height, capUvScale); return mergeFaces([capFaces(shape, height, capUvScale), wallFaces(shape, height, wallUvScale)]);
const walls = wallFaces(shape, height, wallUvScale); }
return mergeFaces([top, walls]); /**
* Extrude a shape, returning the caps (top + bottom faces) and walls as
* separate geometries. This lets callers apply different materials to the
* textured faces versus the sides (e.g. white, tintable walls on a tile).
*/
export function extrudeShapeParts(
shape: Shape,
options: ExtrudeOptions = {},
): { caps: ExtrudedGeometry; walls: ExtrudedGeometry } {
const height = options.height ?? 1;
const capUvScale = options.capUvScale ?? 1;
const wallUvScale = options.wallUvScale ?? 1;
return {
caps: faceToGeometry(capFaces(shape, height, capUvScale)),
walls: faceToGeometry(wallFaces(shape, height, wallUvScale)),
};
}
/** Wrap a single `FaceGeometry` as an `ExtrudedGeometry`. */
function faceToGeometry(f: FaceGeometry): ExtrudedGeometry {
return {
positions: new Float32Array(f.positions),
normals: new Float32Array(f.normals),
uvs: new Float32Array(f.uvs),
indices: new Uint32Array(f.indices),
};
} }
/** Concatenate multiple `FaceGeometry`s into one flat `ExtrudedGeometry`. */ /** Concatenate multiple `FaceGeometry`s into one flat `ExtrudedGeometry`. */
+14
View File
@@ -6,6 +6,7 @@ import {
polygonShape, polygonShape,
rectShape, rectShape,
roundedRectShape, roundedRectShape,
scaleShape,
shapeFromThree, shapeFromThree,
signedArea, signedArea,
} from './shapes.js'; } from './shapes.js';
@@ -67,6 +68,19 @@ describe('shape generators', () => {
expect(signedArea(s.holes![0]!)).toBeLessThan(0); expect(signedArea(s.holes![0]!)).toBeLessThan(0);
}); });
it('scaleShape scales outline and holes', () => {
const s = scaleShape(rectShape(2, 2), 2, 3);
expect(s.outline).toEqual([
[-2, -3],
[2, -3],
[2, 3],
[-2, 3],
]);
const framed = scaleShape(frameShape(4, 4, 2), 2, 2);
expect(framed.holes).toHaveLength(1);
expect(framed.holes![0]![0]).toEqual([-2, -2]);
});
it('shapeFromThree converts three.js vectors', () => { it('shapeFromThree converts three.js vectors', () => {
const fake = { const fake = {
getPoints: () => [ getPoints: () => [
+14
View File
@@ -128,6 +128,20 @@ export function frameShape(
}; };
} }
/**
* Scale a shape's outline and holes by independent x/y factors. Useful for
* stretching a unit shape to a target width/height while preserving its
* proportions along each axis. UV mapping in `capFaces` normalizes by the
* bounding box, so scaling does not distort the texture.
*/
export function scaleShape(shape: Shape, sx: number, sy: number): Shape {
const scale = (pts: number[][]) => pts.map((p) => [p[0]! * sx, p[1]! * sy]);
return {
outline: scale(shape.outline),
holes: shape.holes?.map(scale),
};
}
/** /**
* Compute the signed area of a polygon. Positive means counter-clockwise. * Compute the signed area of a polygon. Positive means counter-clockwise.
* Used to validate winding in tests. * Used to validate winding in tests.
+16
View File
@@ -72,6 +72,22 @@ describe('capFaces', () => {
expect(uvs[5]).toBeCloseTo(1, 5); expect(uvs[5]).toBeCloseTo(1, 5);
}); });
it('bottom face UVs are a left/right flip of the top face', () => {
const face = capFaces(rectShape(2, 2), 1);
const outlineCount = 4;
// Top face: outline point i maps to ((x-minX)/spanX, (y-minY)/spanY).
// Bottom face mirrors U (1 - u) but keeps V, so the texture reads
// correctly from underneath.
for (let i = 0; i < outlineCount; i++) {
const topU = face.uvs[i * 2]!;
const topV = face.uvs[i * 2 + 1]!;
const bottomU = face.uvs[(outlineCount + i) * 2]!;
const bottomV = face.uvs[(outlineCount + i) * 2 + 1]!;
expect(bottomU).toBeCloseTo(1 - topU, 5);
expect(bottomV).toBeCloseTo(topV, 5);
}
});
it('top face triangles are CCW (positive area)', () => { it('top face triangles are CCW (positive area)', () => {
const face = capFaces(rectShape(2, 2), 1); const face = capFaces(rectShape(2, 2), 1);
const topIndices = face.indices.slice(0, 6); const topIndices = face.indices.slice(0, 6);
+3 -3
View File
@@ -75,13 +75,13 @@ export function capFaces(shape: Shape, height: number, uvScale = 1): FaceGeometr
} }
// Bottom face: same outline, flipped so triangles wind CW when viewed from // Bottom face: same outline, flipped so triangles wind CW when viewed from
// below (normal toward -Z), and UVs flipped vertically so the texture is // below (normal toward -Z). UVs are a left/right (horizontal) mirror of the
// not mirrored. // top face so the texture reads correctly from underneath.
const bottomBase = shape.outline.length; const bottomBase = shape.outline.length;
for (let i = 0; i < shape.outline.length; i++) { for (let i = 0; i < shape.outline.length; i++) {
const [x, y] = point(shape.outline, i); const [x, y] = point(shape.outline, i);
positions.push(x, y, 0); positions.push(x, y, 0);
uvs.push(((x - minX) / spanX) * uvScale, (1 - (y - minY) / spanY) * uvScale); uvs.push((1 - (x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
normals.push(0, 0, -1); normals.push(0, 0, -1);
} }
// Reverse winding order for the bottom face. `triangles` is a flat list of // Reverse winding order for the bottom face. `triangles` is a flat list of
+9 -4
View File
@@ -27,12 +27,17 @@ describe('wallFaces', () => {
expect(wall.positions[5]).toBe(3); expect(wall.positions[5]).toBe(3);
}); });
it('maps u across the perimeter and v up the height', () => { it('uses planar UVs from the top face mapping, independent of z', () => {
const wall = wallFaces(rectShape(2, 2), 2); const wall = wallFaces(rectShape(2, 2), 2);
// u starts at 0 for the first outline point. // First outline point is (-1,-1) -> planar UV (0, 0).
expect(wall.uvs[0]).toBeCloseTo(0, 5); expect(wall.uvs[0]).toBeCloseTo(0, 5);
// v goes 0 -> 1 from bottom to top.
expect(wall.uvs[1]).toBeCloseTo(0, 5); expect(wall.uvs[1]).toBeCloseTo(0, 5);
expect(wall.uvs[3]).toBeCloseTo(1, 5); // The bottom and top vertices of the same outline point share a UV,
// so the texture is constant down the wall regardless of z.
expect(wall.uvs[2]).toBeCloseTo(wall.uvs[0]!, 5);
expect(wall.uvs[3]).toBeCloseTo(wall.uvs[1]!, 5);
// Third outline point is (1, 1) -> planar UV (1, 1).
expect(wall.uvs[8]).toBeCloseTo(1, 5);
expect(wall.uvs[9]).toBeCloseTo(1, 5);
}); });
}); });
+26 -19
View File
@@ -10,8 +10,9 @@ function point(ring: number[][], i: number): [number, number] {
/** /**
* Build the side walls of an extruded shape: a quad strip running along the * Build the side walls of an extruded shape: a quad strip running along the
* outline (and any holes) from `z = 0` up to `z = height`. Each quad has an * outline (and any holes) from `z = 0` up to `z = height`. Each quad has an
* outward-facing normal and UVs that map the outline's arc length to u and * outward-facing normal. UVs use the same planar bounding-box mapping as the
* the height to v. * top face (based on x/y only), so the walls inherit the front texture and
* the z position of a vertex does not affect its UV.
*/ */
export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry { export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry {
const positions: number[] = []; const positions: number[] = [];
@@ -19,23 +20,24 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
const normals: number[] = []; const normals: number[] = [];
const indices: number[] = []; const indices: number[] = [];
// Bounding box over the outline, matching `capFaces` so wall UVs line up
// with the top face's texture mapping.
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let i = 0; i < shape.outline.length; i++) {
const [x, y] = point(shape.outline, i);
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
const spanX = maxX - minX || 1;
const spanY = maxY - minY || 1;
const rings = [shape.outline, ...(shape.holes ?? [])]; const rings = [shape.outline, ...(shape.holes ?? [])];
for (const ring of rings) { for (const ring of rings) {
// Cumulative arc length along the ring, used for the u coordinate.
const arc: number[] = [0];
let total = 0;
for (let i = 1; i < ring.length; i++) {
const [x1, y1] = point(ring, i - 1);
const [x2, y2] = point(ring, i);
total += Math.hypot(x2 - x1, y2 - y1);
arc.push(total);
}
// Close the loop.
const [x0, y0] = point(ring, 0);
const [xl, yl] = point(ring, ring.length - 1);
total += Math.hypot(xl - x0, yl - y0);
if (total === 0) continue;
const base = positions.length / 3; const base = positions.length / 3;
for (let i = 0; i < ring.length; i++) { for (let i = 0; i < ring.length; i++) {
const [x, y] = point(ring, i); const [x, y] = point(ring, i);
@@ -49,11 +51,16 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
const nx = ey / len; const nx = ey / len;
const ny = -ex / len; const ny = -ex / len;
// Planar UV from the point's x/y, shared by the bottom and top vertices
// so the texture is constant down the wall (z-independent).
const u = ((x - minX) / spanX) * uvScale;
const v = ((y - minY) / spanY) * uvScale;
// Two vertices per outline point: bottom and top. // Two vertices per outline point: bottom and top.
positions.push(x, y, 0); positions.push(x, y, 0);
positions.push(x, y, height); positions.push(x, y, height);
uvs.push((arc[i]! / total) * uvScale, 0); uvs.push(u, v);
uvs.push((arc[i]! / total) * uvScale, uvScale); uvs.push(u, v);
normals.push(nx, ny, 0); normals.push(nx, ny, 0);
normals.push(nx, ny, 0); normals.push(nx, ny, 0);
} }
+3
View File
@@ -66,6 +66,9 @@ importers:
'@tts/extract': '@tts/extract':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/extract version: link:../../packages/extract
'@tts/mesh':
specifier: workspace:*
version: link:../../packages/mesh
'@tts/shared': '@tts/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared