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; }