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
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { extrudeShape, mergeFaces } from './extrude.js';
import { rectShape, hexShape, circleShape, roundedRectShape } from './shapes.js';
import { capFaces } from './tessellate.js';
import { wallFaces } from './walls.js';
import type { FaceGeometry } from './types.js';
describe('extrudeShape', () => {
it('produces a closed, watertight box for a rectangle', () => {
const geo = extrudeShape(rectShape(2, 2), { height: 1 });
// 4 outline points: 2 caps * 4 + 4 walls * 2 = 16 vertices.
expect(geo.positions.length / 3).toBe(16);
expect(geo.normals.length / 3).toBe(16);
expect(geo.uvs.length / 2).toBe(16);
// 2 cap triangles * 2 + 4 wall quads * 2 = 12 triangles.
expect(geo.indices.length).toBe(12 * 3);
});
it('every triangle is non-degenerate (positive area in 3D)', () => {
const geo = extrudeShape(hexShape(1), { height: 0.5 });
for (let i = 0; i < geo.indices.length; i += 3) {
const a = geo.indices[i]! * 3;
const b = geo.indices[i + 1]! * 3;
const c = geo.indices[i + 2]! * 3;
const area = triangleArea(geo.positions, a, b, c);
expect(area).toBeGreaterThan(0);
}
});
it('extrudes circles and rounded rects', () => {
const circle = extrudeShape(circleShape(1, 32), { height: 0.2 });
expect(circle.positions.length / 3).toBe(32 * 2 + 32 * 2);
expect(circle.indices.length).toBeGreaterThan(0);
const rounded = extrudeShape(roundedRectShape(2, 2, 0.3, 4), { height: 0.2 });
expect(rounded.positions.length / 3).toBe(16 * 2 + 16 * 2);
expect(rounded.indices.length).toBeGreaterThan(0);
});
it('applies uv scales', () => {
const geo = extrudeShape(rectShape(2, 2), { height: 1, capUvScale: 2 });
// Top face bottom-left UV should be scaled by 2.
expect(geo.uvs[0]).toBeCloseTo(0, 5);
expect(geo.uvs[1]).toBeCloseTo(0, 5);
expect(geo.uvs[4]).toBeCloseTo(2, 5);
});
});
describe('mergeFaces', () => {
it('concatenates faces and rebases indices', () => {
const a: FaceGeometry = {
positions: [0, 0, 0, 1, 0, 0],
uvs: [0, 0, 1, 0],
normals: [0, 0, 1, 0, 0, 1],
indices: [0, 1, 0],
};
const b: FaceGeometry = {
positions: [2, 0, 0, 3, 0, 0],
uvs: [0, 0, 1, 0],
normals: [0, 0, 1, 0, 0, 1],
indices: [0, 1, 0],
};
const merged = mergeFaces([a, b]);
expect(merged.positions.length / 3).toBe(4);
// Second face's indices rebased by 2.
expect(merged.indices).toEqual(new Uint32Array([0, 1, 0, 2, 3, 2]));
});
});
function triangleArea(
positions: Float32Array,
a: number,
b: number,
c: number,
): number {
const ax = positions[a]!;
const ay = positions[a + 1]!;
const az = positions[a + 2]!;
const bx = positions[b]!;
const by = positions[b + 1]!;
const bz = positions[b + 2]!;
const cx = positions[c]!;
const cy = positions[c + 1]!;
const cz = positions[c + 2]!;
const abx = bx - ax;
const aby = by - ay;
const abz = bz - az;
const acx = cx - ax;
const acy = cy - ay;
const acz = cz - az;
const crossX = aby * acz - abz * acy;
const crossY = abz * acx - abx * acz;
const crossZ = abx * acy - aby * acx;
return Math.hypot(crossX, crossY, crossZ) / 2;
}