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
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
circleShape,
frameShape,
hexShape,
polygonShape,
rectShape,
roundedRectShape,
shapeFromThree,
signedArea,
} from './shapes.js';
describe('shape generators', () => {
it('rectShape is a CCW unit-ish rectangle', () => {
const s = rectShape(2, 4);
expect(s.outline).toEqual([
[-1, -2],
[1, -2],
[1, 2],
[-1, 2],
]);
expect(signedArea(s.outline)).toBeGreaterThan(0);
});
it('polygonShape produces a regular polygon with the right vertex count', () => {
const s = polygonShape(6, 2);
expect(s.outline).toHaveLength(6);
// All vertices on the circle of radius 2.
for (const [x, y] of s.outline) {
expect(Math.hypot(x!, y!)).toBeCloseTo(2, 5);
}
expect(signedArea(s.outline)).toBeGreaterThan(0);
});
it('hexShape is a pointy-top hexagon', () => {
const s = hexShape(1);
expect(s.outline).toHaveLength(6);
// Pointy-top: a vertex sits at the top (angle 90deg, max y).
const top = s.outline.reduce((a, b) => (b[1]! > a[1]! ? b : a));
expect(top[0]).toBeCloseTo(0, 5);
expect(top[1]).toBeCloseTo(1, 5);
});
it('circleShape approximates a circle', () => {
const s = circleShape(3, 64);
expect(s.outline).toHaveLength(64);
for (const [x, y] of s.outline) {
expect(Math.hypot(x!, y!)).toBeCloseTo(3, 5);
}
});
it('roundedRectShape clamps the corner radius', () => {
const s = roundedRectShape(2, 2, 5);
// Radius clamped to half the smaller dimension (1).
const minX = Math.min(...s.outline.map((p) => p[0]!));
const maxX = Math.max(...s.outline.map((p) => p[0]!));
expect(minX).toBeCloseTo(-1, 5);
expect(maxX).toBeCloseTo(1, 5);
expect(s.outline.length).toBe(4 * 4); // 4 corners * 4 segments
});
it('frameShape has a hole wound CW', () => {
const s = frameShape(4, 4, 2);
expect(s.outline).toHaveLength(4);
expect(s.holes).toHaveLength(1);
expect(signedArea(s.outline)).toBeGreaterThan(0);
expect(signedArea(s.holes![0]!)).toBeLessThan(0);
});
it('shapeFromThree converts three.js vectors', () => {
const fake = {
getPoints: () => [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
getPointsHoles: () => [],
};
const s = shapeFromThree(fake);
expect(s.outline).toEqual([
[0, 0],
[1, 0],
[1, 1],
]);
expect(s.holes).toBeUndefined();
});
});