diff --git a/apps/web/src/components/objectIcons.tsx b/apps/web/src/components/objectIcons.tsx index a72189c..6189a83 100644 --- a/apps/web/src/components/objectIcons.tsx +++ b/apps/web/src/components/objectIcons.tsx @@ -7,9 +7,12 @@ import './objectIconsData'; */ const OBJECT_ICONS: Record = { Card: ['mdi:cards-outline'], + CardCustom: ['mdi:cards-outline'], + Deck: ['mdi:cards'], DeckCustom: ['mdi:cards'], Custom_Deck: ['mdi:cards'], + Bag: ['material-symbols:folder'], Custom_Model_Bag: ['file-icons:3d-model', 'material-symbols:folder'], Custom_Model_Infinite_Bag: ['file-icons:3d-model','boxicons:infinite'], diff --git a/apps/web/src/components/viewers/CardViewer.tsx b/apps/web/src/components/viewers/CardViewer.tsx index 5fbff5b..db5b91a 100644 --- a/apps/web/src/components/viewers/CardViewer.tsx +++ b/apps/web/src/components/viewers/CardViewer.tsx @@ -1,37 +1,172 @@ import { useTexture } from '@react-three/drei'; +import { useMemo } from 'react'; +import * as THREE from 'three'; import type { TTSObject } from '@tts/shared'; +import { + extrudeShapeParts, + roundedRectShape, + type ExtrudedGeometry, +} from '@tts/mesh'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; +import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; +import { flipTexture } from './flipTexture'; + +/** Longer card dimension, in world units. */ +const CARD_LENGTH = 2; +/** Corner radius as a fraction of the shorter card edge. */ +const CORNER_RADIUS = 0.05; +/** Thickness of the card. */ +const CARD_THICKNESS = 0.06; + +// A 1x1 transparent placeholder so `useTexture` always receives a valid URL. +// Without it, the face/back hooks would be called conditionally, which breaks +// React's rules of hooks when switching between objects with different URL +// combinations (e.g. a deck with a back vs. a `CardCustom` with only an image). +const FALLBACK_URL = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; /** - * A playing card: a thin box with the face texture on the front and the back - * texture on the rear. Reads `CustomDeck` face/back URLs, falling back to a - * neutral color when absent. + * A playing card: a thin rounded rect with the face texture on the front and + * the back texture on the rear. Covers `Card`/`Deck`/`DeckCustom`/`Custom_Deck` + * (via `CustomDeck` face/back URLs) and `CardCustom` (via `CustomImage`). + * + * A deck image is a sheet divided into a `NumWidth` x `NumHeight` grid of + * sprites. The card footprint is sized to a single sprite's aspect ratio, and + * the face material uses UV offset/scaling to show the sprite selected by + * `CardID`. The corners stay circular because the rounded rect is built from + * the final width/height rather than scaling a square. + * + * `CardID` encodes the deck index in the hundreds place and the 1-based card + * number in the last two digits (e.g. 354 -> deck 3, card 54). The deck config + * (grid, face/back URLs) is resolved from the containing deck object's + * `CustomDeck[deckIndex]`, since a card's own `CustomDeck` may be keyed + * differently or absent. + * + * The back is treated like a tile (a single full image) unless the deck has + * `UniqueBack`, in which case it is a sheet too and gets the same sprite cell. + * It is flipped left/right so it isn't mirrored when viewed from the back of + * the card. */ export default function CardViewer({ object }: { object: TTSObject }) { - const deck = object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined; - const faceUrl = deck?.FaceURL; - const backUrl = deck?.BackURL; + const { cardId, faceUrl, backUrl, numWidth, numHeight, uniqueBack } = + resolveCardConfig(object); return ( - + ); } // Rendered inside the Canvas so `useTexture` can access the R3F store. -function CardMesh({ faceUrl, backUrl }: { faceUrl?: string; backUrl?: string }) { - const face = faceUrl ? useTexture(assetUrl(faceUrl)) : null; - const back = backUrl ? useTexture(assetUrl(backUrl)) : null; +function CardMesh({ + faceUrl, + backUrl, + numWidth, + numHeight, + uniqueBack, + cardId, +}: { + faceUrl?: string; + backUrl?: string; + numWidth?: number; + numHeight?: number; + uniqueBack: boolean; + cardId?: number; +}) { + // Always call both hooks so the hook count is stable across renders. The + // placeholder is used only when a URL is absent; presence is checked via the + // URL strings below, not the texture objects. + const face = useTexture(faceUrl ? assetUrl(faceUrl) : FALLBACK_URL); + const back = useTexture(backUrl ? assetUrl(backUrl) : FALLBACK_URL); + + // Front texture: the sprite cell from the sheet (or the full image when there + // is no grid). Cloned so the sprite offset/repeat don't leak into other cards + // that share the same sheet URL (drei caches textures globally by URL). + const faceMap = useMemo(() => { + if (!faceUrl) return null; + const tex = face.clone(); + const { repeatX, repeatY, offsetX, offsetY } = spriteUv(cardId, numWidth, numHeight); + tex.repeat.set(repeatX, repeatY); + tex.offset.set(offsetX, offsetY); + return tex; + }, [faceUrl, face, cardId, numWidth, numHeight]); + + // Back texture: a single full image (tile) unless the deck has unique backs, + // in which case it's a sheet too. Flipped left/right so it reads correctly + // instead of being mirrored on the back face. + const backMap = useMemo(() => { + if (!backUrl) return null; + const tex = back.clone(); + const { repeatX, repeatY, offsetX, offsetY } = uniqueBack + ? spriteUv(cardId, numWidth, numHeight) + : { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }; + tex.repeat.set(repeatX, repeatY); + tex.offset.set(offsetX, offsetY); + return flipTexture(tex); + }, [backUrl, back, uniqueBack, cardId, numWidth, numHeight]); + + // Build the rounded-rect geometry from the card sprite's aspect ratio. The + // front and back faces each get their own material; the walls are a solid + // white, matching TTS card tinting. + const { frontGeo, backGeo, wallsGeo } = useMemo(() => { + const img = (faceUrl ? face.image : backUrl ? back.image : undefined) as + | HTMLImageElement + | undefined; + const aspect = cardAspect(img, numWidth, numHeight); + const width = CARD_LENGTH * aspect; + const height = CARD_LENGTH; + // Radius scales with the shorter edge so corners look proportional and + // stay circular (no scaling distortion). + const shape = roundedRectShape(width, height, CORNER_RADIUS * Math.min(width, height)); + const { front: frontGeo, back: backGeo, walls: wallsGeo } = extrudeShapeParts(shape, { + height: CARD_THICKNESS, + }); + return { + frontGeo: toGeometry(frontGeo), + backGeo: toGeometry(backGeo), + wallsGeo: toGeometry(wallsGeo), + }; + }, [faceUrl, face, backUrl, back, numWidth, numHeight]); + return ( - - - - + + + + + + + + + + + ); +} + +/** 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; } \ No newline at end of file diff --git a/apps/web/src/components/viewers/TileViewer.tsx b/apps/web/src/components/viewers/TileViewer.tsx index dd6ea5d..04daf95 100644 --- a/apps/web/src/components/viewers/TileViewer.tsx +++ b/apps/web/src/components/viewers/TileViewer.tsx @@ -13,6 +13,7 @@ import { } from '@tts/mesh'; import Scene from './Scene'; import { assetUrl } from './assetUrl'; +import { flipTexture } from './flipTexture'; /** `CustomTile.Type` enum from Tabletop Simulator. */ const TileType = { @@ -62,24 +63,40 @@ function TileMesh({ // 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 { front, back, 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) }; + const parts = extrudeShapeParts(shape, { height: thickness }); + return { + front: toGeometry(parts.front), + back: toGeometry(parts.back), + walls: toGeometry(parts.walls), + }; }, [type, thickness, stretch, texture]); + // The back face maps with the same planar UVs as the front, so flip it + // left/right to avoid a mirrored texture when viewed from behind. + const backMap = useMemo(() => (texture ? flipTexture(texture) : null), [texture]); + return ( - {/* Top/bottom faces carry the tile texture. */} - + {/* Front face carries the tile texture. */} + + {/* Back face, flipped so it isn't mirrored. */} + + + {/* Sides are a solid white, matching TTS tile tinting. */} diff --git a/apps/web/src/components/viewers/TokenViewer.tsx b/apps/web/src/components/viewers/TokenViewer.tsx index 4eea612..8be779c 100644 --- a/apps/web/src/components/viewers/TokenViewer.tsx +++ b/apps/web/src/components/viewers/TokenViewer.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import * as THREE from 'three'; import type { TTSObject } from '@tts/shared'; import { - extrudeShape, + extrudeShapeParts, type ExtrudedGeometry, type Shape, type UVBounds, @@ -67,24 +67,35 @@ function TokenMesh({ url, thickness }: { url?: string; thickness: number }) { }; }, [url]); - const geometry = useMemo(() => { + const { front, back, walls } = useMemo(() => { // The traced shape and its UV framing share the same transform, so the // full image rectangle maps to the same bounds in mesh coordinates. const shape = trace ? toMeshShape(trace) : circleShape(); const uvBounds = trace ? toUvBounds(trace) : undefined; - return toGeometry( - extrudeShape(shape, { height: thickness, uvBounds }), - ); + const parts = extrudeShapeParts(shape, { height: thickness, uvBounds }); + return { + front: toGeometry(parts.front), + back: toGeometry(parts.back), + walls: toGeometry(parts.walls), + }; }, [trace, thickness]); + // A token is solid: front, back, and walls all carry the texture (projected + // UV), unlike tiles/cards where only the faces are textured. + const material = ( + + ); + return ( - - - + + {material} + {material} + {material} + ); } diff --git a/apps/web/src/components/viewers/cardResolution.test.ts b/apps/web/src/components/viewers/cardResolution.test.ts new file mode 100644 index 0000000..168c9ae --- /dev/null +++ b/apps/web/src/components/viewers/cardResolution.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import type { TTSObject } from '@tts/shared'; +import { cardAspect, resolveCardConfig, spriteUv } from './cardResolution'; + +/** A deck config, matching the shape in the Wingspan dump. */ +function deckConfig(overrides: Partial[number]> = {}) { + return { + FaceURL: 'https://example.com/face.png', + BackURL: 'https://example.com/back.png', + NumWidth: 6, + NumHeight: 4, + UniqueBack: false, + ...overrides, + }; +} + +/** A card nested inside a deck, as in the dump. */ +function card( + cardId: number, + parent: TTSObject, + ownDeck?: TTSObject['CustomDeck'], +): TTSObject { + const o: TTSObject = { + Name: 'Card', + GUID: 'abc123', + Description: '', + CardID: cardId, + Parent: parent, + }; + if (ownDeck) o.CustomDeck = ownDeck; + return o; +} + +/** A deck object carrying `CustomDeck` configs keyed by deck index. */ +function deck(keys: NonNullable): TTSObject { + return { + Name: 'DeckCustom', + GUID: 'deck1', + Description: '', + CustomDeck: keys, + }; +} + +describe('resolveCardConfig', () => { + it('resolves the deck config from the parent deck by CardID hundreds digit', () => { + const parent = deck({ + 16: deckConfig({ FaceURL: 'https://example.com/16-face.png' }), + 21: deckConfig({ FaceURL: 'https://example.com/21-face.png' }), + }); + const config = resolveCardConfig(card(1605, parent)); + + expect(config.cardId).toBe(1605); + expect(config.faceUrl).toBe('https://example.com/16-face.png'); + expect(config.backUrl).toBe('https://example.com/back.png'); + expect(config.numWidth).toBe(6); + expect(config.numHeight).toBe(4); + expect(config.uniqueBack).toBe(false); + }); + + it('ignores a card own CustomDeck keyed differently than its CardID', () => { + // From the dump: card `1605` carries `CustomDeck: {14: ...}` even though + // its deck index is 16. The parent deck's `CustomDeck[16]` is authoritative. + const parent = deck({ + 16: deckConfig({ FaceURL: 'https://example.com/16-face.png' }), + }); + const config = resolveCardConfig( + card(1605, parent, { 14: deckConfig({ FaceURL: 'https://example.com/wrong.png' }) }), + ); + + expect(config.faceUrl).toBe('https://example.com/16-face.png'); + }); + + it('resolves from the parent deck even when the card has no own CustomDeck', () => { + const parent = deck({ + 8: deckConfig({ FaceURL: 'https://example.com/8-face.png', NumWidth: 6, NumHeight: 4 }), + }); + const config = resolveCardConfig(card(800, parent)); + + expect(config.faceUrl).toBe('https://example.com/8-face.png'); + expect(config.numWidth).toBe(6); + expect(config.numHeight).toBe(4); + }); + + it('falls back to the object own CustomDeck when there is no parent', () => { + const config = resolveCardConfig({ + Name: 'Card', + GUID: 'x', + Description: '', + CardID: 2101, + CustomDeck: { 21: deckConfig({ FaceURL: 'https://example.com/own.png' }) }, + }); + + expect(config.faceUrl).toBe('https://example.com/own.png'); + expect(config.numWidth).toBe(6); + }); + + it('falls back to CustomImage for CardCustom', () => { + const config = resolveCardConfig({ + Name: 'CardCustom', + GUID: 'y', + Description: '', + CustomImage: { + ImageURL: 'https://example.com/custom.png', + ImageSecondaryURL: 'https://example.com/custom-back.png', + }, + }); + + expect(config.faceUrl).toBe('https://example.com/custom.png'); + expect(config.backUrl).toBe('https://example.com/custom-back.png'); + expect(config.numWidth).toBeUndefined(); + expect(config.uniqueBack).toBe(false); + }); + + it('reports uniqueBack from the deck config', () => { + const parent = deck({ + 3: deckConfig({ UniqueBack: true }), + }); + const config = resolveCardConfig(card(354, parent)); + + expect(config.uniqueBack).toBe(true); + }); +}); + +describe('spriteUv', () => { + it('shows the whole image when there is no grid', () => { + expect(spriteUv(undefined, undefined, undefined)).toEqual({ + repeatX: 1, + repeatY: 1, + offsetX: 0, + offsetY: 0, + }); + }); + + it('selects the first sprite (card 0) at the top-left', () => { + expect(spriteUv(800, 6, 4)).toEqual({ + repeatX: 1 / 6, + repeatY: 1 / 4, + offsetX: 0, + offsetY: 3 / 4, + }); + }); + + it('selects the last sprite (card 23) at the bottom-right', () => { + expect(spriteUv(823, 6, 4)).toEqual({ + repeatX: 1 / 6, + repeatY: 1 / 4, + offsetX: 5 / 6, + offsetY: 0, + }); + }); + + it('walks row by row across the sheet', () => { + // Card 6 in a 6-wide sheet is the first sprite of the second row. + expect(spriteUv(806, 6, 4)).toEqual({ + repeatX: 1 / 6, + repeatY: 1 / 4, + offsetX: 0, + offsetY: 2 / 4, + }); + }); + + it('clamps out-of-range card numbers', () => { + expect(spriteUv(899, 6, 4)).toEqual(spriteUv(823, 6, 4)); + expect(spriteUv(800, 6, 4)).toEqual(spriteUv(800, 6, 4)); + }); +}); + +describe('cardAspect', () => { + it('divides the sheet dimensions by the grid', () => { + const img = { width: 1200, height: 800 } as HTMLImageElement; + expect(cardAspect(img, 6, 4)).toBeCloseTo(1); + expect(cardAspect(img, 4, 4)).toBeCloseTo(1.5); + }); + + it('uses the raw image dimensions when there is no grid', () => { + const img = { width: 1200, height: 800 } as HTMLImageElement; + expect(cardAspect(img, undefined, undefined)).toBeCloseTo(1.5); + }); + + it('falls back to 1 without an image', () => { + expect(cardAspect(undefined, 6, 4)).toBe(1); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/viewers/cardResolution.ts b/apps/web/src/components/viewers/cardResolution.ts new file mode 100644 index 0000000..1bfc53f --- /dev/null +++ b/apps/web/src/components/viewers/cardResolution.ts @@ -0,0 +1,91 @@ +import type { TTSObject } from '@tts/shared'; + +/** + * Everything needed to render a card, derived from the object and its + * containing deck. Kept free of react-three so it can be unit-tested in a + * plain node environment. + */ +export interface CardRenderConfig { + /** The card's `CardID`: deck index in the hundreds place, 0-based card + * number in the last two digits (e.g. 354 -> deck 3, card 54). */ + cardId?: number; + faceUrl?: string; + backUrl?: string; + /** Grid columns of the face sheet. */ + numWidth?: number; + /** Grid rows of the face sheet. */ + numHeight?: number; + /** Whether each card has its own back sprite (a sheet) vs. a shared tile. */ + uniqueBack: boolean; +} + +/** + * Resolve a card's render config. + * + * The deck config (grid, face/back URLs) lives on the containing deck object, + * keyed by the hundreds digit of the card's `CardID`. A card's own + * `CustomDeck` may be keyed differently or absent, so the parent deck is the + * authoritative source. Falls back to the object's own `CustomDeck` (or + * `CustomImage` for `CardCustom`) when there's no parent deck. + */ +export function resolveCardConfig(object: TTSObject): CardRenderConfig { + const cardId = object.CardID; + const deckIndex = cardId != null ? Math.floor(cardId / 100) : undefined; + const deck = + object.Parent?.CustomDeck?.[deckIndex!] ?? + (object.CustomDeck ? Object.values(object.CustomDeck)[0] : undefined); + return { + cardId, + faceUrl: deck?.FaceURL ?? object.CustomImage?.ImageURL, + backUrl: deck?.BackURL ?? object.CustomImage?.ImageSecondaryURL, + numWidth: deck?.NumWidth, + numHeight: deck?.NumHeight, + uniqueBack: deck?.UniqueBack ?? false, + }; +} + +/** + * UV repeat/offset that selects a single sprite from a `NumWidth` x `NumHeight` + * sheet. `CardID` encodes the deck index in the hundreds place and the 0-based + * card number in the last two digits (e.g. 354 -> deck 3, card 54). Without a + * grid, the whole image is shown (repeat 1, offset 0). + */ +export function spriteUv( + cardId: number | undefined, + numWidth: number | undefined, + numHeight: number | undefined, +): { repeatX: number; repeatY: number; offsetX: number; offsetY: number } { + if (!numWidth || !numHeight) { + return { repeatX: 1, repeatY: 1, offsetX: 0, offsetY: 0 }; + } + // The card number is 0-based (e.g. CardID 806 -> card 6), so clamp to + // [0, numWidth*numHeight - 1] and index directly. + const cardNumber = cardId != null ? cardId % 100 : 0; + const n = Math.min(Math.max(cardNumber, 0), numWidth * numHeight - 1); + const col = n % numWidth; + const row = Math.floor(n / numWidth); + return { + repeatX: 1 / numWidth, + repeatY: 1 / numHeight, + offsetX: col / numWidth, + // Row 0 is the top of the image (v=1), so the offset counts down from 1. + offsetY: 1 - (row + 1) / numHeight, + }; +} + +/** + * The aspect ratio (width / height) of a single card sprite. For a card sheet, + * the sheet dimensions are divided by the `NumWidth`/`NumHeight` grid so the + * result reflects one card rather than the whole sheet. Falls back to 1 (a + * square) while the image is loading or when there's no image. + */ +export function cardAspect( + img: HTMLImageElement | undefined, + numWidth: number | undefined, + numHeight: number | undefined, +): number { + if (!img || !img.width || !img.height) return 1; + const w = numWidth && numWidth > 0 ? img.width / numWidth : img.width; + const h = numHeight && numHeight > 0 ? img.height / numHeight : img.height; + return w / h; +} \ No newline at end of file diff --git a/apps/web/src/components/viewers/flipTexture.test.ts b/apps/web/src/components/viewers/flipTexture.test.ts new file mode 100644 index 0000000..57dff3b --- /dev/null +++ b/apps/web/src/components/viewers/flipTexture.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { flipTexture } from './flipTexture'; + +describe('flipTexture', () => { + it('negates repeat.x and shifts offset.x to keep the region in place', () => { + const tex = new THREE.Texture(); + tex.repeat.set(0.5, 0.25); + tex.offset.set(0.3, 0.4); + + const flipped = flipTexture(tex); + + expect(flipped.repeat.x).toBeCloseTo(-0.5, 5); + expect(flipped.repeat.y).toBeCloseTo(0.25, 5); + // offset.x = 0.3 + 0.5 = 0.8; the visible region stays put while mirrored. + expect(flipped.offset.x).toBeCloseTo(0.8, 5); + expect(flipped.offset.y).toBeCloseTo(0.4, 5); + }); + + it('clones the source so the original is untouched', () => { + const tex = new THREE.Texture(); + tex.repeat.set(1, 1); + tex.offset.set(0, 0); + + const flipped = flipTexture(tex); + + expect(flipped).not.toBe(tex); + expect(tex.repeat.x).toBe(1); + expect(tex.offset.x).toBe(0); + }); + + it('flips a sprite cell correctly', () => { + // A sprite cell: repeat 1/6, offset col/6. Flipping should mirror within + // the cell, not shift it off the sheet. + const tex = new THREE.Texture(); + tex.repeat.set(1 / 6, 1 / 4); + tex.offset.set(2 / 6, 3 / 4); + + const flipped = flipTexture(tex); + + expect(flipped.repeat.x).toBeCloseTo(-1 / 6, 5); + expect(flipped.offset.x).toBeCloseTo(2 / 6 + 1 / 6, 5); + expect(flipped.offset.y).toBeCloseTo(3 / 4, 5); + }); +}); \ No newline at end of file diff --git a/apps/web/src/components/viewers/flipTexture.ts b/apps/web/src/components/viewers/flipTexture.ts new file mode 100644 index 0000000..87a592b --- /dev/null +++ b/apps/web/src/components/viewers/flipTexture.ts @@ -0,0 +1,19 @@ +import type * as THREE from 'three'; + +/** + * Return a texture flipped left/right so it reads correctly when viewed from + * behind (the back face). The back cap maps with the same planar UVs as the + * front, so without a flip the back appears mirrored. + * + * The source texture is cloned so the transform doesn't leak into other meshes + * that share the same texture (drei caches textures globally by URL). + */ +export function flipTexture(texture: THREE.Texture): THREE.Texture { + const tex = texture.clone(); + // Negate repeat.x and shift offset.x by one full repeat so the visible + // region stays in the same place while mirrored. After negation + // `repeat.x` is `-rx`, so subtracting it adds `rx` to the offset. + tex.repeat.x = -tex.repeat.x; + tex.offset.x -= tex.repeat.x; + return tex; +} \ No newline at end of file diff --git a/apps/web/src/components/viewers/register.ts b/apps/web/src/components/viewers/register.ts index cc52157..2de34ad 100644 --- a/apps/web/src/components/viewers/register.ts +++ b/apps/web/src/components/viewers/register.ts @@ -14,6 +14,7 @@ registerViewer('Tile', TileViewer); registerViewer('Custom_Tile', TileViewer); registerViewer('Custom_Token', TokenViewer); registerViewer('Card', CardViewer); +registerViewer('CardCustom', CardViewer); registerViewer('Deck', CardViewer); registerViewer('DeckCustom', CardViewer); registerViewer('Custom_Deck', CardViewer); diff --git a/packages/mesh/src/extrude.test.ts b/packages/mesh/src/extrude.test.ts index 55966d0..b9c81e7 100644 --- a/packages/mesh/src/extrude.test.ts +++ b/packages/mesh/src/extrude.test.ts @@ -62,27 +62,42 @@ describe('extrudeShape', () => { }); }); + + 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); + it('returns front, back, and walls as separate geometries', () => { + const { front, back, walls } = extrudeShapeParts(rectShape(2, 2), { height: 1 }); + // Each face: 4 outline points. + expect(front.positions.length / 3).toBe(4); + expect(back.positions.length / 3).toBe(4); // 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); + expect(front.positions.length + back.positions.length + walls.positions.length).toBe( + combined.positions.length, + ); + expect(front.indices.length + back.indices.length + walls.indices.length).toBe( + combined.indices.length, + ); }); - it('applies uvBounds to caps and walls', () => { + it('front faces +Z and back faces -Z', () => { + const { front, back } = extrudeShapeParts(rectShape(2, 2), { height: 1 }); + expect(Array.from(front.normals.slice(0, 3))).toEqual([0, 0, 1]); + expect(Array.from(back.normals.slice(0, 3))).toEqual([0, 0, -1]); + }); + + it('applies uvBounds to front, back, and walls', () => { const uvBounds = { minX: 0, minY: 0, maxX: 4, maxY: 4 }; - const { caps, walls } = extrudeShapeParts(rectShape(1, 1), { + const { front, back, 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); + // Front: bottom-left vertex at (-0.5,-0.5) -> u=-0.125. + expect(front.uvs[0]).toBeCloseTo(-0.125, 5); + // Back uses the same planar xy mapping (no mirror). + expect(back.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); diff --git a/packages/mesh/src/extrude.ts b/packages/mesh/src/extrude.ts index 1f86ce1..bf1f93f 100644 --- a/packages/mesh/src/extrude.ts +++ b/packages/mesh/src/extrude.ts @@ -1,6 +1,6 @@ import type { ExtrudedGeometry, FaceGeometry, UVBounds } from './types.js'; import type { Shape } from './shapes.js'; -import { capFaces } from './tessellate.js'; +import { backFaces, capFaces, frontFaces } from './tessellate.js'; import { wallFaces } from './walls.js'; export interface ExtrudeOptions { @@ -37,21 +37,22 @@ export function extrudeShape(shape: Shape, options: ExtrudeOptions = {}): Extrud } /** - * 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). + * Extrude a shape, returning the front face, back face, 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 } { +): { front: ExtrudedGeometry; back: ExtrudedGeometry; walls: ExtrudedGeometry } { 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, uvBounds)), + front: faceToGeometry(frontFaces(shape, height, capUvScale, uvBounds)), + back: faceToGeometry(backFaces(shape, height, capUvScale, uvBounds)), walls: faceToGeometry(wallFaces(shape, height, wallUvScale, uvBounds)), }; } diff --git a/packages/mesh/src/tessellate.ts b/packages/mesh/src/tessellate.ts index 705e8f5..f8b3c5a 100644 --- a/packages/mesh/src/tessellate.ts +++ b/packages/mesh/src/tessellate.ts @@ -32,7 +32,7 @@ export function triangulate(shape: Shape): number[] { } /** - * Build the top and bottom faces of an extruded shape. + * Build a single cap (top or bottom face) 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 @@ -43,11 +43,14 @@ export function triangulate(shape: Shape): number[] { * 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( +function buildCap( shape: Shape, height: number, - uvScale = 1, - uvBounds?: UVBounds, + uvScale: number, + uvBounds: UVBounds | undefined, + z: number, + normalZ: number, + reverse: boolean, ): FaceGeometry { const triangles = triangulate(shape); @@ -67,41 +70,70 @@ export function capFaces( 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); + positions.push(x, y, z); uvs.push(((x - minX) / spanX) * uvScale, ((y - minY) / spanY) * uvScale); - normals.push(0, 0, 1); - } - for (const t of triangles) { - indices.push(topBase + t); + normals.push(0, 0, normalZ); } - // Bottom face: same outline, flipped so triangles wind CW when viewed from - // 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(((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 - // 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); + // The bottom face reverses winding so triangles wind CW when viewed from + // below (normal toward -Z). `triangles` is a flat list of vertex indices in + // groups of 3, so step by 3. + if (reverse) { + for (let i = 0; i < triangles.length; i += 3) { + indices.push(triangles[i + 2]!, triangles[i + 1]!, triangles[i]!); + } + } else { + for (const t of triangles) { + indices.push(t); + } } return { positions, uvs, normals, indices }; } +/** The top face of an extruded shape, normal toward +Z. */ +export function frontFaces( + shape: Shape, + height: number, + uvScale = 1, + uvBounds?: UVBounds, +): FaceGeometry { + return buildCap(shape, height, uvScale, uvBounds, height, 1, false); +} + +/** The bottom face of an extruded shape, normal toward -Z. */ +export function backFaces( + shape: Shape, + height: number, + uvScale = 1, + uvBounds?: UVBounds, +): FaceGeometry { + return buildCap(shape, height, uvScale, uvBounds, 0, -1, true); +} + +/** + * Build the top and bottom faces of an extruded shape, merged into one + * `FaceGeometry` (front vertices first, then back). + */ +export function capFaces( + shape: Shape, + height: number, + uvScale = 1, + uvBounds?: UVBounds, +): FaceGeometry { + const front = frontFaces(shape, height, uvScale, uvBounds); + const back = backFaces(shape, height, uvScale, uvBounds); + const frontCount = front.positions.length / 3; + return { + positions: [...front.positions, ...back.positions], + uvs: [...front.uvs, ...back.uvs], + normals: [...front.normals, ...back.normals], + indices: [...front.indices, ...back.indices.map((i) => i + frontCount)], + }; +} + /** Compute the bounding box of a shape's outline. */ function shapeBounds(shape: Shape): { minX: number;