From d554d6bde1bcbc9f1e4aa7eb3efb21e65c1814f4 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 14:21:53 +0800 Subject: [PATCH] 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. --- apps/web/package.json | 1 + .../web/src/components/viewers/TileViewer.tsx | 111 ++++++++++++++++-- packages/mesh/src/extrude.test.ts | 34 +++++- packages/mesh/src/extrude.ts | 34 +++++- packages/mesh/src/shapes.test.ts | 14 +++ packages/mesh/src/shapes.ts | 14 +++ packages/mesh/src/tessellate.test.ts | 16 +++ packages/mesh/src/tessellate.ts | 6 +- packages/mesh/src/walls.test.ts | 13 +- packages/mesh/src/walls.ts | 45 ++++--- pnpm-lock.yaml | 3 + 11 files changed, 246 insertions(+), 45 deletions(-) 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