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
+32 -2
View File
@@ -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', () => {
+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
* 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`. */
+14
View File
@@ -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: () => [
+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.
* Used to validate winding in tests.
+16
View File
@@ -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);
+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
// 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
+9 -4
View File
@@ -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
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
* 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);
}