feat(mesh): support uvBounds and planar back-face UVs
Add an optional uvBounds to extrudeShape/extrudeShapeParts so UVs can be derived from a framing rectangle instead of the shape's bounding box, letting a traced silhouette align with its full image. The bottom face now uses the same planar xy UVs as the top face (no horizontal mirror), so the texture is consistent across front, back, and walls.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[][] {
|
||||
|
||||
@@ -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;
|
||||
// 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,
|
||||
}
|
||||
const spanX = maxX - minX || 1;
|
||||
const spanY = maxY - 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
|
||||
@@ -95,3 +101,24 @@ 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 };
|
||||
}
|
||||
@@ -20,3 +20,16 @@ export interface ExtrudedGeometry {
|
||||
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;
|
||||
}
|
||||
+38
-14
@@ -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 { minX, minY, spanX, spanY } = uvBounds
|
||||
? {
|
||||
minX: uvBounds.minX,
|
||||
minY: uvBounds.minY,
|
||||
spanX: uvBounds.maxX - uvBounds.minX || 1,
|
||||
spanY: uvBounds.maxY - uvBounds.minY || 1,
|
||||
}
|
||||
const spanX = maxX - minX || 1;
|
||||
const spanY = maxY - minY || 1;
|
||||
: shapeBounds(shape);
|
||||
|
||||
const rings = [shape.outline, ...(shape.holes ?? [])];
|
||||
for (const ring of rings) {
|
||||
@@ -80,3 +83,24 @@ 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user