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