feat(mesh): add tile mesh generation package

Add @tts/mesh, a package for generating extruded tile meshes from 2D
shapes. It provides shape generators (rect, hex, circle, rounded rect,
frame), earcut-based tessellation of top/bottom faces with UVs, and
outward-normal wall generation, combined into raw BufferGeometry-ready
typed arrays.
This commit is contained in:
2026-08-08 13:42:32 +08:00
parent 3b86a641aa
commit 14f83f476c
13 changed files with 782 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { triangulate, capFaces } from './tessellate.js';
import { frameShape, rectShape, roundedRectShape, signedArea } from './shapes.js';
/** Signed area of a triangle fan, used to sanity-check winding. */
function fanArea(positions: number[], indices: number[]): number {
let area = 0;
for (let i = 0; i < indices.length; i += 3) {
const a = indices[i]! * 3;
const b = indices[i + 1]! * 3;
const c = indices[i + 2]! * 3;
area +=
(positions[a]! * (positions[b + 1]! - positions[c + 1]!) +
positions[b]! * (positions[c + 1]! - positions[a + 1]!) +
positions[c]! * (positions[a + 1]! - positions[b + 1]!)) /
2;
}
return area;
}
describe('triangulate', () => {
it('triangulates a rectangle into two triangles', () => {
const indices = triangulate(rectShape(2, 2));
expect(indices).toHaveLength(6); // 2 triangles
});
it('triangulates a hexagon', () => {
const indices = triangulate({ outline: hexOutline() });
// n - 2 triangles for a simple polygon.
expect(indices).toHaveLength((6 - 2) * 3);
});
it('triangulates a rounded rect', () => {
const indices = triangulate(roundedRectShape(2, 2, 0.3, 4));
// 16 outline points -> 14 triangles.
expect(indices).toHaveLength((16 - 2) * 3);
});
it('triangulates a shape with a hole', () => {
const indices = triangulate(frameShape(4, 4, 2));
// 4 outer + 4 hole vertices -> 8 triangles (earcut's result).
expect(indices).toHaveLength(8 * 3);
});
});
describe('capFaces', () => {
it('builds top and bottom faces with correct winding', () => {
const face = capFaces(rectShape(2, 2), 1);
const outlineCount = 4;
expect(face.positions.length / 3).toBe(outlineCount * 2);
expect(face.uvs.length / 2).toBe(outlineCount * 2);
expect(face.normals.length / 3).toBe(outlineCount * 2);
// Top face normal +Z, bottom face normal -Z.
const topNormal = face.normals.slice(0, 3);
const bottomNormal = face.normals.slice(outlineCount * 3, outlineCount * 3 + 3);
expect(topNormal).toEqual([0, 0, 1]);
expect(bottomNormal).toEqual([0, 0, -1]);
// Top face at z=1, bottom at z=0.
expect(face.positions[2]).toBe(1);
expect(face.positions[outlineCount * 3 + 2]).toBe(0);
});
it('maps UVs across the bounding box', () => {
const face = capFaces(rectShape(2, 2), 1);
// Bottom-left outline vertex maps to (0,0), top-right to (1,1).
const uvs = face.uvs;
expect(uvs[0]).toBeCloseTo(0, 5);
expect(uvs[1]).toBeCloseTo(0, 5);
expect(uvs[4]).toBeCloseTo(1, 5);
expect(uvs[5]).toBeCloseTo(1, 5);
});
it('top face triangles are CCW (positive area)', () => {
const face = capFaces(rectShape(2, 2), 1);
const topIndices = face.indices.slice(0, 6);
expect(fanArea(face.positions, topIndices)).toBeGreaterThan(0);
});
});
function hexOutline(): number[][] {
const pts: number[][] = [];
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
pts.push([Math.cos(a), Math.sin(a)]);
}
return pts;
}