feat(mesh): add tile mesh generation package
Add @tts/mesh, a package for generating extruded tile meshes from 2D shapes. It provides shape generators (rect, hex, circle, rounded rect, frame), earcut-based tessellation of top/bottom faces with UVs, and outward-normal wall generation, combined into raw BufferGeometry-ready typed arrays.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@tts/mesh",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "echo \"no lint configured\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"earcut": "^3.2.3",
|
||||||
|
"three": "^0.185.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/earcut": "^3.0.0",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extrudeShape, mergeFaces } from './extrude.js';
|
||||||
|
import { rectShape, hexShape, circleShape, roundedRectShape } 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ExtrudedGeometry, FaceGeometry } from './types.js';
|
||||||
|
import type { Shape } from './shapes.js';
|
||||||
|
import { capFaces } from './tessellate.js';
|
||||||
|
import { wallFaces } from './walls.js';
|
||||||
|
|
||||||
|
export interface ExtrudeOptions {
|
||||||
|
/** Height of the extrusion along Z. Defaults to 1. */
|
||||||
|
height?: number;
|
||||||
|
/** UV scale for the top/bottom faces. Values > 1 repeat the texture. */
|
||||||
|
capUvScale?: number;
|
||||||
|
/** UV scale for the walls. Values > 1 repeat the texture. */
|
||||||
|
wallUvScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrude a 2D `Shape` into a closed 3D mesh: a top face at `z = height`, a
|
||||||
|
* bottom face at `z = 0`, and side walls connecting them. The result is
|
||||||
|
* returned as raw typed arrays suitable for a three.js `BufferGeometry`.
|
||||||
|
*
|
||||||
|
* The top face normal points toward +Z and its UVs span the shape's bounding
|
||||||
|
* box, so a texture maps across the whole face. The bottom face is mirrored
|
||||||
|
* (normal -Z) with vertically-flipped UVs so the texture isn't upside down.
|
||||||
|
*/
|
||||||
|
export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): ExtrudedGeometry {
|
||||||
|
const height = options.height ?? 1;
|
||||||
|
const capUvScale = options.capUvScale ?? 1;
|
||||||
|
const wallUvScale = options.wallUvScale ?? 1;
|
||||||
|
|
||||||
|
const top = capFaces(shape, height, capUvScale);
|
||||||
|
const walls = wallFaces(shape, height, wallUvScale);
|
||||||
|
|
||||||
|
return mergeFaces([top, walls]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concatenate multiple `FaceGeometry`s into one flat `ExtrudedGeometry`. */
|
||||||
|
export function mergeFaces(faces: FaceGeometry[]): ExtrudedGeometry {
|
||||||
|
let vertexCount = 0;
|
||||||
|
let indexCount = 0;
|
||||||
|
for (const f of faces) {
|
||||||
|
vertexCount += f.positions.length / 3;
|
||||||
|
indexCount += f.indices.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Float32Array(vertexCount * 3);
|
||||||
|
const normals = new Float32Array(vertexCount * 3);
|
||||||
|
const uvs = new Float32Array(vertexCount * 2);
|
||||||
|
const indices = new Uint32Array(indexCount);
|
||||||
|
|
||||||
|
let v = 0;
|
||||||
|
let idx = 0;
|
||||||
|
for (const f of faces) {
|
||||||
|
positions.set(f.positions, v * 3);
|
||||||
|
normals.set(f.normals, v * 3);
|
||||||
|
uvs.set(f.uvs, v * 2);
|
||||||
|
for (const i of f.indices) {
|
||||||
|
indices[idx++] = i + v;
|
||||||
|
}
|
||||||
|
v += f.positions.length / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, normals, uvs, indices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export * from './shapes.js';
|
||||||
|
export * from './tessellate.js';
|
||||||
|
export * from './walls.js';
|
||||||
|
export * from './extrude.js';
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
frameShape,
|
||||||
|
hexShape,
|
||||||
|
polygonShape,
|
||||||
|
rectShape,
|
||||||
|
roundedRectShape,
|
||||||
|
shapeFromThree,
|
||||||
|
signedArea,
|
||||||
|
} from './shapes.js';
|
||||||
|
|
||||||
|
describe('shape generators', () => {
|
||||||
|
it('rectShape is a CCW unit-ish rectangle', () => {
|
||||||
|
const s = rectShape(2, 4);
|
||||||
|
expect(s.outline).toEqual([
|
||||||
|
[-1, -2],
|
||||||
|
[1, -2],
|
||||||
|
[1, 2],
|
||||||
|
[-1, 2],
|
||||||
|
]);
|
||||||
|
expect(signedArea(s.outline)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polygonShape produces a regular polygon with the right vertex count', () => {
|
||||||
|
const s = polygonShape(6, 2);
|
||||||
|
expect(s.outline).toHaveLength(6);
|
||||||
|
// All vertices on the circle of radius 2.
|
||||||
|
for (const [x, y] of s.outline) {
|
||||||
|
expect(Math.hypot(x!, y!)).toBeCloseTo(2, 5);
|
||||||
|
}
|
||||||
|
expect(signedArea(s.outline)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hexShape is a pointy-top hexagon', () => {
|
||||||
|
const s = hexShape(1);
|
||||||
|
expect(s.outline).toHaveLength(6);
|
||||||
|
// Pointy-top: a vertex sits at the top (angle 90deg, max y).
|
||||||
|
const top = s.outline.reduce((a, b) => (b[1]! > a[1]! ? b : a));
|
||||||
|
expect(top[0]).toBeCloseTo(0, 5);
|
||||||
|
expect(top[1]).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('circleShape approximates a circle', () => {
|
||||||
|
const s = circleShape(3, 64);
|
||||||
|
expect(s.outline).toHaveLength(64);
|
||||||
|
for (const [x, y] of s.outline) {
|
||||||
|
expect(Math.hypot(x!, y!)).toBeCloseTo(3, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundedRectShape clamps the corner radius', () => {
|
||||||
|
const s = roundedRectShape(2, 2, 5);
|
||||||
|
// Radius clamped to half the smaller dimension (1).
|
||||||
|
const minX = Math.min(...s.outline.map((p) => p[0]!));
|
||||||
|
const maxX = Math.max(...s.outline.map((p) => p[0]!));
|
||||||
|
expect(minX).toBeCloseTo(-1, 5);
|
||||||
|
expect(maxX).toBeCloseTo(1, 5);
|
||||||
|
expect(s.outline.length).toBe(4 * 4); // 4 corners * 4 segments
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frameShape has a hole wound CW', () => {
|
||||||
|
const s = frameShape(4, 4, 2);
|
||||||
|
expect(s.outline).toHaveLength(4);
|
||||||
|
expect(s.holes).toHaveLength(1);
|
||||||
|
expect(signedArea(s.outline)).toBeGreaterThan(0);
|
||||||
|
expect(signedArea(s.holes![0]!)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shapeFromThree converts three.js vectors', () => {
|
||||||
|
const fake = {
|
||||||
|
getPoints: () => [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
getPointsHoles: () => [],
|
||||||
|
};
|
||||||
|
const s = shapeFromThree(fake);
|
||||||
|
expect(s.outline).toEqual([
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[1, 1],
|
||||||
|
]);
|
||||||
|
expect(s.holes).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type { FaceGeometry } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closed 2D polygon described by its outline. The outline must be wound
|
||||||
|
* counter-clockwise (CCW) in the XY plane so that the resulting top face
|
||||||
|
* normal points toward +Z. `Shape` is the minimal interface the tessellator
|
||||||
|
* and wall generator need; the shape generators below produce these.
|
||||||
|
*/
|
||||||
|
export interface Shape {
|
||||||
|
/** Outline vertices in order, each `[x, y]`. */
|
||||||
|
outline: number[][];
|
||||||
|
/** Optional holes, each a list of `[x, y]` wound clockwise (CW). */
|
||||||
|
holes?: number[][][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `Shape` from a three.js `THREE.Shape`. The shape's `getPoints` and
|
||||||
|
* `getPointsHoles` methods return `Vector2` arrays, which we convert to plain
|
||||||
|
* `[x, y]` pairs. This lets callers author shapes with the familiar three.js
|
||||||
|
* path API while our tessellator stays dependency-light.
|
||||||
|
*/
|
||||||
|
export function shapeFromThree(shape: {
|
||||||
|
getPoints(divisions?: number): { x: number; y: number }[];
|
||||||
|
getPointsHoles(divisions?: number): { x: number; y: number }[][];
|
||||||
|
}): Shape {
|
||||||
|
const outline = shape.getPoints().map((p) => [p.x, p.y]);
|
||||||
|
const holes = shape
|
||||||
|
.getPointsHoles()
|
||||||
|
.map((hole) => hole.map((p) => [p.x, p.y]));
|
||||||
|
return holes.length > 0 ? { outline, holes } : { outline };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rectangle centered at the origin with the given width and height. */
|
||||||
|
export function rectShape(width: number, height: number): Shape {
|
||||||
|
const hw = width / 2;
|
||||||
|
const hh = height / 2;
|
||||||
|
return {
|
||||||
|
outline: [
|
||||||
|
[-hw, -hh],
|
||||||
|
[hw, -hh],
|
||||||
|
[hw, hh],
|
||||||
|
[-hw, hh],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A regular polygon (e.g. hexagon) centered at the origin. */
|
||||||
|
export function polygonShape(sides: number, radius: number, rotation = 0): Shape {
|
||||||
|
const outline: number[][] = [];
|
||||||
|
for (let i = 0; i < sides; i++) {
|
||||||
|
const angle = rotation + (i / sides) * Math.PI * 2;
|
||||||
|
outline.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
|
||||||
|
}
|
||||||
|
return { outline };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hexagon centered at the origin (pointy-top orientation). */
|
||||||
|
export function hexShape(radius: number): Shape {
|
||||||
|
return polygonShape(6, radius, Math.PI / 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A circle approximated by `segments` points, centered at the origin. */
|
||||||
|
export function circleShape(radius: number, segments = 48): Shape {
|
||||||
|
return polygonShape(segments, radius, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rounded rectangle centered at the origin. `radius` is the corner radius,
|
||||||
|
* clamped to at most half the smaller dimension. Each corner is approximated
|
||||||
|
* by `cornerSegments` points.
|
||||||
|
*/
|
||||||
|
export function roundedRectShape(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
cornerSegments = 4,
|
||||||
|
): Shape {
|
||||||
|
const hw = width / 2;
|
||||||
|
const hh = height / 2;
|
||||||
|
const r = Math.min(radius, hw, hh);
|
||||||
|
|
||||||
|
const outline: number[][] = [];
|
||||||
|
// Corner centers, starting bottom-left and going counter-clockwise.
|
||||||
|
const corners: Array<[number, number]> = [
|
||||||
|
[-hw + r, -hh + r],
|
||||||
|
[hw - r, -hh + r],
|
||||||
|
[hw - r, hh - r],
|
||||||
|
[-hw + r, hh - r],
|
||||||
|
];
|
||||||
|
// Start angle for each corner (bottom-left -> top-left, CCW).
|
||||||
|
const startAngles = [Math.PI, -Math.PI / 2, 0, Math.PI / 2];
|
||||||
|
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const [cx, cy] = corners[c]!;
|
||||||
|
const start = startAngles[c]!;
|
||||||
|
for (let s = 0; s < cornerSegments; s++) {
|
||||||
|
const a = start + (s / cornerSegments) * (Math.PI / 2);
|
||||||
|
outline.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { outline };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A shape with a rectangular hole, e.g. a picture frame. */
|
||||||
|
export function frameShape(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
thickness: number,
|
||||||
|
): Shape {
|
||||||
|
const hw = width / 2;
|
||||||
|
const hh = height / 2;
|
||||||
|
const tw = thickness / 2;
|
||||||
|
return {
|
||||||
|
outline: [
|
||||||
|
[-hw, -hh],
|
||||||
|
[hw, -hh],
|
||||||
|
[hw, hh],
|
||||||
|
[-hw, hh],
|
||||||
|
],
|
||||||
|
holes: [
|
||||||
|
[
|
||||||
|
[-hw + tw, -hh + tw],
|
||||||
|
[-hw + tw, hh - tw],
|
||||||
|
[hw - tw, hh - tw],
|
||||||
|
[hw - tw, -hh + tw],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the signed area of a polygon. Positive means counter-clockwise.
|
||||||
|
* Used to validate winding in tests.
|
||||||
|
*/
|
||||||
|
export function signedArea(points: number[][]): number {
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const [x1, y1] = points[i]!;
|
||||||
|
const [x2, y2] = points[(i + 1) % points.length]!;
|
||||||
|
area += x1! * y2! - x2! * y1!;
|
||||||
|
}
|
||||||
|
return area / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FaceGeometry } from './types.js';
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import earcut from 'earcut';
|
||||||
|
import type { FaceGeometry } from './types.js';
|
||||||
|
import type { Shape } from './shapes.js';
|
||||||
|
|
||||||
|
/** Read a `[x, y]` point, asserting it exists (rings are never empty). */
|
||||||
|
function point(ring: number[][], i: number): [number, number] {
|
||||||
|
const p = ring[i]!;
|
||||||
|
return [p[0]!, p[1]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triangulate a (possibly concave) polygon with optional holes using
|
||||||
|
* `earcut` — the same library three.js uses internally for `ShapeGeometry`
|
||||||
|
* and `ExtrudeGeometry`. Returns triangle indices into the flattened vertex
|
||||||
|
* list `[outline..., hole0..., hole1...]`.
|
||||||
|
*/
|
||||||
|
export function triangulate(shape: Shape): number[] {
|
||||||
|
const vertices: number[] = [];
|
||||||
|
for (let i = 0; i < shape.outline.length; i++) {
|
||||||
|
const [x, y] = shape.outline[i]!;
|
||||||
|
vertices.push(x!, y!);
|
||||||
|
}
|
||||||
|
const holes: number[] = [];
|
||||||
|
for (const hole of shape.holes ?? []) {
|
||||||
|
holes.push(vertices.length / 2);
|
||||||
|
for (let i = 0; i < hole.length; i++) {
|
||||||
|
const [x, y] = hole[i]!;
|
||||||
|
vertices.push(x!, y!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return earcut(vertices, holes.length > 0 ? holes : undefined, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the top and bottom faces 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
|
||||||
|
* shape's bounding box to the unit square, so a texture stretches across the
|
||||||
|
* whole face. `uvScale` scales the UVs (values > 1 repeat the texture).
|
||||||
|
*/
|
||||||
|
export function capFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry {
|
||||||
|
const triangles = triangulate(shape);
|
||||||
|
|
||||||
|
// Bounding box for UV mapping.
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (let i = 0; i < shape.outline.length; i++) {
|
||||||
|
const [x, y] = point(shape.outline, i);
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
const spanX = maxX - minX || 1;
|
||||||
|
const spanY = maxY - minY || 1;
|
||||||
|
|
||||||
|
const positions: number[] = [];
|
||||||
|
const uvs: number[] = [];
|
||||||
|
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);
|
||||||
|
uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale);
|
||||||
|
normals.push(0, 0, 1);
|
||||||
|
}
|
||||||
|
for (const t of triangles) {
|
||||||
|
indices.push(topBase + t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom face: same outline, flipped so triangles wind CW when viewed from
|
||||||
|
// below (normal toward -Z), and UVs flipped vertically so the texture is
|
||||||
|
// not mirrored.
|
||||||
|
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, (1 - (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, uvs, normals, indices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* A flat face of an extruded mesh: a set of vertices with positions, normals,
|
||||||
|
* and UVs, plus triangle indices into that vertex list.
|
||||||
|
*/
|
||||||
|
export interface FaceGeometry {
|
||||||
|
/** x, y, z per vertex. */
|
||||||
|
positions: number[];
|
||||||
|
/** x, y per vertex. */
|
||||||
|
uvs: number[];
|
||||||
|
/** x, y, z per vertex. */
|
||||||
|
normals: number[];
|
||||||
|
/** Triangle indices (groups of 3) into the vertex list. */
|
||||||
|
indices: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A complete extruded mesh as raw typed arrays, ready for a BufferGeometry. */
|
||||||
|
export interface ExtrudedGeometry {
|
||||||
|
positions: Float32Array;
|
||||||
|
normals: Float32Array;
|
||||||
|
uvs: Float32Array;
|
||||||
|
indices: Uint32Array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { wallFaces } from './walls.js';
|
||||||
|
import { rectShape } from './shapes.js';
|
||||||
|
|
||||||
|
describe('wallFaces', () => {
|
||||||
|
it('builds a quad strip around a rectangle', () => {
|
||||||
|
const wall = wallFaces(rectShape(2, 2), 1);
|
||||||
|
// 4 outline points * 2 vertices each.
|
||||||
|
expect(wall.positions.length / 3).toBe(8);
|
||||||
|
expect(wall.indices.length).toBe(4 * 6); // 4 quads * 2 triangles
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has outward-facing normals', () => {
|
||||||
|
const wall = wallFaces(rectShape(2, 2), 1);
|
||||||
|
// Bottom edge (from (-1,-1) to (1,-1)) normal should point -Y.
|
||||||
|
// First two vertices belong to the first outline point (-1,-1).
|
||||||
|
const n = wall.normals.slice(0, 3);
|
||||||
|
expect(n[0]).toBeCloseTo(0, 5);
|
||||||
|
expect(n[1]).toBeCloseTo(-1, 5);
|
||||||
|
expect(n[2]).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spans from z=0 to z=height', () => {
|
||||||
|
const wall = wallFaces(rectShape(2, 2), 3);
|
||||||
|
// First vertex is bottom (z=0), second is top (z=3).
|
||||||
|
expect(wall.positions[2]).toBe(0);
|
||||||
|
expect(wall.positions[5]).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps u across the perimeter and v up the height', () => {
|
||||||
|
const wall = wallFaces(rectShape(2, 2), 2);
|
||||||
|
// u starts at 0 for the first outline point.
|
||||||
|
expect(wall.uvs[0]).toBeCloseTo(0, 5);
|
||||||
|
// v goes 0 -> 1 from bottom to top.
|
||||||
|
expect(wall.uvs[1]).toBeCloseTo(0, 5);
|
||||||
|
expect(wall.uvs[3]).toBeCloseTo(1, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { FaceGeometry } from './types.js';
|
||||||
|
import type { Shape } from './shapes.js';
|
||||||
|
|
||||||
|
/** Read a `[x, y]` point, asserting it exists (rings are never empty). */
|
||||||
|
function point(ring: number[][], i: number): [number, number] {
|
||||||
|
const p = ring[i]!;
|
||||||
|
return [p[0]!, p[1]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the side walls of an extruded shape: a quad strip running along the
|
||||||
|
* outline (and any holes) from `z = 0` up to `z = height`. Each quad has an
|
||||||
|
* outward-facing normal and UVs that map the outline's arc length to u and
|
||||||
|
* the height to v.
|
||||||
|
*/
|
||||||
|
export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const uvs: number[] = [];
|
||||||
|
const normals: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
|
||||||
|
const rings = [shape.outline, ...(shape.holes ?? [])];
|
||||||
|
for (const ring of rings) {
|
||||||
|
// Cumulative arc length along the ring, used for the u coordinate.
|
||||||
|
const arc: number[] = [0];
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 1; i < ring.length; i++) {
|
||||||
|
const [x1, y1] = point(ring, i - 1);
|
||||||
|
const [x2, y2] = point(ring, i);
|
||||||
|
total += Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
arc.push(total);
|
||||||
|
}
|
||||||
|
// Close the loop.
|
||||||
|
const [x0, y0] = point(ring, 0);
|
||||||
|
const [xl, yl] = point(ring, ring.length - 1);
|
||||||
|
total += Math.hypot(xl - x0, yl - y0);
|
||||||
|
if (total === 0) continue;
|
||||||
|
|
||||||
|
const base = positions.length / 3;
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [x, y] = point(ring, i);
|
||||||
|
const [xn, yn] = point(ring, (i + 1) % ring.length);
|
||||||
|
|
||||||
|
// Outward normal: perpendicular to the edge (xn,yn)-(x,y), in the XY
|
||||||
|
// plane (z component 0).
|
||||||
|
const ex = xn - x;
|
||||||
|
const ey = yn - y;
|
||||||
|
const len = Math.hypot(ex, ey) || 1;
|
||||||
|
const nx = ey / len;
|
||||||
|
const ny = -ex / len;
|
||||||
|
|
||||||
|
// Two vertices per outline point: bottom and top.
|
||||||
|
positions.push(x, y, 0);
|
||||||
|
positions.push(x, y, height);
|
||||||
|
uvs.push((arc[i]! / total) * uvScale, 0);
|
||||||
|
uvs.push((arc[i]! / total) * uvScale, uvScale);
|
||||||
|
normals.push(nx, ny, 0);
|
||||||
|
normals.push(nx, ny, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build quads between consecutive outline points.
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const j = (i + 1) % ring.length;
|
||||||
|
const a = base + i * 2; // bottom of point i
|
||||||
|
const b = base + i * 2 + 1; // top of point i
|
||||||
|
const c = base + j * 2; // bottom of point j
|
||||||
|
const d = base + j * 2 + 1; // top of point j
|
||||||
|
// Two triangles per quad, wound so the normal faces outward.
|
||||||
|
indices.push(a, c, d);
|
||||||
|
indices.push(a, d, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions, uvs, normals, indices };
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+29
@@ -123,6 +123,25 @@ importers:
|
|||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/mesh:
|
||||||
|
dependencies:
|
||||||
|
earcut:
|
||||||
|
specifier: ^3.2.3
|
||||||
|
version: 3.2.3
|
||||||
|
three:
|
||||||
|
specifier: ^0.185.1
|
||||||
|
version: 0.185.1
|
||||||
|
devDependencies:
|
||||||
|
'@types/earcut':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
|
'@types/three':
|
||||||
|
specifier: ^0.185.4
|
||||||
|
version: 0.185.4
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.2
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
@@ -606,6 +625,9 @@ packages:
|
|||||||
'@types/draco3d@1.4.10':
|
'@types/draco3d@1.4.10':
|
||||||
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
||||||
|
|
||||||
|
'@types/earcut@3.0.0':
|
||||||
|
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -743,6 +765,9 @@ packages:
|
|||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
|
earcut@3.2.3:
|
||||||
|
resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
|
||||||
|
|
||||||
enhanced-resolve@5.24.5:
|
enhanced-resolve@5.24.5:
|
||||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -1661,6 +1686,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/draco3d@1.4.10': {}
|
'@types/draco3d@1.4.10': {}
|
||||||
|
|
||||||
|
'@types/earcut@3.0.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/node@22.20.1':
|
'@types/node@22.20.1':
|
||||||
@@ -1792,6 +1819,8 @@ snapshots:
|
|||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
|
earcut@3.2.3: {}
|
||||||
|
|
||||||
enhanced-resolve@5.24.5:
|
enhanced-resolve@5.24.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|||||||
Reference in New Issue
Block a user