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.
This commit is contained in:
2026-08-08 17:41:59 +08:00
parent f7139495fe
commit 835250abdd
12 changed files with 633 additions and 80 deletions
+3
View File
@@ -7,9 +7,12 @@ import './objectIconsData';
*/
const OBJECT_ICONS: Record<string, string[]> = {
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'],
+153 -18
View File
@@ -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 (
<Scene>
<CardMesh faceUrl={faceUrl} backUrl={backUrl} />
<CardMesh
faceUrl={faceUrl}
backUrl={backUrl}
numWidth={numWidth}
numHeight={numHeight}
uniqueBack={uniqueBack}
cardId={cardId}
/>
</Scene>
);
}
// 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 (
<mesh>
<boxGeometry args={[1.4, 2, 0.06]} />
<meshStandardMaterial
color={face || back ? '#ffffff' : '#52525b'}
map={face ?? back ?? undefined}
roughness={0.6}
/>
</mesh>
<group>
<mesh geometry={frontGeo}>
<meshStandardMaterial
color={faceMap ? '#ffffff' : '#52525b'}
map={faceMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={backGeo}>
<meshStandardMaterial
color={backMap ? '#ffffff' : '#52525b'}
map={backMap ?? undefined}
roughness={0.6}
/>
</mesh>
<mesh geometry={wallsGeo}>
<meshStandardMaterial color="#ffffff" roughness={0.6} />
</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;
}
+22 -5
View File
@@ -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 (
<group>
{/* Top/bottom faces carry the tile texture. */}
<mesh geometry={caps}>
{/* Front face carries the tile texture. */}
<mesh geometry={front}>
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
{/* Back face, flipped so it isn't mirrored. */}
<mesh geometry={back}>
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={backMap ?? undefined}
roughness={0.8}
/>
</mesh>
{/* Sides are a solid white, matching TTS tile tinting. */}
<mesh geometry={walls}>
<meshStandardMaterial color="#ffffff" roughness={0.8} />
+23 -12
View File
@@ -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 = (
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
);
return (
<mesh geometry={geometry}>
<meshStandardMaterial
color={texture ? '#ffffff' : '#52525b'}
map={texture ?? undefined}
roughness={0.8}
/>
</mesh>
<group>
<mesh geometry={front}>{material}</mesh>
<mesh geometry={back}>{material}</mesh>
<mesh geometry={walls}>{material}</mesh>
</group>
);
}
@@ -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<NonNullable<TTSObject['CustomDeck']>[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['CustomDeck']>): 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);
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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;
}
@@ -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);