diff --git a/packages/mesh/src/extrude.test.ts b/packages/mesh/src/extrude.test.ts index 21449a8..55966d0 100644 --- a/packages/mesh/src/extrude.test.ts +++ b/packages/mesh/src/extrude.test.ts @@ -74,6 +74,19 @@ describe('extrudeShapeParts', () => { expect(caps.positions.length + walls.positions.length).toBe(combined.positions.length); expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length); }); + + it('applies uvBounds to caps and walls', () => { + const uvBounds = { minX: 0, minY: 0, maxX: 4, maxY: 4 }; + const { caps, 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); + // Walls: first outline point (-0.5,-0.5) -> u=-0.125, v=-0.125. + expect(walls.uvs[0]).toBeCloseTo(-0.125, 5); + expect(walls.uvs[1]).toBeCloseTo(-0.125, 5); + }); }); describe('mergeFaces', () => { diff --git a/packages/mesh/src/extrude.ts b/packages/mesh/src/extrude.ts index 26ad7a7..1f86ce1 100644 --- a/packages/mesh/src/extrude.ts +++ b/packages/mesh/src/extrude.ts @@ -1,4 +1,4 @@ -import type { ExtrudedGeometry, FaceGeometry } from './types.js'; +import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js'; import type { Shape } from './shapes.js'; import { capFaces } from './tessellate.js'; import { wallFaces } from './walls.js'; @@ -10,6 +10,11 @@ export interface ExtrudeOptions { capUvScale?: number; /** UV scale for the walls. Values > 1 repeat the texture. */ wallUvScale?: number; + /** + * Rectangle, in shape coordinates, that maps to the full texture. When set, + * UVs are derived from this framing instead of the shape's bounding box. + */ + uvBounds?: UVBounds; } /** @@ -19,14 +24,16 @@ export interface ExtrudeOptions { * * 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 horizontally-flipped UVs so the texture isn't upside down. + * (normal -Z) with the same planar xy UVs, so the texture is consistent + * across front, back, and walls. */ export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): ExtrudedGeometry { const height = options.height ?? 1; const capUvScale = options.capUvScale ?? 1; const wallUvScale = options.wallUvScale ?? 1; + const uvBounds = options.uvBounds; - return mergeFaces([capFaces(shape, height, capUvScale), wallFaces(shape, height, wallUvScale)]); + return mergeFaces([capFaces(shape, height, capUvScale, uvBounds), wallFaces(shape, height, wallUvScale, uvBounds)]); } /** @@ -41,10 +48,11 @@ export function extrudeShapeParts( 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)), - walls: faceToGeometry(wallFaces(shape, height, wallUvScale)), + caps: faceToGeometry(capFaces(shape, height, capUvScale, uvBounds)), + walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)), }; } diff --git a/packages/mesh/src/tessellate.test.ts b/packages/mesh/src/tessellate.test.ts index 4dc7c32..4cee3a6 100644 --- a/packages/mesh/src/tessellate.test.ts +++ b/packages/mesh/src/tessellate.test.ts @@ -72,18 +72,18 @@ describe('capFaces', () => { expect(uvs[5]).toBeCloseTo(1, 5); }); - it('bottom face UVs are a left/right flip of the top face', () => { + it('bottom face UVs match the top face (planar xy, no mirror)', () => { const face = capFaces(rectShape(2, 2), 1); const outlineCount = 4; // Top face: outline point i maps to ((x-minX)/spanX, (y-minY)/spanY). - // Bottom face mirrors U (1 - u) but keeps V, so the texture reads - // correctly from underneath. + // Bottom face uses the same planar xy mapping (no horizontal mirror), so + // the texture is consistent across front, back, and walls. for (let i = 0; i < outlineCount; i++) { const topU = face.uvs[i * 2]!; const topV = face.uvs[i * 2 + 1]!; const bottomU = face.uvs[(outlineCount + i) * 2]!; const bottomV = face.uvs[(outlineCount + i) * 2 + 1]!; - expect(bottomU).toBeCloseTo(1 - topU, 5); + expect(bottomU).toBeCloseTo(topU, 5); expect(bottomV).toBeCloseTo(topV, 5); } }); @@ -93,6 +93,24 @@ describe('capFaces', () => { const topIndices = face.indices.slice(0, 6); expect(fanArea(face.positions, topIndices)).toBeGreaterThan(0); }); + + it('derives UVs from uvBounds instead of the shape bbox', () => { + // A 1x1 shape centered at origin inside a 4x4 framing (0..4): UVs should + // span -0.125..0.125, not 0..1. + const face = capFaces(rectShape(1, 1), 1, 1, { + minX: 0, + minY: 0, + maxX: 4, + maxY: 4, + }); + const outlineCount = 4; + // Bottom-left vertex at (-0.5,-0.5) -> u=(0.5-0)/4=-0.125, v=-0.125. + expect(face.uvs[0]).toBeCloseTo(-0.125, 5); + expect(face.uvs[1]).toBeCloseTo(-0.125, 5); + // Top-left vertex at (-0.5,0.5) -> u=-0.125, v=0.125. + expect(face.uvs[(outlineCount - 1) * 2]).toBeCloseTo(-0.125, 5); + expect(face.uvs[(outlineCount - 1) * 2 + 1]).toBeCloseTo(0.125, 5); + }); }); function hexOutline(): number[][] { diff --git a/packages/mesh/src/tessellate.ts b/packages/mesh/src/tessellate.ts index 5f0ff51..705e8f5 100644 --- a/packages/mesh/src/tessellate.ts +++ b/packages/mesh/src/tessellate.ts @@ -1,5 +1,5 @@ import earcut from 'earcut'; -import type { FaceGeometry } from './types.js'; +import type { FaceGeometry, UVBounds } from './types.js'; import type { Shape } from './shapes.js'; /** Read a `[x, y]` point, asserting it exists (rings are never empty). */ @@ -38,24 +38,29 @@ export function triangulate(shape: Shape): number[] { * +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). + * + * When `uvBounds` is provided, UVs are derived from that rectangle instead of + * 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(shape: Shape, height: number, uvScale = 1): FaceGeometry { +export function capFaces( + shape: Shape, + height: number, + uvScale = 1, + uvBounds?: UVBounds, +): 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; + // Bounding box for UV mapping: the shape's own box, or the caller-provided + // framing rectangle. + const { minX, minY, spanX, spanY } = uvBounds + ? { + minX: uvBounds.minX, + minY: uvBounds.minY, + spanX: uvBounds.maxX - uvBounds.minX || 1, + spanY: uvBounds.maxY - uvBounds.minY || 1, + } + : shapeBounds(shape); const positions: number[] = []; const uvs: number[] = []; @@ -75,13 +80,14 @@ export function capFaces(shape: Shape, height: number, uvScale = 1): FaceGeometr } // Bottom face: same outline, flipped so triangles wind CW when viewed from - // below (normal toward -Z). UVs are a left/right (horizontal) mirror of the - // top face so the texture reads correctly from underneath. + // 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((1 - (x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale); + 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 @@ -94,4 +100,25 @@ export function capFaces(shape: Shape, height: number, uvScale = 1): FaceGeometr } return { positions, uvs, normals, indices }; +} + +/** Compute the bounding box of a shape's outline. */ +function shapeBounds(shape: Shape): { + minX: number; + minY: number; + spanX: number; + spanY: number; +} { + 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; + } + return { minX, minY, spanX: maxX - minX || 1, spanY: maxY - minY || 1 }; } \ No newline at end of file diff --git a/packages/mesh/src/types.ts b/packages/mesh/src/types.ts index 9d6a7fd..c1fcf54 100644 --- a/packages/mesh/src/types.ts +++ b/packages/mesh/src/types.ts @@ -19,4 +19,17 @@ export interface ExtrudedGeometry { normals: Float32Array; uvs: Float32Array; indices: Uint32Array; +} + +/** + * A rectangle, in shape coordinates, that maps to the full texture (UV 0..1). + * When provided, UVs are derived from this framing instead of the shape's own + * bounding box. This lets a shape that is only part of a larger image (e.g. a + * traced silhouette inside a transparent canvas) align its texture correctly. + */ +export interface UVBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; } \ No newline at end of file diff --git a/packages/mesh/src/walls.ts b/packages/mesh/src/walls.ts index dd9e18c..2882dd3 100644 --- a/packages/mesh/src/walls.ts +++ b/packages/mesh/src/walls.ts @@ -1,4 +1,4 @@ -import type { FaceGeometry } from './types.js'; +import type { FaceGeometry, UVBounds } from './types.js'; import type { Shape } from './shapes.js'; /** Read a `[x, y]` point, asserting it exists (rings are never empty). */ @@ -13,8 +13,16 @@ function point(ring: number[][], i: number): [number, number] { * outward-facing normal. UVs use the same planar bounding-box mapping as the * top face (based on x/y only), so the walls inherit the front texture and * the z position of a vertex does not affect its UV. + * + * When `uvBounds` is provided, UVs are derived from that rectangle instead of + * the shape's bounding box, matching `capFaces`. */ -export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry { +export function wallFaces( + shape: Shape, + height: number, + uvScale = 1, + uvBounds?: UVBounds, +): FaceGeometry { const positions: number[] = []; const uvs: number[] = []; const normals: number[] = []; @@ -22,19 +30,14 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet // Bounding box over the outline, matching `capFaces` so wall UVs line up // with the top face's texture 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 { minX, minY, spanX, spanY } = uvBounds + ? { + minX: uvBounds.minX, + minY: uvBounds.minY, + spanX: uvBounds.maxX - uvBounds.minX || 1, + spanY: uvBounds.maxY - uvBounds.minY || 1, + } + : shapeBounds(shape); const rings = [shape.outline, ...(shape.holes ?? [])]; for (const ring of rings) { @@ -79,4 +82,25 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet } return { positions, uvs, normals, indices }; +} + +/** Compute the bounding box of a shape's outline. */ +function shapeBounds(shape: Shape): { + minX: number; + minY: number; + spanX: number; + spanY: number; +} { + 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; + } + return { minX, minY, spanX: maxX - minX || 1, spanY: maxY - minY || 1 }; } \ No newline at end of file