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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extrudeShape, mergeFaces } from './extrude.js';
|
||||
import { rectShape, hexShape, circleShape, roundedRectShape } from './shapes.js';
|
||||
import { extrudeShape, extrudeShapeParts, mergeFaces } from './extrude.js';
|
||||
import { rectShape, hexShape, circleShape, roundedRectShape, scaleShape } from './shapes.js';
|
||||
import { capFaces } from './tessellate.js';
|
||||
import { wallFaces } from './walls.js';
|
||||
import type { FaceGeometry } from './types.js';
|
||||
@@ -44,6 +44,36 @@ describe('extrudeShape', () => {
|
||||
expect(geo.uvs[1]).toBeCloseTo(0, 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', () => {
|
||||
|
||||
@@ -19,17 +19,43 @@ export interface ExtrudeOptions {
|
||||
*
|
||||
* 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
|
||||
* (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 {
|
||||
const height = options.height ?? 1;
|
||||
const capUvScale = options.capUvScale ?? 1;
|
||||
const wallUvScale = options.wallUvScale ?? 1;
|
||||
|
||||
const top = capFaces(shape, height, capUvScale);
|
||||
const walls = wallFaces(shape, height, wallUvScale);
|
||||
return mergeFaces([capFaces(shape, height, capUvScale), 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`. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
polygonShape,
|
||||
rectShape,
|
||||
roundedRectShape,
|
||||
scaleShape,
|
||||
shapeFromThree,
|
||||
signedArea,
|
||||
} from './shapes.js';
|
||||
@@ -67,6 +68,19 @@ describe('shape generators', () => {
|
||||
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', () => {
|
||||
const fake = {
|
||||
getPoints: () => [
|
||||
|
||||
@@ -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.
|
||||
* Used to validate winding in tests.
|
||||
|
||||
@@ -72,6 +72,22 @@ describe('capFaces', () => {
|
||||
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)', () => {
|
||||
const face = capFaces(rectShape(2, 2), 1);
|
||||
const topIndices = face.indices.slice(0, 6);
|
||||
|
||||
@@ -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
|
||||
// below (normal toward -Z), and UVs flipped vertically so the texture is
|
||||
// not mirrored.
|
||||
// below (normal toward -Z). UVs are a left/right (horizontal) mirror of the
|
||||
// top face so the texture reads correctly from underneath.
|
||||
const bottomBase = shape.outline.length;
|
||||
for (let i = 0; i < shape.outline.length; i++) {
|
||||
const [x, y] = point(shape.outline, i);
|
||||
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);
|
||||
}
|
||||
// Reverse winding order for the bottom face. `triangles` is a flat list of
|
||||
|
||||
@@ -27,12 +27,17 @@ describe('wallFaces', () => {
|
||||
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);
|
||||
// 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);
|
||||
// v goes 0 -> 1 from bottom to top.
|
||||
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
@@ -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
|
||||
* 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
|
||||
* the height to v.
|
||||
* outward-facing normal. UVs use the same planar bounding-box mapping as the
|
||||
* 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 {
|
||||
const positions: number[] = [];
|
||||
@@ -19,23 +20,24 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
|
||||
const normals: 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 ?? [])];
|
||||
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;
|
||||
for (let i = 0; i < ring.length; 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 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.
|
||||
positions.push(x, y, 0);
|
||||
positions.push(x, y, height);
|
||||
uvs.push((arc[i]! / total) * uvScale, 0);
|
||||
uvs.push((arc[i]! / total) * uvScale, uvScale);
|
||||
uvs.push(u, v);
|
||||
uvs.push(u, v);
|
||||
normals.push(nx, ny, 0);
|
||||
normals.push(nx, ny, 0);
|
||||
}
|
||||
|
||||
Generated
+3
@@ -66,6 +66,9 @@ importers:
|
||||
'@tts/extract':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/extract
|
||||
'@tts/mesh':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/mesh
|
||||
'@tts/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
|
||||
Reference in New Issue
Block a user