From 835250abdd668fdbabeae3c112d3be60da637123 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 17:37:47 +0800 Subject: [PATCH] feat(mesh): render cards, tiles, and tokens with separated front/back/walls Split extrudeShapeParts into front, back, and walls so each face can carry its own material. Flip back textures left/right on the material so they are not mirrored, and slice card faces from the deck sprite sheet via CardID. Resolve deck config from the parent deck, which is authoritative over a card's own CustomDeck. Add unit tests for card resolution, sprite UVs, and the flip helper. --- apps/web/src/components/objectIcons.tsx | 3 + .../web/src/components/viewers/CardViewer.tsx | 171 ++++++++++++++-- .../web/src/components/viewers/TileViewer.tsx | 27 ++- .../src/components/viewers/TokenViewer.tsx | 35 ++-- .../components/viewers/cardResolution.test.ts | 183 ++++++++++++++++++ .../src/components/viewers/cardResolution.ts | 91 +++++++++ .../components/viewers/flipTexture.test.ts | 45 +++++ .../web/src/components/viewers/flipTexture.ts | 19 ++ apps/web/src/components/viewers/register.ts | 1 + packages/mesh/src/extrude.test.ts | 35 +++- packages/mesh/src/extrude.ts | 13 +- packages/mesh/src/tessellate.ts | 90 ++++++--- 12 files changed, 633 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/components/viewers/cardResolution.test.ts create mode 100644 apps/web/src/components/viewers/cardResolution.ts create mode 100644 apps/web/src/components/viewers/flipTexture.test.ts create mode 100644 apps/web/src/components/viewers/flipTexture.ts 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;