import { describe, expect, it } from 'vitest'; import { extrudeShape, extrudeShapeParts, mergeFaces } from './extrude.js'; import { rectShape, hexShape, circleShape, roundedRectShape, scaleShape } 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); }); it('produces non-square geometry when scaled by an aspect ratio', () => { const geo = extrudeShape(scaleShape(rectShape(2, 2), 2, 1), { height: 0.5 }); let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (let i = 0; i < geo.positions.length; i += 3) { minX = Math.min(minX, geo.positions[i]!); maxX = Math.max(maxX, geo.positions[i]!); minY = Math.min(minY, geo.positions[i + 1]!); maxY = Math.max(maxY, geo.positions[i + 1]!); } expect(maxX - minX).toBeCloseTo(4, 5); // 2 * aspect 2 expect(maxY - minY).toBeCloseTo(2, 5); }); }); describe('extrudeShapeParts', () => { it('returns front, back, and walls as separate geometries', () => { const { front, back, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 }); // Each face: 4 outline points. expect(front.positions.length / 3).toBe(4); expect(back.positions.length / 3).toBe(4); // Walls: 4 outline points * 2 vertices = 8 vertices. expect(walls.positions.length / 3).toBe(8); // Combined, they match `extrudeShape`. const combined = extrudeShape(rectShape(2, 2), { height: 1 }); expect(front.positions.length + back.positions.length + walls.positions.length).toBe( combined.positions.length, ); expect(front.indices.length + back.indices.length + walls.indices.length).toBe( combined.indices.length, ); }); it('front faces +Z and back faces -Z', () => { const { front, back } = extrudeShapeParts(rectShape(2, 2), { height: 1 }); expect(Array.from(front.normals.slice(0, 3))).toEqual([0, 0, 1]); expect(Array.from(back.normals.slice(0, 3))).toEqual([0, 0, -1]); }); it('applies uvBounds to front, back, and walls', () => { const uvBounds = { minX: 0, minY: 0, maxX: 4, maxY: 4 }; const { front, back, walls } = extrudeShapeParts(rectShape(1, 1), { height: 1, uvBounds, }); // Front: bottom-left vertex at (-0.5,-0.5) -> u=-0.125. expect(front.uvs[0]).toBeCloseTo(-0.125, 5); // Back uses the same planar xy mapping (no mirror). expect(back.uvs[0]).toBeCloseTo(-0.125, 5); // Walls: first outline point (-0.5,-0.5) -> u=-0.125, v=-0.125. expect(walls.uvs[0]).toBeCloseTo(-0.125, 5); expect(walls.uvs[1]).toBeCloseTo(-0.125, 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; }