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.positions.length + walls.positions.length).toBe(combined.positions.length);
|
||||||
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.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', () => {
|
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 type { Shape } from './shapes.js';
|
||||||
import { capFaces } from './tessellate.js';
|
import { capFaces } from './tessellate.js';
|
||||||
import { wallFaces } from './walls.js';
|
import { wallFaces } from './walls.js';
|
||||||
@@ -10,6 +10,11 @@ export interface ExtrudeOptions {
|
|||||||
capUvScale?: number;
|
capUvScale?: number;
|
||||||
/** UV scale for the walls. Values > 1 repeat the texture. */
|
/** UV scale for the walls. Values > 1 repeat the texture. */
|
||||||
wallUvScale?: number;
|
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
|
* 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
|
* 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 {
|
export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): ExtrudedGeometry {
|
||||||
const height = options.height ?? 1;
|
const height = options.height ?? 1;
|
||||||
const capUvScale = options.capUvScale ?? 1;
|
const capUvScale = options.capUvScale ?? 1;
|
||||||
const wallUvScale = options.wallUvScale ?? 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 height = options.height ?? 1;
|
||||||
const capUvScale = options.capUvScale ?? 1;
|
const capUvScale = options.capUvScale ?? 1;
|
||||||
const wallUvScale = options.wallUvScale ?? 1;
|
const wallUvScale = options.wallUvScale ?? 1;
|
||||||
|
const uvBounds = options.uvBounds;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
caps: faceToGeometry(capFaces(shape, height, capUvScale)),
|
caps: faceToGeometry(capFaces(shape, height, capUvScale, uvBounds)),
|
||||||
walls: faceToGeometry(wallFaces(shape, height, wallUvScale)),
|
walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,18 +72,18 @@ describe('capFaces', () => {
|
|||||||
expect(uvs[5]).toBeCloseTo(1, 5);
|
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 face = capFaces(rectShape(2, 2), 1);
|
||||||
const outlineCount = 4;
|
const outlineCount = 4;
|
||||||
// Top face: outline point i maps to ((x-minX)/spanX, (y-minY)/spanY).
|
// 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
|
// Bottom face uses the same planar xy mapping (no horizontal mirror), so
|
||||||
// correctly from underneath.
|
// the texture is consistent across front, back, and walls.
|
||||||
for (let i = 0; i < outlineCount; i++) {
|
for (let i = 0; i < outlineCount; i++) {
|
||||||
const topU = face.uvs[i * 2]!;
|
const topU = face.uvs[i * 2]!;
|
||||||
const topV = face.uvs[i * 2 + 1]!;
|
const topV = face.uvs[i * 2 + 1]!;
|
||||||
const bottomU = face.uvs[(outlineCount + i) * 2]!;
|
const bottomU = face.uvs[(outlineCount + i) * 2]!;
|
||||||
const bottomV = face.uvs[(outlineCount + i) * 2 + 1]!;
|
const bottomV = face.uvs[(outlineCount + i) * 2 + 1]!;
|
||||||
expect(bottomU).toBeCloseTo(1 - topU, 5);
|
expect(bottomU).toBeCloseTo(topU, 5);
|
||||||
expect(bottomV).toBeCloseTo(topV, 5);
|
expect(bottomV).toBeCloseTo(topV, 5);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -93,6 +93,24 @@ describe('capFaces', () => {
|
|||||||
const topIndices = face.indices.slice(0, 6);
|
const topIndices = face.indices.slice(0, 6);
|
||||||
expect(fanArea(face.positions, topIndices)).toBeGreaterThan(0);
|
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[][] {
|
function hexOutline(): number[][] {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import earcut from 'earcut';
|
import earcut from 'earcut';
|
||||||
import type { FaceGeometry } from './types.js';
|
import type { FaceGeometry, UVBounds } from './types.js';
|
||||||
import type { Shape } from './shapes.js';
|
import type { Shape } from './shapes.js';
|
||||||
|
|
||||||
/** Read a `[x, y]` point, asserting it exists (rings are never empty). */
|
/** 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
|
* +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
|
* 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).
|
* 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);
|
const triangles = triangulate(shape);
|
||||||
|
|
||||||
// Bounding box for UV mapping.
|
// Bounding box for UV mapping: the shape's own box, or the caller-provided
|
||||||
let minX = Infinity;
|
// framing rectangle.
|
||||||
let minY = Infinity;
|
const { minX, minY, spanX, spanY } = uvBounds
|
||||||
let maxX = -Infinity;
|
? {
|
||||||
let maxY = -Infinity;
|
minX: uvBounds.minX,
|
||||||
for (let i = 0; i < shape.outline.length; i++) {
|
minY: uvBounds.minY,
|
||||||
const [x, y] = point(shape.outline, i);
|
spanX: uvBounds.maxX - uvBounds.minX || 1,
|
||||||
if (x < minX) minX = x;
|
spanY: uvBounds.maxY - uvBounds.minY || 1,
|
||||||
if (y < minY) minY = y;
|
}
|
||||||
if (x > maxX) maxX = x;
|
: shapeBounds(shape);
|
||||||
if (y > maxY) maxY = y;
|
|
||||||
}
|
|
||||||
const spanX = maxX - minX || 1;
|
|
||||||
const spanY = maxY - minY || 1;
|
|
||||||
|
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
const uvs: 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
|
// 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
|
// below (normal toward -Z). UVs use the same planar xy mapping as the top
|
||||||
// top face so the texture reads correctly from underneath.
|
// face (no mirror), so the texture is consistent across front, back, and
|
||||||
|
// walls regardless of z.
|
||||||
const bottomBase = shape.outline.length;
|
const bottomBase = shape.outline.length;
|
||||||
for (let i = 0; i < shape.outline.length; i++) {
|
for (let i = 0; i < shape.outline.length; i++) {
|
||||||
const [x, y] = point(shape.outline, i);
|
const [x, y] = point(shape.outline, i);
|
||||||
positions.push(x, y, 0);
|
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);
|
normals.push(0, 0, -1);
|
||||||
}
|
}
|
||||||
// Reverse winding order for the bottom face. `triangles` is a flat list of
|
// 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 };
|
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;
|
uvs: Float32Array;
|
||||||
indices: Uint32Array;
|
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;
|
||||||
|
}
|
||||||
+39
-15
@@ -1,4 +1,4 @@
|
|||||||
import type { FaceGeometry } from './types.js';
|
import type { FaceGeometry, UVBounds } from './types.js';
|
||||||
import type { Shape } from './shapes.js';
|
import type { Shape } from './shapes.js';
|
||||||
|
|
||||||
/** Read a `[x, y]` point, asserting it exists (rings are never empty). */
|
/** 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
|
* 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
|
* 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.
|
* 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 positions: number[] = [];
|
||||||
const uvs: number[] = [];
|
const uvs: number[] = [];
|
||||||
const normals: 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
|
// Bounding box over the outline, matching `capFaces` so wall UVs line up
|
||||||
// with the top face's texture mapping.
|
// with the top face's texture mapping.
|
||||||
let minX = Infinity;
|
const { minX, minY, spanX, spanY } = uvBounds
|
||||||
let minY = Infinity;
|
? {
|
||||||
let maxX = -Infinity;
|
minX: uvBounds.minX,
|
||||||
let maxY = -Infinity;
|
minY: uvBounds.minY,
|
||||||
for (let i = 0; i < shape.outline.length; i++) {
|
spanX: uvBounds.maxX - uvBounds.minX || 1,
|
||||||
const [x, y] = point(shape.outline, i);
|
spanY: uvBounds.maxY - uvBounds.minY || 1,
|
||||||
if (x < minX) minX = x;
|
}
|
||||||
if (y < minY) minY = y;
|
: shapeBounds(shape);
|
||||||
if (x > maxX) maxX = x;
|
|
||||||
if (y > maxY) maxY = y;
|
|
||||||
}
|
|
||||||
const spanX = maxX - minX || 1;
|
|
||||||
const spanY = maxY - minY || 1;
|
|
||||||
|
|
||||||
const rings = [shape.outline, ...(shape.holes ?? [])];
|
const rings = [shape.outline, ...(shape.holes ?? [])];
|
||||||
for (const ring of rings) {
|
for (const ring of rings) {
|
||||||
@@ -80,3 +83,24 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
|
|||||||
|
|
||||||
return { positions, uvs, normals, indices };
|
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