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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user