Files
tts-workshop/packages/mesh/src/extrude.test.ts
T
hypercross 835250abdd feat(mesh): render cards, tiles, and tokens with separated front/back/walls
Split extrudeShapeParts into front, back, and walls so each face can carry
its own material. Flip back textures left/right on the material so they are
not mirrored, and slice card faces from the deck sprite sheet via CardID.
Resolve deck config from the parent deck, which is authoritative over a
card's own CustomDeck. Add unit tests for card resolution, sprite UVs, and
the flip helper.
2026-08-08 17:41:59 +08:00

153 lines
5.5 KiB
TypeScript

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