diff --git a/apps/web/package.json b/apps/web/package.json
index 6da4f1f..bc886ce 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx
index 5bb4191..dd6ea5d 100644
--- a/apps/web/src/components/viewers/TileViewer.tsx
+++ b/apps/web/src/components/viewers/TileViewer.tsx
@@ -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 (
-
+
);
}
// 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 (
-
-
-
-
+
+ {/* Top/bottom faces carry the tile texture. */}
+
+
+
+ {/* Sides are a solid white, matching TTS tile tinting. */}
+
+
+
+
);
-}
\ No newline at end of file
+}
+
+/** 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);
+ }
+}
diff --git a/packages/mesh/src/extrude.test.ts b/packages/mesh/src/extrude.test.ts
index d5f2d72..21449a8 100644
--- a/packages/mesh/src/extrude.test.ts
+++ b/packages/mesh/src/extrude.test.ts
@@ -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', () => {
diff --git a/packages/mesh/src/extrude.ts b/packages/mesh/src/extrude.ts
index f1af11f..26ad7a7 100644
--- a/packages/mesh/src/extrude.ts
+++ b/packages/mesh/src/extrude.ts
@@ -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`. */
diff --git a/packages/mesh/src/shapes.test.ts b/packages/mesh/src/shapes.test.ts
index 01fce1a..35b1af0 100644
--- a/packages/mesh/src/shapes.test.ts
+++ b/packages/mesh/src/shapes.test.ts
@@ -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: () => [
diff --git a/packages/mesh/src/shapes.ts b/packages/mesh/src/shapes.ts
index d6a3a2e..610d4be 100644
--- a/packages/mesh/src/shapes.ts
+++ b/packages/mesh/src/shapes.ts
@@ -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.
diff --git a/packages/mesh/src/tessellate.test.ts b/packages/mesh/src/tessellate.test.ts
index e3b24f8..4dc7c32 100644
--- a/packages/mesh/src/tessellate.test.ts
+++ b/packages/mesh/src/tessellate.test.ts
@@ -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);
diff --git a/packages/mesh/src/tessellate.ts b/packages/mesh/src/tessellate.ts
index 14e812b..5f0ff51 100644
--- a/packages/mesh/src/tessellate.ts
+++ b/packages/mesh/src/tessellate.ts
@@ -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
diff --git a/packages/mesh/src/walls.test.ts b/packages/mesh/src/walls.test.ts
index 4e555f6..eba6635 100644
--- a/packages/mesh/src/walls.test.ts
+++ b/packages/mesh/src/walls.test.ts
@@ -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);
});
});
\ No newline at end of file
diff --git a/packages/mesh/src/walls.ts b/packages/mesh/src/walls.ts
index 43f4351..dd9e18c 100644
--- a/packages/mesh/src/walls.ts
+++ b/packages/mesh/src/walls.ts
@@ -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);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ded6f18..8f43adb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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