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:
2026-08-08 13:42:32 +08:00
parent 3b86a641aa
commit 14f83f476c
13 changed files with 782 additions and 0 deletions
+95
View File
@@ -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;
}
+62
View File
@@ -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 };
}
+5
View File
@@ -0,0 +1,5 @@
export * from './types.js';
export * from './shapes.js';
export * from './tessellate.js';
export * from './walls.js';
export * from './extrude.js';
+87
View File
@@ -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();
});
});
+145
View File
@@ -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';
+89
View File
@@ -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;
}
+97
View File
@@ -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 };
}
+22
View File
@@ -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;
}
+38
View File
@@ -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);
});
});
+75
View File
@@ -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 };
}