Files
tts-workshop/packages/mesh/src/tessellate.test.ts
T
hypercross 21fc1b29c1 feat(mesh): support uvBounds and planar back-face UVs
Add an optional uvBounds to extrudeShape/extrudeShapeParts so UVs can be derived from a framing rectangle instead of the shape's bounding box, letting a traced silhouette align with its full image. The bottom face now uses the same planar xy UVs as the top face (no horizontal mirror), so the texture is consistent across front, back, and walls.
2026-08-08 15:12:07 +08:00

123 lines
4.4 KiB
TypeScript

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('bottom face UVs match the top face (planar xy, no mirror)', () => {
const face = capFaces(rectShape(2, 2), 1);
const outlineCount = 4;
// Top face: outline point i maps to ((x-minX)/spanX, (y-minY)/spanY).
// Bottom face uses the same planar xy mapping (no horizontal mirror), so
// the texture is consistent across front, back, and walls.
for (let i = 0; i < outlineCount; i++) {
const topU = face.uvs[i * 2]!;
const topV = face.uvs[i * 2 + 1]!;
const bottomU = face.uvs[(outlineCount + i) * 2]!;
const bottomV = face.uvs[(outlineCount + i) * 2 + 1]!;
expect(bottomU).toBeCloseTo(topU, 5);
expect(bottomV).toBeCloseTo(topV, 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);
});
it('derives UVs from uvBounds instead of the shape bbox', () => {
// A 1x1 shape centered at origin inside a 4x4 framing (0..4): UVs should
// span -0.125..0.125, not 0..1.
const face = capFaces(rectShape(1, 1), 1, 1, {
minX: 0,
minY: 0,
maxX: 4,
maxY: 4,
});
const outlineCount = 4;
// Bottom-left vertex at (-0.5,-0.5) -> u=(0.5-0)/4=-0.125, v=-0.125.
expect(face.uvs[0]).toBeCloseTo(-0.125, 5);
expect(face.uvs[1]).toBeCloseTo(-0.125, 5);
// Top-left vertex at (-0.5,0.5) -> u=-0.125, v=0.125.
expect(face.uvs[(outlineCount - 1) * 2]).toBeCloseTo(-0.125, 5);
expect(face.uvs[(outlineCount - 1) * 2 + 1]).toBeCloseTo(0.125, 5);
});
});
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;
}