feat(tile): render tiles with the mesh generator
Replace the per-shape tile geometries with @tts/mesh extrusion, honoring the tile type and stretch aspect ratio. Add extrudeShapeParts so the caps carry the texture while the walls are solid white, matching TTS tinting. Fix bottom-face UVs to mirror horizontally and make wall UVs inherit the front mapping independent of z.
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"@react-three/fiber": "^9.7.0",
|
"@react-three/fiber": "^9.7.0",
|
||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
"@tts/extract": "workspace:*",
|
"@tts/extract": "workspace:*",
|
||||||
|
"@tts/mesh": "workspace:*",
|
||||||
"@tts/shared": "workspace:*",
|
"@tts/shared": "workspace:*",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -1,34 +1,119 @@
|
|||||||
import { useTexture } from '@react-three/drei';
|
import { useTexture } from '@react-three/drei';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
|
import {
|
||||||
|
circleShape,
|
||||||
|
extrudeShapeParts,
|
||||||
|
hexShape,
|
||||||
|
rectShape,
|
||||||
|
roundedRectShape,
|
||||||
|
scaleShape,
|
||||||
|
type ExtrudedGeometry,
|
||||||
|
} from '@tts/mesh';
|
||||||
import Scene from './Scene';
|
import Scene from './Scene';
|
||||||
import { assetUrl } from './assetUrl';
|
import { assetUrl } from './assetUrl';
|
||||||
|
|
||||||
|
/** `CustomTile.Type` enum from Tabletop Simulator. */
|
||||||
|
const TileType = {
|
||||||
|
Box: 0,
|
||||||
|
Hex: 1,
|
||||||
|
Circle: 2,
|
||||||
|
Rounded: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TILE_SIZE = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
* A flat tile with a texture on its top face. Uses `CustomImage.ImageURL`
|
||||||
* (falling back to `ImageSecondaryURL`), with a neutral color when absent.
|
* (falling back to `ImageSecondaryURL`), with a neutral color when absent.
|
||||||
|
* The footprint follows `CustomTile.Type` (box, hex, circle, or rounded).
|
||||||
|
*
|
||||||
|
* When `CustomTile.Stretch` is false, the tile's aspect ratio follows the
|
||||||
|
* source image instead of being forced square.
|
||||||
*/
|
*/
|
||||||
export default function TileViewer({ object }: { object: TTSObject }) {
|
export default function TileViewer({ object }: { object: TTSObject }) {
|
||||||
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
|
||||||
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.1;
|
const thickness = object.CustomImage?.CustomTile?.Thickness ?? 0.2;
|
||||||
|
const type = object.CustomImage?.CustomTile?.Type ?? TileType.Box;
|
||||||
|
const stretch = object.CustomImage?.CustomTile?.Stretch ?? true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scene>
|
<Scene>
|
||||||
<TileMesh url={url} thickness={thickness} />
|
<TileMesh url={url} thickness={thickness} type={type} stretch={stretch} />
|
||||||
</Scene>
|
</Scene>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
// Rendered inside the Canvas so `useTexture` can access the R3F store.
|
||||||
function TileMesh({ url, thickness }: { url?: string; thickness: number }) {
|
function TileMesh({
|
||||||
const texture = url ? useTexture(assetUrl(url)) : null;
|
url,
|
||||||
|
thickness,
|
||||||
|
type,
|
||||||
|
stretch,
|
||||||
|
}: {
|
||||||
|
url?: string;
|
||||||
|
thickness: number;
|
||||||
|
type: number;
|
||||||
|
stretch: boolean;
|
||||||
|
}) {
|
||||||
|
const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;
|
||||||
|
|
||||||
|
// Build the extruded geometry from the tile shape. When `stretch` is false
|
||||||
|
// and a texture is available, scale the shape to the image's aspect ratio so
|
||||||
|
// the tile matches the source proportions instead of being square.
|
||||||
|
const { caps, walls } = useMemo(() => {
|
||||||
|
const img = texture?.image as HTMLImageElement;
|
||||||
|
const aspect = stretch ? img.width / img.height : 1;
|
||||||
|
const shape = tileShape(type, aspect);
|
||||||
|
const { caps, walls } = extrudeShapeParts(shape, { height: thickness });
|
||||||
|
return { caps: toGeometry(caps), walls: toGeometry(walls) };
|
||||||
|
}, [type, thickness, stretch, texture]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<group>
|
||||||
<boxGeometry args={[1.6, 1.6, thickness]} />
|
{/* Top/bottom faces carry the tile texture. */}
|
||||||
<meshStandardMaterial
|
<mesh geometry={caps}>
|
||||||
color={texture ? '#ffffff' : '#52525b'}
|
<meshStandardMaterial
|
||||||
map={texture ?? undefined}
|
color={texture ? '#ffffff' : '#52525b'}
|
||||||
roughness={0.8}
|
map={texture ?? undefined}
|
||||||
/>
|
roughness={0.8}
|
||||||
</mesh>
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Sides are a solid white, matching TTS tile tinting. */}
|
||||||
|
<mesh geometry={walls}>
|
||||||
|
<meshStandardMaterial color="#ffffff" roughness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
|
||||||
|
function toGeometry(extruded: ExtrudedGeometry) {
|
||||||
|
const { positions, normals, uvs, indices } = extruded;
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the 2D footprint for a tile type, scaled to a target aspect ratio. */
|
||||||
|
function tileShape(type: number, aspect: number) {
|
||||||
|
// Base shape is square (1x1); scale x to the aspect ratio so the tile is
|
||||||
|
// `aspect` wide and 1 tall (or keep 1x1 when the aspect is 1).
|
||||||
|
const sx = aspect;
|
||||||
|
const sy = 1;
|
||||||
|
switch (type) {
|
||||||
|
case TileType.Hex:
|
||||||
|
return scaleShape(hexShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Circle:
|
||||||
|
return scaleShape(circleShape(TILE_SIZE / 2), sx, sy);
|
||||||
|
case TileType.Rounded:
|
||||||
|
return scaleShape(roundedRectShape(TILE_SIZE, TILE_SIZE, 0.08), sx, sy);
|
||||||
|
case TileType.Box:
|
||||||
|
default:
|
||||||
|
return scaleShape(rectShape(TILE_SIZE, TILE_SIZE), sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { extrudeShape, mergeFaces } from './extrude.js';
|
import { extrudeShape, extrudeShapeParts, mergeFaces } from './extrude.js';
|
||||||
import { rectShape, hexShape, circleShape, roundedRectShape } from './shapes.js';
|
import { rectShape, hexShape, circleShape, roundedRectShape, scaleShape } from './shapes.js';
|
||||||
import { capFaces } from './tessellate.js';
|
import { capFaces } from './tessellate.js';
|
||||||
import { wallFaces } from './walls.js';
|
import { wallFaces } from './walls.js';
|
||||||
import type { FaceGeometry } from './types.js';
|
import type { FaceGeometry } from './types.js';
|
||||||
@@ -44,6 +44,36 @@ describe('extrudeShape', () => {
|
|||||||
expect(geo.uvs[1]).toBeCloseTo(0, 5);
|
expect(geo.uvs[1]).toBeCloseTo(0, 5);
|
||||||
expect(geo.uvs[4]).toBeCloseTo(2, 5);
|
expect(geo.uvs[4]).toBeCloseTo(2, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('produces non-square geometry when scaled by an aspect ratio', () => {
|
||||||
|
const geo = extrudeShape(scaleShape(rectShape(2, 2), 2, 1), { height: 0.5 });
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (let i = 0; i < geo.positions.length; i += 3) {
|
||||||
|
minX = Math.min(minX, geo.positions[i]!);
|
||||||
|
maxX = Math.max(maxX, geo.positions[i]!);
|
||||||
|
minY = Math.min(minY, geo.positions[i + 1]!);
|
||||||
|
maxY = Math.max(maxY, geo.positions[i + 1]!);
|
||||||
|
}
|
||||||
|
expect(maxX - minX).toBeCloseTo(4, 5); // 2 * aspect 2
|
||||||
|
expect(maxY - minY).toBeCloseTo(2, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extrudeShapeParts', () => {
|
||||||
|
it('returns caps and walls as separate geometries', () => {
|
||||||
|
const { caps, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 });
|
||||||
|
// Caps: 2 faces * 4 outline points = 8 vertices.
|
||||||
|
expect(caps.positions.length / 3).toBe(8);
|
||||||
|
// Walls: 4 outline points * 2 vertices = 8 vertices.
|
||||||
|
expect(walls.positions.length / 3).toBe(8);
|
||||||
|
// Combined, they match `extrudeShape`.
|
||||||
|
const combined = extrudeShape(rectShape(2, 2), { height: 1 });
|
||||||
|
expect(caps.positions.length + walls.positions.length).toBe(combined.positions.length);
|
||||||
|
expect(caps.indices.length + walls.indices.length).toBe(combined.indices.length);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergeFaces', () => {
|
describe('mergeFaces', () => {
|
||||||
|
|||||||
@@ -19,17 +19,43 @@ 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 vertically-flipped UVs so the texture isn't upside down.
|
* (normal -Z) with horizontally-flipped UVs so the texture isn't upside down.
|
||||||
*/
|
*/
|
||||||
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 top = capFaces(shape, height, capUvScale);
|
return mergeFaces([capFaces(shape, height, capUvScale), wallFaces(shape, height, wallUvScale)]);
|
||||||
const walls = wallFaces(shape, height, wallUvScale);
|
}
|
||||||
|
|
||||||
return mergeFaces([top, walls]);
|
/**
|
||||||
|
* Extrude a shape, returning the caps (top + bottom faces) and walls as
|
||||||
|
* separate geometries. This lets callers apply different materials to the
|
||||||
|
* textured faces versus the sides (e.g. white, tintable walls on a tile).
|
||||||
|
*/
|
||||||
|
export function extrudeShapeParts(
|
||||||
|
shape: Shape,
|
||||||
|
options: ExtrudeOptions = {},
|
||||||
|
): { caps: ExtrudedGeometry; walls: ExtrudedGeometry } {
|
||||||
|
const height = options.height ?? 1;
|
||||||
|
const capUvScale = options.capUvScale ?? 1;
|
||||||
|
const wallUvScale = options.wallUvScale ?? 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
caps: faceToGeometry(capFaces(shape, height, capUvScale)),
|
||||||
|
walls: faceToGeometry(wallFaces(shape, height, wallUvScale)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a single `FaceGeometry` as an `ExtrudedGeometry`. */
|
||||||
|
function faceToGeometry(f: FaceGeometry): ExtrudedGeometry {
|
||||||
|
return {
|
||||||
|
positions: new Float32Array(f.positions),
|
||||||
|
normals: new Float32Array(f.normals),
|
||||||
|
uvs: new Float32Array(f.uvs),
|
||||||
|
indices: new Uint32Array(f.indices),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Concatenate multiple `FaceGeometry`s into one flat `ExtrudedGeometry`. */
|
/** Concatenate multiple `FaceGeometry`s into one flat `ExtrudedGeometry`. */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
polygonShape,
|
polygonShape,
|
||||||
rectShape,
|
rectShape,
|
||||||
roundedRectShape,
|
roundedRectShape,
|
||||||
|
scaleShape,
|
||||||
shapeFromThree,
|
shapeFromThree,
|
||||||
signedArea,
|
signedArea,
|
||||||
} from './shapes.js';
|
} from './shapes.js';
|
||||||
@@ -67,6 +68,19 @@ describe('shape generators', () => {
|
|||||||
expect(signedArea(s.holes![0]!)).toBeLessThan(0);
|
expect(signedArea(s.holes![0]!)).toBeLessThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('scaleShape scales outline and holes', () => {
|
||||||
|
const s = scaleShape(rectShape(2, 2), 2, 3);
|
||||||
|
expect(s.outline).toEqual([
|
||||||
|
[-2, -3],
|
||||||
|
[2, -3],
|
||||||
|
[2, 3],
|
||||||
|
[-2, 3],
|
||||||
|
]);
|
||||||
|
const framed = scaleShape(frameShape(4, 4, 2), 2, 2);
|
||||||
|
expect(framed.holes).toHaveLength(1);
|
||||||
|
expect(framed.holes![0]![0]).toEqual([-2, -2]);
|
||||||
|
});
|
||||||
|
|
||||||
it('shapeFromThree converts three.js vectors', () => {
|
it('shapeFromThree converts three.js vectors', () => {
|
||||||
const fake = {
|
const fake = {
|
||||||
getPoints: () => [
|
getPoints: () => [
|
||||||
|
|||||||
@@ -128,6 +128,20 @@ export function frameShape(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale a shape's outline and holes by independent x/y factors. Useful for
|
||||||
|
* stretching a unit shape to a target width/height while preserving its
|
||||||
|
* proportions along each axis. UV mapping in `capFaces` normalizes by the
|
||||||
|
* bounding box, so scaling does not distort the texture.
|
||||||
|
*/
|
||||||
|
export function scaleShape(shape: Shape, sx: number, sy: number): Shape {
|
||||||
|
const scale = (pts: number[][]) => pts.map((p) => [p[0]! * sx, p[1]! * sy]);
|
||||||
|
return {
|
||||||
|
outline: scale(shape.outline),
|
||||||
|
holes: shape.holes?.map(scale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the signed area of a polygon. Positive means counter-clockwise.
|
* Compute the signed area of a polygon. Positive means counter-clockwise.
|
||||||
* Used to validate winding in tests.
|
* Used to validate winding in tests.
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ 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', () => {
|
||||||
|
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.
|
||||||
|
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(bottomV).toBeCloseTo(topV, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('top face triangles are CCW (positive area)', () => {
|
it('top face triangles are CCW (positive area)', () => {
|
||||||
const face = capFaces(rectShape(2, 2), 1);
|
const face = capFaces(rectShape(2, 2), 1);
|
||||||
const topIndices = face.indices.slice(0, 6);
|
const topIndices = face.indices.slice(0, 6);
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ 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), and UVs flipped vertically so the texture is
|
// below (normal toward -Z). UVs are a left/right (horizontal) mirror of the
|
||||||
// not mirrored.
|
// top face so the texture reads correctly from underneath.
|
||||||
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(((x - minX) / spanX) * uvScale, (1 - (y - minY) / spanY) * uvScale);
|
uvs.push((1 - (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
|
||||||
|
|||||||
@@ -27,12 +27,17 @@ describe('wallFaces', () => {
|
|||||||
expect(wall.positions[5]).toBe(3);
|
expect(wall.positions[5]).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps u across the perimeter and v up the height', () => {
|
it('uses planar UVs from the top face mapping, independent of z', () => {
|
||||||
const wall = wallFaces(rectShape(2, 2), 2);
|
const wall = wallFaces(rectShape(2, 2), 2);
|
||||||
// u starts at 0 for the first outline point.
|
// First outline point is (-1,-1) -> planar UV (0, 0).
|
||||||
expect(wall.uvs[0]).toBeCloseTo(0, 5);
|
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[1]).toBeCloseTo(0, 5);
|
||||||
expect(wall.uvs[3]).toBeCloseTo(1, 5);
|
// The bottom and top vertices of the same outline point share a UV,
|
||||||
|
// so the texture is constant down the wall regardless of z.
|
||||||
|
expect(wall.uvs[2]).toBeCloseTo(wall.uvs[0]!, 5);
|
||||||
|
expect(wall.uvs[3]).toBeCloseTo(wall.uvs[1]!, 5);
|
||||||
|
// Third outline point is (1, 1) -> planar UV (1, 1).
|
||||||
|
expect(wall.uvs[8]).toBeCloseTo(1, 5);
|
||||||
|
expect(wall.uvs[9]).toBeCloseTo(1, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+26
-19
@@ -10,8 +10,9 @@ function point(ring: number[][], i: number): [number, number] {
|
|||||||
/**
|
/**
|
||||||
* Build the side walls of an extruded shape: a quad strip running along the
|
* 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
|
* 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
|
* outward-facing normal. UVs use the same planar bounding-box mapping as the
|
||||||
* the height to v.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry {
|
export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeometry {
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
@@ -19,23 +20,24 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
|
|||||||
const normals: number[] = [];
|
const normals: number[] = [];
|
||||||
const indices: number[] = [];
|
const indices: number[] = [];
|
||||||
|
|
||||||
|
// 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 rings = [shape.outline, ...(shape.holes ?? [])];
|
const rings = [shape.outline, ...(shape.holes ?? [])];
|
||||||
for (const ring of rings) {
|
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;
|
const base = positions.length / 3;
|
||||||
for (let i = 0; i < ring.length; i++) {
|
for (let i = 0; i < ring.length; i++) {
|
||||||
const [x, y] = point(ring, i);
|
const [x, y] = point(ring, i);
|
||||||
@@ -49,11 +51,16 @@ export function wallFaces(shape: Shape, height: number, uvScale = 1): FaceGeomet
|
|||||||
const nx = ey / len;
|
const nx = ey / len;
|
||||||
const ny = -ex / len;
|
const ny = -ex / len;
|
||||||
|
|
||||||
|
// Planar UV from the point's x/y, shared by the bottom and top vertices
|
||||||
|
// so the texture is constant down the wall (z-independent).
|
||||||
|
const u = ((x - minX) / spanX) * uvScale;
|
||||||
|
const v = ((y - minY) / spanY) * uvScale;
|
||||||
|
|
||||||
// Two vertices per outline point: bottom and top.
|
// Two vertices per outline point: bottom and top.
|
||||||
positions.push(x, y, 0);
|
positions.push(x, y, 0);
|
||||||
positions.push(x, y, height);
|
positions.push(x, y, height);
|
||||||
uvs.push((arc[i]! / total) * uvScale, 0);
|
uvs.push(u, v);
|
||||||
uvs.push((arc[i]! / total) * uvScale, uvScale);
|
uvs.push(u, v);
|
||||||
normals.push(nx, ny, 0);
|
normals.push(nx, ny, 0);
|
||||||
normals.push(nx, ny, 0);
|
normals.push(nx, ny, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -66,6 +66,9 @@ importers:
|
|||||||
'@tts/extract':
|
'@tts/extract':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/extract
|
version: link:../../packages/extract
|
||||||
|
'@tts/mesh':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/mesh
|
||||||
'@tts/shared':
|
'@tts/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
|||||||
Reference in New Issue
Block a user