From 14f83f476c965272beb3853000b4186034e0b91e Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 13:42:32 +0800 Subject: [PATCH] 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. --- packages/mesh/package.json | 29 ++++++ packages/mesh/src/extrude.test.ts | 95 ++++++++++++++++++ packages/mesh/src/extrude.ts | 62 ++++++++++++ packages/mesh/src/index.ts | 5 + packages/mesh/src/shapes.test.ts | 87 ++++++++++++++++ packages/mesh/src/shapes.ts | 145 +++++++++++++++++++++++++++ packages/mesh/src/tessellate.test.ts | 89 ++++++++++++++++ packages/mesh/src/tessellate.ts | 97 ++++++++++++++++++ packages/mesh/src/types.ts | 22 ++++ packages/mesh/src/walls.test.ts | 38 +++++++ packages/mesh/src/walls.ts | 75 ++++++++++++++ packages/mesh/tsconfig.json | 9 ++ pnpm-lock.yaml | 29 ++++++ 13 files changed, 782 insertions(+) create mode 100644 packages/mesh/package.json create mode 100644 packages/mesh/src/extrude.test.ts create mode 100644 packages/mesh/src/extrude.ts create mode 100644 packages/mesh/src/index.ts create mode 100644 packages/mesh/src/shapes.test.ts create mode 100644 packages/mesh/src/shapes.ts create mode 100644 packages/mesh/src/tessellate.test.ts create mode 100644 packages/mesh/src/tessellate.ts create mode 100644 packages/mesh/src/types.ts create mode 100644 packages/mesh/src/walls.test.ts create mode 100644 packages/mesh/src/walls.ts create mode 100644 packages/mesh/tsconfig.json diff --git a/packages/mesh/package.json b/packages/mesh/package.json new file mode 100644 index 0000000..35bddcc --- /dev/null +++ b/packages/mesh/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/mesh/src/extrude.test.ts b/packages/mesh/src/extrude.test.ts new file mode 100644 index 0000000..d5f2d72 --- /dev/null +++ b/packages/mesh/src/extrude.test.ts @@ -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; +} \ No newline at end of file diff --git a/packages/mesh/src/extrude.ts b/packages/mesh/src/extrude.ts new file mode 100644 index 0000000..f1af11f --- /dev/null +++ b/packages/mesh/src/extrude.ts @@ -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 }; +} \ No newline at end of file diff --git a/packages/mesh/src/index.ts b/packages/mesh/src/index.ts new file mode 100644 index 0000000..e887053 --- /dev/null +++ b/packages/mesh/src/index.ts @@ -0,0 +1,5 @@ +export * from './types.js'; +export * from './shapes.js'; +export * from './tessellate.js'; +export * from './walls.js'; +export * from './extrude.js'; \ No newline at end of file diff --git a/packages/mesh/src/shapes.test.ts b/packages/mesh/src/shapes.test.ts new file mode 100644 index 0000000..01fce1a --- /dev/null +++ b/packages/mesh/src/shapes.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/packages/mesh/src/shapes.ts b/packages/mesh/src/shapes.ts new file mode 100644 index 0000000..d6a3a2e --- /dev/null +++ b/packages/mesh/src/shapes.ts @@ -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'; \ No newline at end of file diff --git a/packages/mesh/src/tessellate.test.ts b/packages/mesh/src/tessellate.test.ts new file mode 100644 index 0000000..e3b24f8 --- /dev/null +++ b/packages/mesh/src/tessellate.test.ts @@ -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; +} \ No newline at end of file diff --git a/packages/mesh/src/tessellate.ts b/packages/mesh/src/tessellate.ts new file mode 100644 index 0000000..14e812b --- /dev/null +++ b/packages/mesh/src/tessellate.ts @@ -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 }; +} \ No newline at end of file diff --git a/packages/mesh/src/types.ts b/packages/mesh/src/types.ts new file mode 100644 index 0000000..9d6a7fd --- /dev/null +++ b/packages/mesh/src/types.ts @@ -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; +} \ No newline at end of file diff --git a/packages/mesh/src/walls.test.ts b/packages/mesh/src/walls.test.ts new file mode 100644 index 0000000..4e555f6 --- /dev/null +++ b/packages/mesh/src/walls.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/packages/mesh/src/walls.ts b/packages/mesh/src/walls.ts new file mode 100644 index 0000000..43f4351 --- /dev/null +++ b/packages/mesh/src/walls.ts @@ -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 }; +} \ No newline at end of file diff --git a/packages/mesh/tsconfig.json b/packages/mesh/tsconfig.json new file mode 100644 index 0000000..7830064 --- /dev/null +++ b/packages/mesh/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9386836..ded6f18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,25 @@ importers: specifier: ^5.7.2 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: dependencies: zod: @@ -606,6 +625,9 @@ packages: '@types/draco3d@1.4.10': resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -743,6 +765,9 @@ packages: draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} @@ -1661,6 +1686,8 @@ snapshots: '@types/draco3d@1.4.10': {} + '@types/earcut@3.0.0': {} + '@types/estree@1.0.9': {} '@types/node@22.20.1': @@ -1792,6 +1819,8 @@ snapshots: draco3d@1.5.7: {} + earcut@3.2.3: {} + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11