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.
This commit is contained in:
2026-08-08 17:41:59 +08:00
parent f7139495fe
commit 835250abdd
12 changed files with 633 additions and 80 deletions
+25 -10
View File
@@ -62,27 +62,42 @@ describe('extrudeShape', () => {
});
});
describe('extrudeShapeParts', () => {
it('returns caps and walls as separate geometries', () => {
const { caps, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
// Caps: 2 faces * 4 outline points = 8 vertices.
expect(caps.positions.length / 3).toBe(8);
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(caps.positions.length + walls.positions.length).toBe(combined.positions.length);
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length);
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('applies uvBounds to caps and walls', () => {
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 { caps, walls } = extrudeShapeParts(rectShape(1, 1), {
const { front, back, walls } = extrudeShapeParts(rectShape(1, 1), {
height: 1,
uvBounds,
});
// Caps: bottom-left vertex at (-0.5,-0.5) -> u=-0.125.
expect(caps.uvs[0]).toBeCloseTo(-0.125, 5);
// 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);
+7 -6
View File
@@ -1,6 +1,6 @@
import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js';
import type { Shape } from './shapes.js';
import { capFaces } from './tessellate.js';
import { backFaces, capFaces, frontFaces } from './tessellate.js';
import { wallFaces } from './walls.js';
export interface ExtrudeOptions {
@@ -37,21 +37,22 @@ export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): Extrud
}
/**
* Extrude a shape, returning the caps (top + bottom faces) and walls as
* separate geometries. This lets callers apply different materials to the
* textured faces versus the sides (e.g. white, tintable walls on a tile).
* Extrude a shape, returning the front face, back face, and walls as separate
* geometries. This lets callers apply different materials to the textured
* faces versus the sides (e.g. white, tintable walls on a tile).
*/
export function extrudeShapeParts(
shape: Shape,
options: ExtrudeOptions = {},
): { caps: ExtrudedGeometry; walls: ExtrudedGeometry } {
): { front: ExtrudedGeometry; back: ExtrudedGeometry; walls: ExtrudedGeometry } {
const height = options.height ?? 1;
const capUvScale = options.capUvScale ?? 1;
const wallUvScale = options.wallUvScale ?? 1;
const uvBounds = options.uvBounds;
return {
caps: faceToGeometry(capFaces(shape, height, capUvScale, uvBounds)),
front: faceToGeometry(frontFaces(shape, height, capUvScale, uvBounds)),
back: faceToGeometry(backFaces(shape, height, capUvScale, uvBounds)),
walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)),
};
}
+61 -29
View File
@@ -32,7 +32,7 @@ export function triangulate(shape: Shape): number[] {
}
/**
* Build the top and bottom faces of an extruded shape.
* Build a single cap (top or bottom face) of an extruded shape.
*
* The top face lies in the XY plane at `z = height` with its normal toward
* +Z; the bottom face lies at `z = 0` with its normal toward -Z. UVs map the
@@ -43,11 +43,14 @@ export function triangulate(shape: Shape): number[] {
* the shape's bounding box, so the texture aligns to a larger framing (e.g. a
* traced silhouette inside a transparent image canvas).
*/
export function capFaces(
function buildCap(
shape: Shape,
height: number,
uvScale = 1,
uvBounds?: UVBounds,
uvScale: number,
uvBounds: UVBounds | undefined,
z: number,
normalZ: number,
reverse: boolean,
): FaceGeometry {
const triangles = triangulate(shape);
@@ -67,41 +70,70 @@ export function capFaces(
const normals: number[] = [];
const indices: number[] = [];
// Top face.
const topBase = 0;
for (let i = 0; i < shape.outline.length; i++) {
const [x, y] = point(shape.outline, i);
positions.push(x, y, height);
positions.push(x, y, z);
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
normals.push(0, 0, 1);
}
for (const t of triangles) {
indices.push(topBase + t);
normals.push(0, 0, normalZ);
}
// Bottom face: same outline, flipped so triangles wind CW when viewed from
// below (normal toward -Z). UVs use the same planar xy mapping as the top
// face (no mirror), so the texture is consistent across front, back, and
// walls regardless of z.
const bottomBase = shape.outline.length;
for (let i = 0; i < shape.outline.length; i++) {
const [x, y] = point(shape.outline, i);
positions.push(x, y, 0);
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
normals.push(0, 0, -1);
}
// Reverse winding order for the bottom face. `triangles` is a flat list of
// vertex indices in groups of 3, so step by 3.
for (let i = 0; i < triangles.length; i += 3) {
const t0 = triangles[i]!;
const t1 = triangles[i + 1]!;
const t2 = triangles[i + 2]!;
indices.push(bottomBase + t2, bottomBase + t1, bottomBase + t0);
// The bottom face reverses winding so triangles wind CW when viewed from
// below (normal toward -Z). `triangles` is a flat list of vertex indices in
// groups of 3, so step by 3.
if (reverse) {
for (let i = 0; i < triangles.length; i += 3) {
indices.push(triangles[i + 2]!, triangles[i + 1]!, triangles[i]!);
}
} else {
for (const t of triangles) {
indices.push(t);
}
}
return { positions, uvs, normals, indices };
}
/** The top face of an extruded shape, normal toward +Z. */
export function frontFaces(
shape: Shape,
height: number,
uvScale = 1,
uvBounds?: UVBounds,
): FaceGeometry {
return buildCap(shape, height, uvScale, uvBounds, height, 1, false);
}
/** The bottom face of an extruded shape, normal toward -Z. */
export function backFaces(
shape: Shape,
height: number,
uvScale = 1,
uvBounds?: UVBounds,
): FaceGeometry {
return buildCap(shape, height, uvScale, uvBounds, 0, -1, true);
}
/**
* Build the top and bottom faces of an extruded shape, merged into one
* `FaceGeometry` (front vertices first, then back).
*/
export function capFaces(
shape: Shape,
height: number,
uvScale = 1,
uvBounds?: UVBounds,
): FaceGeometry {
const front = frontFaces(shape, height, uvScale, uvBounds);
const back = backFaces(shape, height, uvScale, uvBounds);
const frontCount = front.positions.length / 3;
return {
positions: [...front.positions, ...back.positions],
uvs: [...front.uvs, ...back.uvs],
normals: [...front.normals, ...back.normals],
indices: [...front.indices, ...back.indices.map((i) => i + frontCount)],
};
}
/** Compute the bounding box of a shape's outline. */
function shapeBounds(shape: Shape): {
minX: number;